diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5072c5870a7375020a8b5f1d7c3e5a9d6fc459ea..afba1e086b8434419814291449c34998fbe838df 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -407,6 +407,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isEnvironmentUnavailable: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -435,6 +436,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} + showSendWhileRunning={props.showSendWhileRunning ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} @@ -3166,6 +3168,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport} + showSendWhileRunning={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts b/apps/web/src/components/chat/ComposerPrimaryActions.test.ts deleted file mode 100644 index 3dbcd39e9d13c10208b70e06ec451f6b4918edd8..0000000000000000000000000000000000000000 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { createElement } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -const stageArtworkState = vi.hoisted(() => ({ - mode: "none" as "artwork" | "none", - variant: null as "nightly" | "dev" | null, -})); - -vi.mock("~/hooks/useSettings", () => ({ - useEnvironmentIdentificationMode: () => stageArtworkState.mode, -})); -vi.mock("../SidebarStageBackdrop", () => ({ - StageBackdropButtonArt: ({ variant }: { variant: string }) => `stage-${variant}`, - useSidebarStageBackdropVariant: (enabled = true) => (enabled ? stageArtworkState.variant : null), -})); - -import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; - -function renderPendingActions(isRunning: boolean) { - return renderToStaticMarkup( - createElement(ComposerPrimaryActions, { - compact: true, - pendingAction: { - questionIndex: 0, - isLastQuestion: true, - canAdvance: true, - isResponding: false, - isComplete: true, - }, - isRunning, - showPlanFollowUpPrompt: false, - promptHasText: false, - isSendBusy: false, - sendDisabledReason: null, - isConnecting: false, - isEnvironmentUnavailable: false, - isPreparingWorktree: false, - hasSendableContent: false, - onPreviousPendingQuestion: () => {}, - onInterrupt: () => {}, - onImplementPlanInNewThread: () => {}, - }), - ); -} - -function renderStandaloneStop() { - return renderToStaticMarkup( - createElement(ComposerPrimaryActions, { - compact: true, - pendingAction: null, - isRunning: true, - showPlanFollowUpPrompt: false, - promptHasText: false, - isSendBusy: false, - sendDisabledReason: null, - isConnecting: false, - isEnvironmentUnavailable: false, - isPreparingWorktree: false, - hasSendableContent: false, - onPreviousPendingQuestion: () => {}, - onInterrupt: () => {}, - onImplementPlanInNewThread: () => {}, - }), - ); -} - -function renderSendButton() { - return renderToStaticMarkup( - createElement(ComposerPrimaryActions, { - compact: true, - pendingAction: null, - isRunning: false, - showPlanFollowUpPrompt: false, - promptHasText: true, - isSendBusy: false, - sendDisabledReason: null, - isConnecting: false, - isEnvironmentUnavailable: false, - isPreparingWorktree: false, - hasSendableContent: true, - onPreviousPendingQuestion: () => {}, - onInterrupt: () => {}, - onImplementPlanInNewThread: () => {}, - }), - ); -} - -afterEach(() => { - stageArtworkState.mode = "none"; - stageArtworkState.variant = null; -}); - -describe("formatPendingPrimaryActionLabel", () => { - it("returns 'Submitting...' while responding", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: false, - isResponding: true, - questionIndex: 0, - }), - ).toBe("Submitting..."); - }); - - it("returns 'Submitting...' while responding regardless of other flags", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: true, - isLastQuestion: true, - isResponding: true, - questionIndex: 3, - }), - ).toBe("Submitting..."); - }); - - it("returns 'Submit' in compact mode on the last question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: true, - isLastQuestion: true, - isResponding: false, - questionIndex: 0, - }), - ).toBe("Submit"); - }); - - it("returns 'Next' in compact mode when not the last question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: true, - isLastQuestion: false, - isResponding: false, - questionIndex: 1, - }), - ).toBe("Next"); - }); - - it("returns 'Next question' when not the last question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: false, - isResponding: false, - questionIndex: 0, - }), - ).toBe("Next question"); - }); - - it("returns singular 'Submit answer' on the last question when it is the only question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: true, - isResponding: false, - questionIndex: 0, - }), - ).toBe("Submit answer"); - }); - - it("returns plural 'Submit answers' on the last question when there are multiple questions", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: true, - isResponding: false, - questionIndex: 1, - }), - ).toBe("Submit answers"); - }); - - it("returns plural 'Submit answers' for higher question indices", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: true, - isResponding: false, - questionIndex: 5, - }), - ).toBe("Submit answers"); - }); -}); - -describe("ComposerPrimaryActions", () => { - it("offers Stop generation while a running turn is waiting for user input", () => { - expect(renderPendingActions(true)).toContain('aria-label="Stop generation"'); - }); - - it("does not offer Stop generation for a pending request without a running turn", () => { - expect(renderPendingActions(false)).not.toContain('aria-label="Stop generation"'); - }); - - it("matches the small pending action size without changing the standalone size", () => { - expect(renderPendingActions(true)).toContain("size-8 sm:size-7"); - expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8"); - expect(renderStandaloneStop()).not.toContain("sm:size-7"); - }); - - it("renders stage artwork inside the send button when artwork identification is active", () => { - stageArtworkState.mode = "artwork"; - stageArtworkState.variant = "nightly"; - - const markup = renderSendButton(); - - expect(markup).toContain("stage-nightly"); - expect(markup).toContain("bg-transparent text-white"); - expect(markup).not.toContain("bg-message-action text-message-action-foreground"); - }); - - it("keeps the normal send-button fill when artwork identification is inactive", () => { - stageArtworkState.variant = "nightly"; - - const markup = renderSendButton(); - - expect(markup).not.toContain("stage-nightly"); - expect(markup).toContain("bg-message-action text-message-action-foreground"); - }); -}); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c48f029f7f9bf55b158c85da33afebef3cf794d8 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -0,0 +1,263 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +const stageArtworkState = vi.hoisted(() => ({ + mode: "none" as "artwork" | "none", + variant: null as "nightly" | "dev" | null, +})); + +vi.mock("~/hooks/useSettings", () => ({ + useEnvironmentIdentificationMode: () => stageArtworkState.mode, +})); +vi.mock("../SidebarStageBackdrop", () => ({ + StageBackdropButtonArt: ({ variant }: { variant: string }) => `stage-${variant}`, + useSidebarStageBackdropVariant: (enabled = true) => (enabled ? stageArtworkState.variant : null), +})); + +import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; + +function renderPendingActions(isRunning: boolean) { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: { + questionIndex: 0, + isLastQuestion: true, + canAdvance: true, + isResponding: false, + isComplete: true, + }, + isRunning, + showPlanFollowUpPrompt: false, + promptHasText: false, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent: false, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + }), + ); +} + +function renderStandaloneStop() { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: null, + isRunning: true, + showPlanFollowUpPrompt: false, + promptHasText: false, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent: false, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + }), + ); +} + +function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: boolean) { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: null, + isRunning: true, + showPlanFollowUpPrompt: false, + promptHasText: hasSendableContent, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent, + showSendWhileRunning, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + }), + ); +} + +function renderSendButton() { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: null, + isRunning: false, + showPlanFollowUpPrompt: false, + promptHasText: true, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent: true, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + }), + ); +} + +afterEach(() => { + stageArtworkState.mode = "none"; + stageArtworkState.variant = null; +}); + +describe("formatPendingPrimaryActionLabel", () => { + it("returns 'Submitting...' while responding", () => { + expect( + formatPendingPrimaryActionLabel({ + compact: false, + isLastQuestion: false, + isResponding: true, + questionIndex: 0, + }), + ).toBe("Submitting..."); + }); + + it("returns 'Submitting...' while responding regardless of other flags", () => { + expect( + formatPendingPrimaryActionLabel({ + compact: true, + isLastQuestion: true, + isResponding: true, + questionIndex: 3, + }), + ).toBe("Submitting..."); + }); + + it("returns 'Submit' in compact mode on the last question", () => { + expect( + formatPendingPrimaryActionLabel({ + compact: true, + isLastQuestion: true, + isResponding: false, + questionIndex: 0, + }), + ).toBe("Submit"); + }); + + it("returns 'Next' in compact mode when not the last question", () => { + expect( + formatPendingPrimaryActionLabel({ + compact: true, + isLastQuestion: false, + isResponding: false, + questionIndex: 1, + }), + ).toBe("Next"); + }); + + it("returns 'Next question' when not the last question", () => { + expect( + formatPendingPrimaryActionLabel({ + compact: false, + isLastQuestion: false, + isResponding: false, + questionIndex: 0, + }), + ).toBe("Next question"); + }); + + it("returns singular 'Submit answer' on the last question when it is the only question", () => { + expect( + formatPendingPrimaryActionLabel({ + compact: false, + isLastQuestion: true, + isResponding: false, + questionIndex: 0, + }), + ).toBe("Submit answer"); + }); + + it("returns plural 'Submit answers' on the last question when there are multiple questions", () => { + expect( + formatPendingPrimaryActionLabel({ + compact: false, + isLastQuestion: true, + isResponding: false, + questionIndex: 1, + }), + ).toBe("Submit answers"); + }); + + it("returns plural 'Submit answers' for higher question indices", () => { + expect( + formatPendingPrimaryActionLabel({ + compact: false, + isLastQuestion: true, + isResponding: false, + questionIndex: 5, + }), + ).toBe("Submit answers"); + }); +}); + +describe("ComposerPrimaryActions", () => { + it("offers Stop generation while a running turn is waiting for user input", () => { + expect(renderPendingActions(true)).toContain('aria-label="Stop generation"'); + }); + + it("does not offer Stop generation for a pending request without a running turn", () => { + expect(renderPendingActions(false)).not.toContain('aria-label="Stop generation"'); + }); + + it("matches the small pending action size without changing the standalone size", () => { + expect(renderPendingActions(true)).toContain("size-8 sm:size-7"); + expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8"); + expect(renderStandaloneStop()).not.toContain("sm:size-7"); + }); + + it("renders stage artwork inside the send button when artwork identification is active", () => { + stageArtworkState.mode = "artwork"; + stageArtworkState.variant = "nightly"; + + const markup = renderSendButton(); + + expect(markup).toContain("stage-nightly"); + expect(markup).toContain("bg-transparent text-white"); + expect(markup).not.toContain("bg-message-action text-message-action-foreground"); + }); + + it("keeps the normal send-button fill when artwork identification is inactive", () => { + stageArtworkState.variant = "nightly"; + + const markup = renderSendButton(); + + expect(markup).not.toContain("stage-nightly"); + expect(markup).toContain("bg-message-action text-message-action-foreground"); + }); + + it("only renders stop while running when Enter-to-send is available", () => { + const markup = renderRunningActions(false, true); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain('aria-label="Send message"'); + }); + + it("renders send alongside stop while running when Enter-to-send is unavailable", () => { + const markup = renderRunningActions(true, true); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).toContain('aria-label="Send message"'); + expect(markup).toContain('type="submit"'); + expect(markup).toContain("size-9 sm:size-8"); + }); + + it("keeps stop as the only action while running with an empty composer", () => { + const markup = renderRunningActions(true, false); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain('aria-label="Send message"'); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index d8626496ae7d8c8a88e110447228da6f59932f65..2a27796d92a51802fb217f0fa8302ffe1e7d0b62 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -28,6 +28,9 @@ interface ComposerPrimaryActionsProps { isPreparingWorktree: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + /** Enter-to-send is disabled on mobile viewports, where stop would otherwise + * be the only primary action and a running turn could not be steered. */ + showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -68,6 +71,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isPreparingWorktree, hasSendableContent, preserveComposerFocusOnPointerDown = false, + showSendWhileRunning = false, onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, @@ -86,7 +90,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ type="button" className={cn( "flex cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-[0_1px_--theme(--color-white/16%)] transition-all duration-150 hover:bg-destructive hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none", - insidePendingAction ? "size-8 sm:size-7" : "size-8 sm:h-8 sm:w-8", + insidePendingAction + ? "size-8 sm:size-7" + : showSendWhileRunning && hasSendableContent + ? "size-9 sm:size-8" + : "size-8 sm:h-8 sm:w-8", )} {...pointerFocusProps} onClick={onInterrupt} @@ -153,10 +161,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); } - if (isRunning) { - return renderStopGenerationButton(false); - } - if (showPlanFollowUpPrompt) { if (promptHasText) { return ( @@ -214,7 +218,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); } - return ( + const sendButton = (