diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index 34b4490ee2c3..110cfb7763ad 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -63,6 +63,7 @@ import {
usePromptStashStore,
type PromptStashEntry,
} from "../../promptStashStore";
+import { ComposerSendButton, composerSendButtonLabel } from "./ComposerSendButton";
import { ComposerStashBadge } from "./ComposerStashBadge";
import { ComposerStashMenu } from "./ComposerStashMenu";
import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression";
@@ -82,6 +83,7 @@ import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts
import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments";
import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards";
import {
+ shouldCollapseRestingComposer,
shouldUseCompactComposerPrimaryActions,
shouldUseCompactComposerFooter,
} from "../composerFooterLayout";
@@ -183,6 +185,7 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children:
return createPortal(
contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
-function isInsideComposerFloatingLayer(element: Element): boolean {
- return element.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null;
+/**
+ * Whether focus sits in any floating layer. Base UI portals every popup to the
+ * document root, so this cannot tell a composer-owned popup from unrelated app
+ * chrome — the composer stays expanded for both, and reconciles once focus
+ * settles somewhere that is not a popup (see `scheduleComposerCollapseCheck`).
+ */
+function isInsideFloatingLayer(element: Element): boolean {
+ return (
+ element.closest(`[${COMPOSER_FLOATING_LAYER_ATTR}="true"]`) !== null ||
+ element.closest(FLOATING_LAYER_SELECTOR) !== null
+ );
}
const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {
@@ -950,6 +972,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
null,
);
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
+ // A ref cannot drive the collapse gate, and images being compressed are not
+ // in composerImages yet. Bumping this on every change to
+ // pendingImageCompressionsRef re-renders so the gate can read the live count
+ // for the *active* thread — a paste in another thread must not pin this one.
+ const [pendingImageCompressionVersion, setPendingImageCompressionVersion] = useState(0);
const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
@@ -965,8 +992,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
active: false,
});
const isMobileViewport = useMediaQuery("max-sm");
- const isComposerCollapsedMobile =
- isMobileViewport && !forceExpandedOnMobile && !isComposerFocused;
// ------------------------------------------------------------------
// Refs
@@ -980,9 +1005,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const composerMenuItemsRef = useRef
([]);
const activeComposerMenuItemRef = useRef(null);
const composerBlurFrameRef = useRef(null);
- const mobileComposerExpandFrameRef = useRef(null);
- const mobileComposerExpandReleaseFrameRef = useRef(null);
- const mobileComposerExpandInFlightRef = useRef(false);
+ const composerExpandFrameRef = useRef(null);
+ const composerExpandReleaseFrameRef = useRef(null);
+ const composerExpandInFlightRef = useRef(false);
+ /** Set when a blur check deferred because focus was inside a popup. */
+ const composerFocusReconcilePendingRef = useRef(false);
+ /** Forward handle: the expand path runs before this callback is defined. */
+ const scheduleComposerCollapseCheckRef = useRef<(() => void) | null>(null);
+ /** Set when a blur arrived mid-expand and still needs to be honoured. */
+ const composerBlurDuringExpandRef = useRef(false);
+ /** Mirrors the editor's `disabled` prop for the dependency-free expand path. */
+ const composerEditorDisabledRef = useRef(false);
const stashPulseKeyRef = useRef(0);
const stashPulseTimeoutRef = useRef(null);
/**
@@ -1023,6 +1056,55 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
],
);
+ // The version is the dependency, not the value: it changes whenever the ref
+ // mutates, which is what makes this recompute for the active thread.
+ const hasPendingImageCompressionForActiveThread = useMemo(
+ () =>
+ activeThreadId !== null && (pendingImageCompressionsRef.current.get(activeThreadId) ?? 0) > 0,
+ [activeThreadId, pendingImageCompressionVersion],
+ );
+
+ // Attachments outlive the prompt text in the collapsed row, which renders
+ // words only, so they are what keeps the composer open at rest. Images still
+ // compressing count too: they are not in composerImages yet, and collapsing
+ // mid-compression would flash the resting row and read as a failed attach.
+ const composerHasAttachments =
+ hasPendingImageCompressionForActiveThread ||
+ composerImages.length > 0 ||
+ composerElementContexts.length > 0 ||
+ composerPreviewAnnotations.length > 0 ||
+ composerReviewComments.length > 0 ||
+ // Raw length, not the sendable subset: expired contexts still render as
+ // chips, so they have to keep the composer open like any other chip.
+ composerTerminalContexts.length > 0;
+
+ // Approvals and pending inputs have collapsed variants that keep their
+ // controls reachable, but the plan follow-up banner and its Implement /
+ // Refine actions only exist in the expanded tree.
+ const composerHasActionableChrome = showPlanFollowUpPrompt && activeProposedPlan !== null;
+
+ const isComposerCollapsed = shouldCollapseRestingComposer({
+ isFocused: isComposerFocused,
+ hasAttachments: composerHasAttachments,
+ hasActionableChrome: composerHasActionableChrome,
+ // All three live in the expanded tree and are reachable from global
+ // keybindings, so collapsing would open nothing (the menus) or swallow the
+ // save confirmation (the pulse, which clears itself after 1200ms).
+ hasTransientChrome: isStashMenuOpen || isComposerModelPickerOpen || stashPulse.active,
+ // Every in-flight signal, not just a running turn: the footer owns the
+ // Stop control, the send spinner and the "Preparing worktree" label. An
+ // outstanding request is excluded — the session stays "running" while it
+ // waits, and approvals and pending inputs carry their actions in their own
+ // collapsed rows.
+ isBusy:
+ (phase === "running" || isSendBusy || isConnecting || isPreparingWorktree) &&
+ activePendingApproval === null &&
+ pendingUserInputs.length === 0,
+ // The draft hero sets this without a viewport check, but it is a mobile-only
+ // override — the hero composer should still collapse at rest on desktop.
+ forceExpanded: forceExpandedOnMobile && isMobileViewport,
+ });
+
// ------------------------------------------------------------------
// Derived: composer trigger / menu
// ------------------------------------------------------------------
@@ -1150,8 +1232,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isComposerApprovalState ||
pendingUserInputs.length > 0 ||
(showPlanFollowUpPrompt && activeProposedPlan !== null);
- const showCollapsedMobilePromptRow =
- isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
+ const showCollapsedPromptRow =
+ isComposerCollapsed && !isComposerApprovalState && pendingUserInputs.length === 0;
const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
const composerFooterActionLayoutKey = useMemo(() => {
@@ -1253,9 +1335,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
projectSelectionRequired ||
environmentUnavailable !== null ||
!composerSendState.hasSendableContent;
- const collapsedComposerPrimaryActionLabel = "Send message";
+ const collapsedComposerPrimaryActionLabel = composerSendButtonLabel({
+ isEnvironmentUnavailable:
+ environmentUnavailable !== null || noProviderAvailable || projectSelectionRequired,
+ sendDisabledReason,
+ isConnecting,
+ isPreparingWorktree,
+ isSendBusy,
+ });
+ // Mirrors the editor's own `disabled` prop: expanding into an editor that
+ // cannot take focus would flash the composer open and straight back shut.
+ const collapsedComposerExpandDisabled =
+ isConnecting || isComposerApprovalState || projectSelectionRequired;
+ composerEditorDisabledRef.current = collapsedComposerExpandDisabled;
const showMobilePendingAnswerActions =
- isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
+ isMobileViewport && !isComposerCollapsed && pendingPrimaryAction !== null;
// ------------------------------------------------------------------
// Prompt helpers
@@ -1605,6 +1699,84 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
// ------------------------------------------------------------------
// Callbacks: prompt replacement / menu
// ------------------------------------------------------------------
+ /**
+ * Expands the composer, then focuses it once React has actually rendered the
+ * editor. While collapsed the editor sits behind `display:none`, where
+ * `focus()` is a no-op — so this retries across frames until the element is
+ * focusable rather than assuming one frame is enough, and reconciles the
+ * collapse state if focus never lands (otherwise a missed focus would leave
+ * the composer expanded with no caret).
+ */
+ const expandComposerThenFocus = useCallback(
+ (focusEditor?: (editor: ComposerPromptEditorHandle) => void) => {
+ // A disabled editor is `contenteditable="false"`, so focus can never
+ // land. Starting the handshake would expand, miss every attempt, then
+ // reconcile straight back to collapsed — a flash with nothing to show
+ // for it. Leave the composer exactly as it is instead.
+ if (composerEditorDisabledRef.current) {
+ return;
+ }
+ if (composerBlurFrameRef.current !== null) {
+ window.cancelAnimationFrame(composerBlurFrameRef.current);
+ composerBlurFrameRef.current = null;
+ }
+ if (composerExpandFrameRef.current !== null) {
+ window.cancelAnimationFrame(composerExpandFrameRef.current);
+ }
+ if (composerExpandReleaseFrameRef.current !== null) {
+ window.cancelAnimationFrame(composerExpandReleaseFrameRef.current);
+ }
+ composerExpandInFlightRef.current = true;
+ setIsComposerFocused(true);
+
+ // A handful of frames is ample for React to commit; the cap stops a
+ // detached composer from retrying forever.
+ let attemptsRemaining = 5;
+ const attemptFocus = () => {
+ composerExpandFrameRef.current = null;
+ const editor = composerEditorRef.current;
+ const surface = composerSurfaceRef.current;
+ const editorIsRendered = surface !== null && surface.isConnected;
+ if (editor && editorIsRendered) {
+ if (focusEditor) {
+ focusEditor(editor);
+ } else {
+ editor.focusAtEnd();
+ }
+ }
+ // Success means the *editor* took focus, not merely something inside
+ // the surface: the collapsed expand control lives in there too, and
+ // counting it would stop the retries while the editor is still hidden.
+ const editorRoot = surface?.querySelector('[contenteditable="true"]') ?? null;
+ const focused =
+ editorRoot !== null &&
+ document.activeElement instanceof Node &&
+ editorRoot.contains(document.activeElement);
+ attemptsRemaining -= 1;
+ if (!focused && attemptsRemaining > 0) {
+ composerExpandFrameRef.current = window.requestAnimationFrame(attemptFocus);
+ return;
+ }
+ composerExpandReleaseFrameRef.current = window.requestAnimationFrame(() => {
+ composerExpandReleaseFrameRef.current = null;
+ composerExpandInFlightRef.current = false;
+ // Re-check when focus never landed (nothing would blur it later), or
+ // when a blur arrived while the lock was held and was dropped.
+ const hadDeferredBlur = composerBlurDuringExpandRef.current;
+ composerBlurDuringExpandRef.current = false;
+ if (!focused || hadDeferredBlur) {
+ scheduleComposerCollapseCheckRef.current?.();
+ }
+ });
+ };
+ composerExpandFrameRef.current = window.requestAnimationFrame(attemptFocus);
+ },
+ [],
+ );
+ const expandComposer = useCallback(() => {
+ expandComposerThenFocus();
+ }, [expandComposerThenFocus]);
+
const applyPromptReplacement = useCallback(
(
rangeStart: number,
@@ -1640,8 +1812,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
setComposerCursor(nextCursor);
setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor));
if (options?.focusEditorAfterReplace !== false) {
- window.requestAnimationFrame(() => {
- composerEditorRef.current?.focusAt(nextCursor);
+ expandComposerThenFocus((editor) => {
+ editor.focusAt(nextCursor);
});
}
return true;
@@ -1649,6 +1821,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
[
activePendingProgress?.activeQuestion,
activePendingUserInput,
+ expandComposerThenFocus,
onChangeActivePendingUserInputCustomAnswer,
promptRef,
setPrompt,
@@ -1888,28 +2061,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
shouldBlurMobileComposerOnSubmit,
],
);
- const expandMobileComposer = useCallback(() => {
- if (composerBlurFrameRef.current !== null) {
- window.cancelAnimationFrame(composerBlurFrameRef.current);
- composerBlurFrameRef.current = null;
- }
- if (mobileComposerExpandFrameRef.current !== null) {
- window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
- }
- if (mobileComposerExpandReleaseFrameRef.current !== null) {
- window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
- }
- mobileComposerExpandInFlightRef.current = true;
- setIsComposerFocused(true);
- mobileComposerExpandFrameRef.current = window.requestAnimationFrame(() => {
- mobileComposerExpandFrameRef.current = null;
- composerEditorRef.current?.focusAtEnd();
- mobileComposerExpandReleaseFrameRef.current = window.requestAnimationFrame(() => {
- mobileComposerExpandReleaseFrameRef.current = null;
- mobileComposerExpandInFlightRef.current = false;
- });
- });
- }, []);
// ------------------------------------------------------------------
// Callbacks: command key
@@ -1970,7 +2121,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
};
}, []);
- /** Briefly highlight the badge so the save registers without a flourish. */
+ /**
+ * Briefly highlight the badge so the save registers without a flourish. The
+ * badge only exists in the expanded tree, so a resting composer is held open
+ * for the pulse — otherwise ⌘S would clear the draft with no visible
+ * confirmation — and released again when the pulse ends. The editor is left
+ * alone: stashing should not steal the caret from wherever the user is.
+ */
const pulseStashBadge = useCallback(() => {
stashPulseKeyRef.current += 1;
setStashPulse({ key: stashPulseKeyRef.current, active: true });
@@ -2083,9 +2240,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
// Only yank the caret to the end when text was actually inserted;
// restoring images alone should leave the user where they were typing.
if (promptChanged) {
- window.requestAnimationFrame(() => {
- composerEditorRef.current?.focusAtEnd();
- });
+ expandComposerThenFocus();
}
},
[
@@ -2183,10 +2338,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
// preview annotations, and review comments are not stashable, so
// destroying them here would be unrecoverable.
promptRef.current = "";
+ // ⌘S is a global shortcut, so it fires with focus anywhere. Clearing the
+ // editor pulls focus into it, which would expand a resting composer and
+ // leave it open until the user clicked away; put focus back where it was.
+ const focusBeforeStash = document.activeElement;
+ const composerSurface = composerSurfaceRef.current;
+ const focusWasOutsideComposer =
+ focusBeforeStash instanceof HTMLElement &&
+ composerSurface !== null &&
+ !composerSurface.contains(focusBeforeStash);
clearComposerDraftPromptAndImages(stashTarget);
setComposerCursor(0);
setComposerTrigger(null);
pulseStashBadge();
+ if (focusWasOutsideComposer) {
+ window.requestAnimationFrame(() => {
+ // The origin can be gone if the transcript re-rendered; leave focus
+ // alone rather than throwing it somewhere arbitrary.
+ if (document.activeElement !== focusBeforeStash && focusBeforeStash.isConnected) {
+ focusBeforeStash.focus();
+ }
+ });
+ }
if (evicted) {
toastManager.add({
@@ -2369,6 +2542,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
if (acceptedFiles.length === 0) return;
pendingImageCompressionsRef.current.set(threadId, pendingCount + acceptedFiles.length);
+ setPendingImageCompressionVersion((version) => version + 1);
try {
const nextImages: ComposerImageAttachment[] = [];
let compressionError: string | null = null;
@@ -2415,6 +2589,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
} else {
pendingImageCompressionsRef.current.delete(threadId);
}
+ setPendingImageCompressionVersion((version) => version + 1);
}
};
@@ -2499,10 +2674,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
void onImplementPlanInNewThread();
}, [onImplementPlanInNewThread]);
const scheduleComposerCollapseCheck = useCallback(() => {
- if (!isMobileViewport) {
- return;
- }
- if (mobileComposerExpandInFlightRef.current) {
+ if (composerExpandInFlightRef.current) {
+ // A blur during the expand handshake is real, but acting on it now would
+ // race the focus we are about to place. Replay it once the lock clears.
+ composerBlurDuringExpandRef.current = true;
return;
}
if (composerBlurFrameRef.current !== null) {
@@ -2510,14 +2685,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
composerBlurFrameRef.current = window.requestAnimationFrame(() => {
composerBlurFrameRef.current = null;
- if (mobileComposerExpandInFlightRef.current) {
+ if (composerExpandInFlightRef.current) {
+ composerBlurDuringExpandRef.current = true;
return;
}
const composerSurface = composerSurfaceRef.current;
const activeElement = document.activeElement;
- if (activeElement instanceof Element && isInsideComposerFloatingLayer(activeElement)) {
+ if (activeElement instanceof Element && isInsideFloatingLayer(activeElement)) {
+ // A popup has focus. It may be the composer's own (model picker, stash
+ // menu) or unrelated app chrome, and portalling makes those
+ // indistinguishable here — so defer rather than guess, and re-check
+ // when focus lands somewhere that is not a popup.
+ composerFocusReconcilePendingRef.current = true;
return;
}
+ composerFocusReconcilePendingRef.current = false;
if (
composerSurface &&
activeElement instanceof Node &&
@@ -2527,18 +2709,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
setIsComposerFocused(false);
});
- }, [isMobileViewport]);
+ }, []);
+ scheduleComposerCollapseCheckRef.current = scheduleComposerCollapseCheck;
+
+ // Popups portal to the document root, so a blur into one cannot be attributed
+ // to the composer or to unrelated app chrome. The check defers in that case;
+ // this re-runs it as soon as focus lands outside every popup, so closing an
+ // unrelated menu no longer leaves the composer pinned open.
+ useEffect(() => {
+ const reconcile = (target: EventTarget | null) => {
+ if (!composerFocusReconcilePendingRef.current) return;
+ if (target instanceof Element && isInsideFloatingLayer(target)) return;
+ composerFocusReconcilePendingRef.current = false;
+ scheduleComposerCollapseCheck();
+ };
+ const handleFocusIn = (event: FocusEvent) => reconcile(event.target);
+ // Dismissing a popup by clicking empty space moves focus to , which
+ // fires no focusin, so pointerdown is the fallback path.
+ const handlePointerDown = (event: PointerEvent) => reconcile(event.target);
+ document.addEventListener("focusin", handleFocusIn, true);
+ document.addEventListener("pointerdown", handlePointerDown, true);
+ return () => {
+ document.removeEventListener("focusin", handleFocusIn, true);
+ document.removeEventListener("pointerdown", handlePointerDown, true);
+ };
+ }, [scheduleComposerCollapseCheck]);
useEffect(() => {
return () => {
if (composerBlurFrameRef.current !== null) {
window.cancelAnimationFrame(composerBlurFrameRef.current);
}
- if (mobileComposerExpandFrameRef.current !== null) {
- window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
+ if (composerExpandFrameRef.current !== null) {
+ window.cancelAnimationFrame(composerExpandFrameRef.current);
}
- if (mobileComposerExpandReleaseFrameRef.current !== null) {
- window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
+ if (composerExpandReleaseFrameRef.current !== null) {
+ window.cancelAnimationFrame(composerExpandReleaseFrameRef.current);
}
};
}, []);
@@ -2550,10 +2756,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
composerRef,
() => ({
focusAtEnd: () => {
- composerEditorRef.current?.focusAtEnd();
+ expandComposerThenFocus();
},
focusAt: (cursor: number) => {
- composerEditorRef.current?.focusAt(cursor);
+ expandComposerThenFocus((editor) => {
+ editor.focusAt(cursor);
+ });
},
addDroppedFiles: (files: File[]) => {
void addComposerImages(files);
@@ -2619,8 +2827,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
promptRef.current = insertion.prompt;
setComposerCursor(nextCollapsedCursor);
setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor));
- window.requestAnimationFrame(() => {
- composerEditorRef.current?.focusAt(nextCollapsedCursor);
+ expandComposerThenFocus((editor) => {
+ editor.focusAt(nextCollapsedCursor);
});
},
getSendContext: () => ({
@@ -2701,7 +2909,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
>
{
const activeElement = event.target;
if (
- isComposerCollapsedMobile &&
+ isComposerCollapsed &&
activeElement instanceof HTMLElement &&
activeElement.closest('[data-chat-composer-collapsed-controls="true"]')
) {
@@ -2727,7 +2935,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
scheduleComposerCollapseCheck();
}}
>
- {!isComposerCollapsedMobile &&
+ {!isComposerCollapsed &&
(activePendingApproval ? (
) : null)}
- {isComposerCollapsedMobile && activePendingApproval ? (
+ {isComposerCollapsed && activePendingApproval ? (
- ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
+ ) : isComposerCollapsed && pendingUserInputs.length > 0 ? (
event.preventDefault()}
- onClick={expandMobileComposer}
+ onClick={expandComposer}
aria-label="Write custom answer"
>
{activePendingProgress?.customAnswer || "Write custom answer"}
@@ -2834,47 +3042,52 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
) : null}
- {showCollapsedMobilePromptRow ? (
-
+ {showCollapsedPromptRow ? (
+
-
+ />
) : null}
@@ -2883,7 +3096,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
className={cn(
"relative px-3 pb-2 sm:px-4",
hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
- isComposerCollapsedMobile && "hidden",
+ isComposerCollapsed && "hidden",
)}
>
)}
- {!isComposerCollapsedMobile &&
+ {!isComposerCollapsed &&
!isComposerApprovalState &&
pendingUserInputs.length === 0 &&
composerPreviewAnnotations.length > 0 && (
@@ -2942,7 +3155,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
/>
)}
- {!isComposerCollapsedMobile &&
+ {!isComposerCollapsed &&
!isComposerApprovalState &&
pendingUserInputs.length === 0 &&
composerReviewComments.length > 0 && (
@@ -2955,7 +3168,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
/>
)}
- {!isComposerCollapsedMobile &&
+ {!isComposerCollapsed &&
!isComposerApprovalState &&
pendingUserInputs.length === 0 &&
composerElementContexts.length > 0 && (
@@ -2968,7 +3181,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
/>
)}
- {!isComposerCollapsedMobile &&
+ {!isComposerCollapsed &&
!isComposerApprovalState &&
pendingUserInputs.length === 0 &&
composerImages.some(
@@ -3121,7 +3334,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
/>
{/* Bottom toolbar */}
- {isComposerCollapsedMobile ? null : activePendingApproval ? (
+ {isComposerCollapsed ? null : activePendingApproval ? (
(
+ isBusy={isConnecting || isSendBusy}
+ label={composerSendButtonLabel({
+ isEnvironmentUnavailable,
+ sendDisabledReason,
+ isConnecting,
+ isPreparingWorktree,
+ isSendBusy,
+ })}
+ {...(preserveComposerFocusOnPointerDown ? { preserveComposerFocusOnPointerDown: true } : {})}
+ />
);
if (!isRunning) {
diff --git a/apps/web/src/components/chat/ComposerSendButton.tsx b/apps/web/src/components/chat/ComposerSendButton.tsx
new file mode 100644
index 000000000000..51654f0c2452
--- /dev/null
+++ b/apps/web/src/components/chat/ComposerSendButton.tsx
@@ -0,0 +1,97 @@
+import { memo, type PointerEventHandler } from "react";
+import { useEnvironmentIdentificationMode } from "~/hooks/useSettings";
+import { cn } from "~/lib/utils";
+import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
+import { Spinner } from "../ui/spinner";
+
+const preventPointerFocus: PointerEventHandler = (event) => {
+ event.preventDefault();
+};
+
+/**
+ * Resolves the send control's accessible name. The label carries the reason a
+ * send is unavailable, so a disabled button still explains itself.
+ */
+export function composerSendButtonLabel(input: {
+ isEnvironmentUnavailable: boolean;
+ sendDisabledReason: string | null;
+ isConnecting: boolean;
+ isPreparingWorktree: boolean;
+ isSendBusy: boolean;
+}): string {
+ if (input.isEnvironmentUnavailable) return "Environment disconnected";
+ if (input.sendDisabledReason !== null) return input.sendDisabledReason;
+ if (input.isConnecting) return "Connecting";
+ if (input.isPreparingWorktree) return "Preparing worktree";
+ if (input.isSendBusy) return "Sending";
+ return "Send message";
+}
+
+interface ComposerSendButtonProps {
+ disabled: boolean;
+ label: string;
+ /** Swaps the glyph for a spinner while a send is in flight. */
+ isBusy: boolean;
+ /** Submits via the enclosing form; the collapsed row calls `onClick` instead. */
+ type: "submit" | "button";
+ preserveComposerFocusOnPointerDown?: boolean;
+ onClick?: (event: React.MouseEvent) => void;
+ /** Marks the collapsed row's copy so focusing it does not expand the composer. */
+ collapsedControl?: boolean;
+}
+
+/**
+ * The composer's send control. Both the expanded footer and the collapsed
+ * resting row render this, so clicking the row swaps the composer around a
+ * button that keeps the same weight, shadow and glyph.
+ */
+export const ComposerSendButton = memo(function ComposerSendButton({
+ disabled,
+ label,
+ isBusy,
+ type,
+ preserveComposerFocusOnPointerDown,
+ onClick,
+ collapsedControl,
+}: ComposerSendButtonProps) {
+ const environmentIdentificationMode = useEnvironmentIdentificationMode();
+ const stageBackdropVariant = useSidebarStageBackdropVariant(
+ environmentIdentificationMode === "artwork",
+ );
+
+ return (
+
+ );
+});
diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts
index 92e054df52dc..323b16012c7f 100644
--- a/apps/web/src/components/composerFooterLayout.test.ts
+++ b/apps/web/src/components/composerFooterLayout.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
+ shouldCollapseRestingComposer,
shouldUseCompactComposerPrimaryActions,
shouldUseCompactComposerFooter,
} from "./composerFooterLayout";
@@ -35,6 +36,45 @@ describe("shouldUseCompactComposerFooter", () => {
});
});
+describe("shouldCollapseRestingComposer", () => {
+ const resting = {
+ isFocused: false,
+ hasAttachments: false,
+ hasActionableChrome: false,
+ hasTransientChrome: false,
+ isBusy: false,
+ forceExpanded: false,
+ };
+
+ it("collapses an empty unfocused composer", () => {
+ expect(shouldCollapseRestingComposer(resting)).toBe(true);
+ });
+
+ it("stays expanded while focused", () => {
+ expect(shouldCollapseRestingComposer({ ...resting, isFocused: true })).toBe(false);
+ });
+
+ it("stays expanded when attachments would otherwise be hidden", () => {
+ expect(shouldCollapseRestingComposer({ ...resting, hasAttachments: true })).toBe(false);
+ });
+
+ it("stays expanded while work is in flight so Stop and the spinner stay reachable", () => {
+ expect(shouldCollapseRestingComposer({ ...resting, isBusy: true })).toBe(false);
+ });
+
+ it("stays expanded for approval, pending input and plan follow-up panels", () => {
+ expect(shouldCollapseRestingComposer({ ...resting, hasActionableChrome: true })).toBe(false);
+ });
+
+ it("stays expanded while keybinding-surfaced chrome is showing", () => {
+ expect(shouldCollapseRestingComposer({ ...resting, hasTransientChrome: true })).toBe(false);
+ });
+
+ it("never collapses when expansion is forced", () => {
+ expect(shouldCollapseRestingComposer({ ...resting, forceExpanded: true })).toBe(false);
+ });
+});
+
describe("shouldUseCompactComposerPrimaryActions", () => {
it("matches the wide footer breakpoint", () => {
expect(
diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts
index 5e0b3a8ea379..dba1af882bee 100644
--- a/apps/web/src/components/composerFooterLayout.ts
+++ b/apps/web/src/components/composerFooterLayout.ts
@@ -11,6 +11,42 @@ export function shouldUseCompactComposerFooter(
return width !== null && width < breakpoint;
}
+/**
+ * Whether the composer rests as a single row instead of the full editor plus
+ * toolbar. Unfocused is the base rule: a drafted message still collapses,
+ * shown truncated in the collapsed row, so parking a draft does not cost the
+ * transcript ~90px.
+ *
+ * It stays expanded whenever collapsing would hide something the user needs to
+ * act on. The collapsed row carries only prompt text and a send button, so
+ * staged attachments, a running turn's Stop control, and the plan follow-up
+ * actions each keep the full composer mounted. Approvals and pending inputs
+ * are not listed here because they render their own collapsed variants.
+ *
+ * `hasTransientChrome` covers the things a global keybinding can surface inside
+ * the expanded tree: the stash menu, the model picker, and the stash badge
+ * pulse that confirms a save. Collapsing would leave those shortcuts opening
+ * nothing, or firing with no visible feedback.
+ */
+export function shouldCollapseRestingComposer(input: {
+ isFocused: boolean;
+ hasAttachments: boolean;
+ hasActionableChrome: boolean;
+ hasTransientChrome: boolean;
+ isBusy: boolean;
+ forceExpanded: boolean;
+}): boolean {
+ if (input.forceExpanded || input.isFocused) {
+ return false;
+ }
+ return (
+ !input.hasAttachments &&
+ !input.hasActionableChrome &&
+ !input.hasTransientChrome &&
+ !input.isBusy
+ );
+}
+
export function shouldUseCompactComposerPrimaryActions(
width: number | null,
options?: { hasWideActions?: boolean },