Skip to content
Closed
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
67 changes: 52 additions & 15 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ import { readLocalApi } from "../localApi";
import { useDiffPanelStore } from "../diffPanelStore";
import {
collapseExpandedComposerCursor,
type ComposerSubmissionDelivery,
type ComposerSubmissionIntent,
parseStandaloneComposerSlashCommand,
} from "../composer-logic";
Expand Down Expand Up @@ -313,6 +314,7 @@ import {
import {
isQueuedMessageDue,
latestCompletedToolActivityId,
newestQueuedMessage,
type QueuedComposerMessage,
useQueuedMessages,
useQueuedMessageStore,
Expand Down Expand Up @@ -7168,21 +7170,27 @@ export default function ChatView(props: ChatViewProps) {
const onSend = async (
e?: { preventDefault: () => void },
submissionIntent: ComposerSubmissionIntent = "foreground",
directAnnotation?: {
annotation: PreviewAnnotationPayload;
image: ComposerImageAttachment | null;
options?: {
delivery?: ComposerSubmissionDelivery;
directAnnotation?: {
annotation: PreviewAnnotationPayload;
image: ComposerImageAttachment | null;
};
/** A queued message being sent now instead of the live composer draft. */
queuedMessage?: QueuedComposerMessage;
},
/** A queued message being sent now instead of the live composer draft. */
queuedMessage?: QueuedComposerMessage,
) => {
e?.preventDefault();
const delivery = options?.delivery ?? "normal";
const directAnnotation = options?.directAnnotation;
const requestedQueuedMessage = options?.queuedMessage;
// Typed out in full rather than picked from the menu. Attachments or contexts
// mean the user is sending a prompt, so those go through as usual.
if (
usageLimitsOffered &&
usageLimitsKey !== null &&
!directAnnotation &&
!queuedMessage &&
!requestedQueuedMessage &&
!composerHasNonPromptContent &&
isUsageLimitsCommand(promptRef.current)
) {
Expand Down Expand Up @@ -7246,7 +7254,7 @@ export default function ChatView(props: ChatViewProps) {
if (activePendingProgress) {
// A queued message waits until the question is answered; it must not
// be submitted as the answer.
if (directAnnotation || queuedMessage) {
if (directAnnotation || requestedQueuedMessage) {
notifyDirectAnnotationAttached();
return;
}
Expand All @@ -7258,6 +7266,29 @@ export default function ChatView(props: ChatViewProps) {
notifyDirectAnnotationAttached();
return;
}
const immediateQueueThreadKey =
delivery === "immediate" && !directAnnotation ? activeThreadKey : null;
const hasComposerContentForImmediateSend =
immediateQueueThreadKey === null
? true
: composerDraftHasUserContent({
prompt: promptRef.current,
images: sendCtx.images,
files: sendCtx.files,
persistedAttachments:
useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)
?.persistedAttachments ?? [],
terminalContexts: sendCtx.terminalContexts,
previewAnnotations: sendCtx.previewAnnotations,
reviewComments: sendCtx.reviewComments,
});
const queuedMessage =
requestedQueuedMessage ??
(immediateQueueThreadKey !== null && !hasComposerContentForImmediateSend
? newestQueuedMessage(
useQueuedMessageStore.getState().queuesByThreadKey[immediateQueueThreadKey] ?? [],
)
: undefined);
const {
images: sendContextImages,
files: composerFiles,
Expand Down Expand Up @@ -7497,11 +7528,17 @@ export default function ChatView(props: ChatViewProps) {
);
return;
}
// A send during a running turn waits in the queue. It leaves on the next
// tool boundary, when the turn ends, or when the user clicks Steer. The
// provider treats a mid-turn send as a steer of the active turn, so the
// dispatch below is the same either way.
if (!queuedMessage && !directAnnotation && phase === "running" && activeThreadKey) {
// An ordinary send during a running turn waits in the queue. It leaves on
// the next tool boundary, when the turn ends, or when the user clicks
// Steer. An explicit immediate delivery skips this gate; the provider
// treats that mid-turn dispatch as a steer.
if (
delivery !== "immediate" &&
!queuedMessage &&
!directAnnotation &&
phase === "running" &&
activeThreadKey
) {
if (composerRef.current?.validateProviderInput(promptForSend) === false) {
return;
}
Expand Down Expand Up @@ -8164,7 +8201,7 @@ export default function ChatView(props: ChatViewProps) {
// after it was queued, or the turn ended. Only one leaves per boundary; the
// take inside onSend re-anchors the rest.
const sendQueuedMessage = useEffectEvent((message: QueuedComposerMessage) => {
void onSend(undefined, message.submissionIntent, undefined, message);
void onSend(undefined, message.submissionIntent, { queuedMessage: message });
});
const nextQueuedMessage = queuedMessages[0] ?? null;
const latestToolActivityId = useMemo(
Expand Down Expand Up @@ -8211,7 +8248,7 @@ export default function ChatView(props: ChatViewProps) {
steer: (id) => {
const message = queuedMessages.find((entry) => entry.id === id);
if (!message || sendInFlightRef.current || queueBlockedByPendingRequest) return;
void onSend(undefined, message.submissionIntent, undefined, message);
void onSend(undefined, message.submissionIntent, { queuedMessage: message });
},
remove: (id) => {
if (!activeThreadKey) return;
Expand Down Expand Up @@ -9137,7 +9174,7 @@ export default function ChatView(props: ChatViewProps) {
configuredUrls={configuredPreviewUrls}
visible={rightPanelOpen}
onSendAnnotation={(annotation, image) => {
void onSend(undefined, "foreground", { annotation, image });
void onSend(undefined, "foreground", { directAnnotation: { annotation, image } });
}}
/>
</Suspense>
Expand Down
83 changes: 80 additions & 3 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
type ReactNode,
useCallback,
useEffect,
useEffectEvent,
useImperativeHandle,
useLayoutEffect,
useMemo,
Expand All @@ -67,13 +68,15 @@ import { createPortal, flushSync } from "react-dom";
import {
clampCollapsedComposerCursor,
type ComposerSubmissionIntent,
type ComposerSubmissionDelivery,
type ComposerTrigger,
collapseExpandedComposerCursor,
composerSubmissionIntentForEnter,
detectComposerTrigger,
expandCollapsedComposerCursor,
formatAssistantCitationForComposer,
replaceTextRange,
resolveComposerImmediateSendDecision,
} from "../../composer-logic";
import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
import {
Expand Down Expand Up @@ -1167,6 +1170,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
isConnecting: boolean;
isEnvironmentUnavailable: boolean;
hasSendableContent: boolean;
sendNowShortcutLabel?: string | null;
preserveComposerFocusOnPointerDown?: boolean;
onPreviousPendingQuestion: () => void;
onInterrupt: () => void;
Expand Down Expand Up @@ -1200,6 +1204,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
isEnvironmentUnavailable={props.isEnvironmentUnavailable}
isPreparingWorktree={props.isPreparingWorktree}
hasSendableContent={props.hasSendableContent}
sendNowShortcutLabel={props.sendNowShortcutLabel ?? null}
preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
onPreviousPendingQuestion={props.onPreviousPendingQuestion}
onInterrupt={props.onInterrupt}
Expand Down Expand Up @@ -1395,7 +1400,11 @@ export interface ChatComposerProps {

// Callbacks
onCompactContext: () => void;
onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void;
onSend: (
e?: { preventDefault: () => void },
intent?: ComposerSubmissionIntent,
options?: { delivery?: ComposerSubmissionDelivery },
) => void;
onInterrupt: () => void;
onImplementPlanInNewThread: () => void;
onRespondToApproval: (
Expand Down Expand Up @@ -2625,6 +2634,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
environmentUnavailable !== null ||
!composerSendState.hasSendableContent;
const collapsedComposerPrimaryActionLabel = "Send message";
const sendNowShortcutLabel = useMemo(
() =>
shortcutLabelForCommand(keybindings, "composer.sendNow", {
context: {
terminalFocus: false,
terminalOpen,
modelPickerOpen: false,
},
}),
[keybindings, terminalOpen],
);
const showMobilePendingAnswerActions =
isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;

Expand Down Expand Up @@ -3717,7 +3737,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
]);

const submitComposer = useCallback(
(event?: { preventDefault: () => void }, intent: ComposerSubmissionIntent = "foreground") => {
(
event?: { preventDefault: () => void },
intent: ComposerSubmissionIntent = "foreground",
delivery: ComposerSubmissionDelivery = "normal",
) => {
if (noProviderAvailable || isSendDisabled) {
event?.preventDefault();
return;
Expand Down Expand Up @@ -3757,7 +3781,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
// ChatView reports its final composed-input preflight through the
// composer handle before its first asynchronous send step.
providerInputRejectedRef.current = false;
onSend(sendEvent, intent);
onSend(sendEvent, intent, { delivery });
return !providerInputRejectedRef.current;
},
});
Expand All @@ -3779,6 +3803,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
shouldBlurMobileComposerOnSubmit,
],
);

const handleSendNowShortcut = useEffectEvent((event: globalThis.KeyboardEvent) => {
if (event.defaultPrevented) return;
const target = event.target;
if (
!(target instanceof HTMLElement) ||
!target.isContentEditable ||
!composerFormRef.current?.contains(target)
) {
return;
}

const command = resolveShortcutCommand(event, keybindings, {
context: {
terminalFocus: getTerminalFocusOwner() !== null,
terminalOpen,
modelPickerOpen: isComposerModelPickerOpen,
},
});
if (command !== "composer.sendNow") return;
const menuOpen = composerMenuOpenRef.current || resolveActiveComposerTrigger().trigger !== null;
const decision = resolveComposerImmediateSendDecision({
command,
isComposing: event.isComposing,
isImeKeydown: event.keyCode === 229,
repeat: event.repeat,
menuOpen,
hasPendingRequest:
isComposerApprovalState || activePendingProgress !== null || pendingUserInputs.length > 0,
});
// Leave Enter to the command menu and Lexical's IME handling. Other
// remapped keys still use this capture path while the editor is focused.
if (decision === "pass") return;
event.preventDefault();
event.stopPropagation();
if (decision === "block") return;

const submissionIntent = composerSubmissionIntentForEnter({
isMobileViewport,
shiftKey: event.shiftKey,
modifierKey: event.metaKey || event.ctrlKey,
isDraftThread: routeKind === "draft",
});
submitComposer(event, submissionIntent ?? "foreground", "immediate");
});

useEffect(() => {
const handler = (event: globalThis.KeyboardEvent) => handleSendNowShortcut(event);
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, []);

const submitCitationAndSend = useCallback(() => {
const intent = composerSubmissionIntentForEnter({
isMobileViewport,
Expand Down Expand Up @@ -6844,6 +6920,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
isPreparingWorktree={isPreparingWorktree}
hasSendableContent={composerSendState.hasSendableContent}
sendNowShortcutLabel={sendNowShortcutLabel}
preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting}
onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
onInterrupt={handleInterruptPrimaryAction}
Expand Down
16 changes: 15 additions & 1 deletion apps/web/src/components/chat/ComposerPrimaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../Sideb
import { Button } from "../ui/button";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
import { Spinner } from "../ui/spinner";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { composerFloatingLayerProps } from "./composerEventScope";

interface PendingActionState {
Expand All @@ -28,6 +29,7 @@ interface ComposerPrimaryActionsProps {
isEnvironmentUnavailable: boolean;
isPreparingWorktree: boolean;
hasSendableContent: boolean;
sendNowShortcutLabel?: string | null;
preserveComposerFocusOnPointerDown?: boolean;
onPreviousPendingQuestion: () => void;
onInterrupt: () => void;
Expand Down Expand Up @@ -68,6 +70,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
isEnvironmentUnavailable,
isPreparingWorktree,
hasSendableContent,
sendNowShortcutLabel = null,
preserveComposerFocusOnPointerDown = false,
onPreviousPendingQuestion,
onInterrupt,
Expand Down Expand Up @@ -273,12 +276,23 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
return sendButton;
}

const sendButtonWithShortcutHint = sendNowShortcutLabel ? (
<Tooltip>
<TooltipTrigger render={sendButton} />
<TooltipPopup side="top">
Queue message, or press {sendNowShortcutLabel} to send immediately
</TooltipPopup>
</Tooltip>
) : (
sendButton
);

// While a turn runs, a sendable draft queues for the next tool boundary, so
// the send button stays next to Stop on every viewport.
return (
<>
{renderStopGenerationButton(false)}
{hasSendableContent ? sendButton : null}
{hasSendableContent ? sendButtonWithShortcutHint : null}
</>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe("KeybindingsSettings.logic", () => {
"composer.workspace",
"composer.branch",
"composer.previousWorktree",
"composer.sendNow",
"modelPicker.previousProvider",
"modelPicker.nextProvider",
"thread.copyReference",
Expand Down Expand Up @@ -203,6 +204,7 @@ describe("KeybindingsSettings.logic", () => {
it("formats static and project script command labels", () => {
expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle");
expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle");
expect(commandLabel("composer.sendNow")).toBe("Composer: Send immediately");
expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db");
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ export function buildKeybindingCommandOptions(

export function commandLabel(command: KeybindingCommand): string {
if (command === "thread.copyReference") return "Pull Request: Copy Link or Thread ID";
if (command === "composer.sendNow") return "Composer: Send immediately";
const raw = String(command);
if (raw.startsWith("script.") && raw.endsWith(".run")) {
return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`;
Expand Down
Loading
Loading