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 ? (
+
+ {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) => (