diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 37ad39c1..063e3497 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -225,6 +225,7 @@ import { import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { useSpinnerVerb } from "../../hooks/useSpinnerVerb"; import type { ReviewCommentContext } from "../../reviewCommentContext"; const runtimeModeConfig: Record< @@ -411,6 +412,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( onInterrupt: () => void; onImplementPlanInNewThread: () => void; }) { + // Only while actually running and not already showing the worktree-prep + // label, so the two never compete for the same slot. + const spinnerVerb = useSpinnerVerb(props.isRunning && !props.isPreparingWorktree); return ( <> {props.activeContextWindow ? ( @@ -421,6 +425,10 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( ) : null} {props.isPreparingWorktree ? ( Preparing worktree... + ) : spinnerVerb ? ( + ) : null} {}, onInterrupt: () => {}, onImplementPlanInNewThread: () => {}, @@ -58,6 +59,7 @@ function renderStandaloneStop() { isEnvironmentUnavailable: false, isPreparingWorktree: false, hasSendableContent: false, + spinnerVerb: null, onPreviousPendingQuestion: () => {}, onInterrupt: () => {}, onImplementPlanInNewThread: () => {}, @@ -79,6 +81,7 @@ function renderSendButton() { isEnvironmentUnavailable: false, isPreparingWorktree: false, hasSendableContent: true, + spinnerVerb: null, onPreviousPendingQuestion: () => {}, onInterrupt: () => {}, onImplementPlanInNewThread: () => {}, diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index d8626496..9280f5f6 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -27,6 +27,9 @@ interface ComposerPrimaryActionsProps { isEnvironmentUnavailable: boolean; isPreparingWorktree: boolean; hasSendableContent: boolean; + // Shares one rotating verb with the visible footer label (ComposerFooterPrimaryActions + // owns the hook) so the stop button's accessible name and the visible text never disagree. + spinnerVerb: string | null; preserveComposerFocusOnPointerDown?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; @@ -67,6 +70,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isEnvironmentUnavailable, isPreparingWorktree, hasSendableContent, + spinnerVerb, preserveComposerFocusOnPointerDown = false, onPreviousPendingQuestion, onInterrupt, @@ -80,6 +84,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ const stageBackdropVariant = useSidebarStageBackdropVariant( environmentIdentificationMode === "artwork", ); + // Accessible-only flavor text (aria-label/title), not a visible label: the + // stop button is a fixed-size icon-only circle with no room to grow. + const stopGenerationLabel = spinnerVerb ? `Stop ${spinnerVerb.toLowerCase()}` : "Stop generation"; const renderStopGenerationButton = (insidePendingAction: boolean) => (