| author | |
| committer | |
| log | 7afa184a99b266d466cc9517c147a75c3d839ad7 |
| tree | 8b9eaee79a2e997777bb73c885c4001e63316eea |
| parent | a7c5ad5db167b3a172ccb26408b0638c99b2a459 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: AMohamedAakhil <hello@takaitech.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>4 files changed, 287 insertions(+), 224 deletions(-)
apps/web/src/components/chat/ChatComposer.tsx+3| ... | @@ -407,6 +407,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( | ... | @@ -407,6 +407,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 407 | isEnvironmentUnavailable: boolean; | 407 | isEnvironmentUnavailable: boolean; |
| 408 | hasSendableContent: boolean; | 408 | hasSendableContent: boolean; |
| 409 | preserveComposerFocusOnPointerDown?: boolean; | 409 | preserveComposerFocusOnPointerDown?: boolean; |
| 410 | showSendWhileRunning?: boolean; | ||
| 410 | onPreviousPendingQuestion: () => void; | 411 | onPreviousPendingQuestion: () => void; |
| 411 | onInterrupt: () => void; | 412 | onInterrupt: () => void; |
| 412 | onImplementPlanInNewThread: () => void; | 413 | onImplementPlanInNewThread: () => void; |
| ... | @@ -435,6 +436,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( | ... | @@ -435,6 +436,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 435 | isPreparingWorktree={props.isPreparingWorktree} | 436 | isPreparingWorktree={props.isPreparingWorktree} |
| 436 | hasSendableContent={props.hasSendableContent} | 437 | hasSendableContent={props.hasSendableContent} |
| 437 | preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} | 438 | preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} |
| 439 | showSendWhileRunning={props.showSendWhileRunning ?? false} | ||
| 438 | onPreviousPendingQuestion={props.onPreviousPendingQuestion} | 440 | onPreviousPendingQuestion={props.onPreviousPendingQuestion} |
| 439 | onInterrupt={props.onInterrupt} | 441 | onInterrupt={props.onInterrupt} |
| 440 | onImplementPlanInNewThread={props.onImplementPlanInNewThread} | 442 | onImplementPlanInNewThread={props.onImplementPlanInNewThread} |
| ... | @@ -3166,6 +3168,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3166,6 +3168,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3166 | isPreparingWorktree={isPreparingWorktree} | 3168 | isPreparingWorktree={isPreparingWorktree} |
| 3167 | hasSendableContent={composerSendState.hasSendableContent} | 3169 | hasSendableContent={composerSendState.hasSendableContent} |
| 3168 | preserveComposerFocusOnPointerDown={isMobileViewport} | 3170 | preserveComposerFocusOnPointerDown={isMobileViewport} |
| 3171 | showSendWhileRunning={isMobileViewport} | ||
| 3169 | onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} | 3172 | onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} |
| 3170 | onInterrupt={handleInterruptPrimaryAction} | 3173 | onInterrupt={handleInterruptPrimaryAction} |
| 3171 | onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} | 3174 | onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} |
apps/web/src/components/chat/ComposerPrimaryActions.test.ts deleted-218| ... | @@ -1,218 +0,0 @@ | ||
| 1 | import { createElement } from "react"; | ||
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | const stageArtworkState = vi.hoisted(() => ({ | ||
| 6 | mode: "none" as "artwork" | "none", | ||
| 7 | variant: null as "nightly" | "dev" | null, | ||
| 8 | })); | ||
| 9 | |||
| 10 | vi.mock("~/hooks/useSettings", () => ({ | ||
| 11 | useEnvironmentIdentificationMode: () => stageArtworkState.mode, | ||
| 12 | })); | ||
| 13 | vi.mock("../SidebarStageBackdrop", () => ({ | ||
| 14 | StageBackdropButtonArt: ({ variant }: { variant: string }) => `stage-${variant}`, | ||
| 15 | useSidebarStageBackdropVariant: (enabled = true) => (enabled ? stageArtworkState.variant : null), | ||
| 16 | })); | ||
| 17 | |||
| 18 | import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; | ||
| 19 | |||
| 20 | function renderPendingActions(isRunning: boolean) { | ||
| 21 | return renderToStaticMarkup( | ||
| 22 | createElement(ComposerPrimaryActions, { | ||
| 23 | compact: true, | ||
| 24 | pendingAction: { | ||
| 25 | questionIndex: 0, | ||
| 26 | isLastQuestion: true, | ||
| 27 | canAdvance: true, | ||
| 28 | isResponding: false, | ||
| 29 | isComplete: true, | ||
| 30 | }, | ||
| 31 | isRunning, | ||
| 32 | showPlanFollowUpPrompt: false, | ||
| 33 | promptHasText: false, | ||
| 34 | isSendBusy: false, | ||
| 35 | sendDisabledReason: null, | ||
| 36 | isConnecting: false, | ||
| 37 | isEnvironmentUnavailable: false, | ||
| 38 | isPreparingWorktree: false, | ||
| 39 | hasSendableContent: false, | ||
| 40 | onPreviousPendingQuestion: () => {}, | ||
| 41 | onInterrupt: () => {}, | ||
| 42 | onImplementPlanInNewThread: () => {}, | ||
| 43 | }), | ||
| 44 | ); | ||
| 45 | } | ||
| 46 | |||
| 47 | function renderStandaloneStop() { | ||
| 48 | return renderToStaticMarkup( | ||
| 49 | createElement(ComposerPrimaryActions, { | ||
| 50 | compact: true, | ||
| 51 | pendingAction: null, | ||
| 52 | isRunning: true, | ||
| 53 | showPlanFollowUpPrompt: false, | ||
| 54 | promptHasText: false, | ||
| 55 | isSendBusy: false, | ||
| 56 | sendDisabledReason: null, | ||
| 57 | isConnecting: false, | ||
| 58 | isEnvironmentUnavailable: false, | ||
| 59 | isPreparingWorktree: false, | ||
| 60 | hasSendableContent: false, | ||
| 61 | onPreviousPendingQuestion: () => {}, | ||
| 62 | onInterrupt: () => {}, | ||
| 63 | onImplementPlanInNewThread: () => {}, | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | } | ||
| 67 | |||
| 68 | function renderSendButton() { | ||
| 69 | return renderToStaticMarkup( | ||
| 70 | createElement(ComposerPrimaryActions, { | ||
| 71 | compact: true, | ||
| 72 | pendingAction: null, | ||
| 73 | isRunning: false, | ||
| 74 | showPlanFollowUpPrompt: false, | ||
| 75 | promptHasText: true, | ||
| 76 | isSendBusy: false, | ||
| 77 | sendDisabledReason: null, | ||
| 78 | isConnecting: false, | ||
| 79 | isEnvironmentUnavailable: false, | ||
| 80 | isPreparingWorktree: false, | ||
| 81 | hasSendableContent: true, | ||
| 82 | onPreviousPendingQuestion: () => {}, | ||
| 83 | onInterrupt: () => {}, | ||
| 84 | onImplementPlanInNewThread: () => {}, | ||
| 85 | }), | ||
| 86 | ); | ||
| 87 | } | ||
| 88 | |||
| 89 | afterEach(() => { | ||
| 90 | stageArtworkState.mode = "none"; | ||
| 91 | stageArtworkState.variant = null; | ||
| 92 | }); | ||
| 93 | |||
| 94 | describe("formatPendingPrimaryActionLabel", () => { | ||
| 95 | it("returns 'Submitting...' while responding", () => { | ||
| 96 | expect( | ||
| 97 | formatPendingPrimaryActionLabel({ | ||
| 98 | compact: false, | ||
| 99 | isLastQuestion: false, | ||
| 100 | isResponding: true, | ||
| 101 | questionIndex: 0, | ||
| 102 | }), | ||
| 103 | ).toBe("Submitting..."); | ||
| 104 | }); | ||
| 105 | |||
| 106 | it("returns 'Submitting...' while responding regardless of other flags", () => { | ||
| 107 | expect( | ||
| 108 | formatPendingPrimaryActionLabel({ | ||
| 109 | compact: true, | ||
| 110 | isLastQuestion: true, | ||
| 111 | isResponding: true, | ||
| 112 | questionIndex: 3, | ||
| 113 | }), | ||
| 114 | ).toBe("Submitting..."); | ||
| 115 | }); | ||
| 116 | |||
| 117 | it("returns 'Submit' in compact mode on the last question", () => { | ||
| 118 | expect( | ||
| 119 | formatPendingPrimaryActionLabel({ | ||
| 120 | compact: true, | ||
| 121 | isLastQuestion: true, | ||
| 122 | isResponding: false, | ||
| 123 | questionIndex: 0, | ||
| 124 | }), | ||
| 125 | ).toBe("Submit"); | ||
| 126 | }); | ||
| 127 | |||
| 128 | it("returns 'Next' in compact mode when not the last question", () => { | ||
| 129 | expect( | ||
| 130 | formatPendingPrimaryActionLabel({ | ||
| 131 | compact: true, | ||
| 132 | isLastQuestion: false, | ||
| 133 | isResponding: false, | ||
| 134 | questionIndex: 1, | ||
| 135 | }), | ||
| 136 | ).toBe("Next"); | ||
| 137 | }); | ||
| 138 | |||
| 139 | it("returns 'Next question' when not the last question", () => { | ||
| 140 | expect( | ||
| 141 | formatPendingPrimaryActionLabel({ | ||
| 142 | compact: false, | ||
| 143 | isLastQuestion: false, | ||
| 144 | isResponding: false, | ||
| 145 | questionIndex: 0, | ||
| 146 | }), | ||
| 147 | ).toBe("Next question"); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("returns singular 'Submit answer' on the last question when it is the only question", () => { | ||
| 151 | expect( | ||
| 152 | formatPendingPrimaryActionLabel({ | ||
| 153 | compact: false, | ||
| 154 | isLastQuestion: true, | ||
| 155 | isResponding: false, | ||
| 156 | questionIndex: 0, | ||
| 157 | }), | ||
| 158 | ).toBe("Submit answer"); | ||
| 159 | }); | ||
| 160 | |||
| 161 | it("returns plural 'Submit answers' on the last question when there are multiple questions", () => { | ||
| 162 | expect( | ||
| 163 | formatPendingPrimaryActionLabel({ | ||
| 164 | compact: false, | ||
| 165 | isLastQuestion: true, | ||
| 166 | isResponding: false, | ||
| 167 | questionIndex: 1, | ||
| 168 | }), | ||
| 169 | ).toBe("Submit answers"); | ||
| 170 | }); | ||
| 171 | |||
| 172 | it("returns plural 'Submit answers' for higher question indices", () => { | ||
| 173 | expect( | ||
| 174 | formatPendingPrimaryActionLabel({ | ||
| 175 | compact: false, | ||
| 176 | isLastQuestion: true, | ||
| 177 | isResponding: false, | ||
| 178 | questionIndex: 5, | ||
| 179 | }), | ||
| 180 | ).toBe("Submit answers"); | ||
| 181 | }); | ||
| 182 | }); | ||
| 183 | |||
| 184 | describe("ComposerPrimaryActions", () => { | ||
| 185 | it("offers Stop generation while a running turn is waiting for user input", () => { | ||
| 186 | expect(renderPendingActions(true)).toContain('aria-label="Stop generation"'); | ||
| 187 | }); | ||
| 188 | |||
| 189 | it("does not offer Stop generation for a pending request without a running turn", () => { | ||
| 190 | expect(renderPendingActions(false)).not.toContain('aria-label="Stop generation"'); | ||
| 191 | }); | ||
| 192 | |||
| 193 | it("matches the small pending action size without changing the standalone size", () => { | ||
| 194 | expect(renderPendingActions(true)).toContain("size-8 sm:size-7"); | ||
| 195 | expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8"); | ||
| 196 | expect(renderStandaloneStop()).not.toContain("sm:size-7"); | ||
| 197 | }); | ||
| 198 | |||
| 199 | it("renders stage artwork inside the send button when artwork identification is active", () => { | ||
| 200 | stageArtworkState.mode = "artwork"; | ||
| 201 | stageArtworkState.variant = "nightly"; | ||
| 202 | |||
| 203 | const markup = renderSendButton(); | ||
| 204 | |||
| 205 | expect(markup).toContain("stage-nightly"); | ||
| 206 | expect(markup).toContain("bg-transparent text-white"); | ||
| 207 | expect(markup).not.toContain("bg-message-action text-message-action-foreground"); | ||
| 208 | }); | ||
| 209 | |||
| 210 | it("keeps the normal send-button fill when artwork identification is inactive", () => { | ||
| 211 | stageArtworkState.variant = "nightly"; | ||
| 212 | |||
| 213 | const markup = renderSendButton(); | ||
| 214 | |||
| 215 | expect(markup).not.toContain("stage-nightly"); | ||
| 216 | expect(markup).toContain("bg-message-action text-message-action-foreground"); | ||
| 217 | }); | ||
| 218 | }); | ||
apps/web/src/components/chat/ComposerPrimaryActions.test.tsx created+263| ... | @@ -0,0 +1,263 @@ | ||
| 1 | import { createElement } from "react"; | ||
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | const stageArtworkState = vi.hoisted(() => ({ | ||
| 6 | mode: "none" as "artwork" | "none", | ||
| 7 | variant: null as "nightly" | "dev" | null, | ||
| 8 | })); | ||
| 9 | |||
| 10 | vi.mock("~/hooks/useSettings", () => ({ | ||
| 11 | useEnvironmentIdentificationMode: () => stageArtworkState.mode, | ||
| 12 | })); | ||
| 13 | vi.mock("../SidebarStageBackdrop", () => ({ | ||
| 14 | StageBackdropButtonArt: ({ variant }: { variant: string }) => `stage-${variant}`, | ||
| 15 | useSidebarStageBackdropVariant: (enabled = true) => (enabled ? stageArtworkState.variant : null), | ||
| 16 | })); | ||
| 17 | |||
| 18 | import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; | ||
| 19 | |||
| 20 | function renderPendingActions(isRunning: boolean) { | ||
| 21 | return renderToStaticMarkup( | ||
| 22 | createElement(ComposerPrimaryActions, { | ||
| 23 | compact: true, | ||
| 24 | pendingAction: { | ||
| 25 | questionIndex: 0, | ||
| 26 | isLastQuestion: true, | ||
| 27 | canAdvance: true, | ||
| 28 | isResponding: false, | ||
| 29 | isComplete: true, | ||
| 30 | }, | ||
| 31 | isRunning, | ||
| 32 | showPlanFollowUpPrompt: false, | ||
| 33 | promptHasText: false, | ||
| 34 | isSendBusy: false, | ||
| 35 | sendDisabledReason: null, | ||
| 36 | isConnecting: false, | ||
| 37 | isEnvironmentUnavailable: false, | ||
| 38 | isPreparingWorktree: false, | ||
| 39 | hasSendableContent: false, | ||
| 40 | onPreviousPendingQuestion: () => {}, | ||
| 41 | onInterrupt: () => {}, | ||
| 42 | onImplementPlanInNewThread: () => {}, | ||
| 43 | }), | ||
| 44 | ); | ||
| 45 | } | ||
| 46 | |||
| 47 | function renderStandaloneStop() { | ||
| 48 | return renderToStaticMarkup( | ||
| 49 | createElement(ComposerPrimaryActions, { | ||
| 50 | compact: true, | ||
| 51 | pendingAction: null, | ||
| 52 | isRunning: true, | ||
| 53 | showPlanFollowUpPrompt: false, | ||
| 54 | promptHasText: false, | ||
| 55 | isSendBusy: false, | ||
| 56 | sendDisabledReason: null, | ||
| 57 | isConnecting: false, | ||
| 58 | isEnvironmentUnavailable: false, | ||
| 59 | isPreparingWorktree: false, | ||
| 60 | hasSendableContent: false, | ||
| 61 | onPreviousPendingQuestion: () => {}, | ||
| 62 | onInterrupt: () => {}, | ||
| 63 | onImplementPlanInNewThread: () => {}, | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | } | ||
| 67 | |||
| 68 | function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: boolean) { | ||
| 69 | return renderToStaticMarkup( | ||
| 70 | createElement(ComposerPrimaryActions, { | ||
| 71 | compact: true, | ||
| 72 | pendingAction: null, | ||
| 73 | isRunning: true, | ||
| 74 | showPlanFollowUpPrompt: false, | ||
| 75 | promptHasText: hasSendableContent, | ||
| 76 | isSendBusy: false, | ||
| 77 | sendDisabledReason: null, | ||
| 78 | isConnecting: false, | ||
| 79 | isEnvironmentUnavailable: false, | ||
| 80 | isPreparingWorktree: false, | ||
| 81 | hasSendableContent, | ||
| 82 | showSendWhileRunning, | ||
| 83 | onPreviousPendingQuestion: () => {}, | ||
| 84 | onInterrupt: () => {}, | ||
| 85 | onImplementPlanInNewThread: () => {}, | ||
| 86 | }), | ||
| 87 | ); | ||
| 88 | } | ||
| 89 | |||
| 90 | function renderSendButton() { | ||
| 91 | return renderToStaticMarkup( | ||
| 92 | createElement(ComposerPrimaryActions, { | ||
| 93 | compact: true, | ||
| 94 | pendingAction: null, | ||
| 95 | isRunning: false, | ||
| 96 | showPlanFollowUpPrompt: false, | ||
| 97 | promptHasText: true, | ||
| 98 | isSendBusy: false, | ||
| 99 | sendDisabledReason: null, | ||
| 100 | isConnecting: false, | ||
| 101 | isEnvironmentUnavailable: false, | ||
| 102 | isPreparingWorktree: false, | ||
| 103 | hasSendableContent: true, | ||
| 104 | onPreviousPendingQuestion: () => {}, | ||
| 105 | onInterrupt: () => {}, | ||
| 106 | onImplementPlanInNewThread: () => {}, | ||
| 107 | }), | ||
| 108 | ); | ||
| 109 | } | ||
| 110 | |||
| 111 | afterEach(() => { | ||
| 112 | stageArtworkState.mode = "none"; | ||
| 113 | stageArtworkState.variant = null; | ||
| 114 | }); | ||
| 115 | |||
| 116 | describe("formatPendingPrimaryActionLabel", () => { | ||
| 117 | it("returns 'Submitting...' while responding", () => { | ||
| 118 | expect( | ||
| 119 | formatPendingPrimaryActionLabel({ | ||
| 120 | compact: false, | ||
| 121 | isLastQuestion: false, | ||
| 122 | isResponding: true, | ||
| 123 | questionIndex: 0, | ||
| 124 | }), | ||
| 125 | ).toBe("Submitting..."); | ||
| 126 | }); | ||
| 127 | |||
| 128 | it("returns 'Submitting...' while responding regardless of other flags", () => { | ||
| 129 | expect( | ||
| 130 | formatPendingPrimaryActionLabel({ | ||
| 131 | compact: true, | ||
| 132 | isLastQuestion: true, | ||
| 133 | isResponding: true, | ||
| 134 | questionIndex: 3, | ||
| 135 | }), | ||
| 136 | ).toBe("Submitting..."); | ||
| 137 | }); | ||
| 138 | |||
| 139 | it("returns 'Submit' in compact mode on the last question", () => { | ||
| 140 | expect( | ||
| 141 | formatPendingPrimaryActionLabel({ | ||
| 142 | compact: true, | ||
| 143 | isLastQuestion: true, | ||
| 144 | isResponding: false, | ||
| 145 | questionIndex: 0, | ||
| 146 | }), | ||
| 147 | ).toBe("Submit"); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("returns 'Next' in compact mode when not the last question", () => { | ||
| 151 | expect( | ||
| 152 | formatPendingPrimaryActionLabel({ | ||
| 153 | compact: true, | ||
| 154 | isLastQuestion: false, | ||
| 155 | isResponding: false, | ||
| 156 | questionIndex: 1, | ||
| 157 | }), | ||
| 158 | ).toBe("Next"); | ||
| 159 | }); | ||
| 160 | |||
| 161 | it("returns 'Next question' when not the last question", () => { | ||
| 162 | expect( | ||
| 163 | formatPendingPrimaryActionLabel({ | ||
| 164 | compact: false, | ||
| 165 | isLastQuestion: false, | ||
| 166 | isResponding: false, | ||
| 167 | questionIndex: 0, | ||
| 168 | }), | ||
| 169 | ).toBe("Next question"); | ||
| 170 | }); | ||
| 171 | |||
| 172 | it("returns singular 'Submit answer' on the last question when it is the only question", () => { | ||
| 173 | expect( | ||
| 174 | formatPendingPrimaryActionLabel({ | ||
| 175 | compact: false, | ||
| 176 | isLastQuestion: true, | ||
| 177 | isResponding: false, | ||
| 178 | questionIndex: 0, | ||
| 179 | }), | ||
| 180 | ).toBe("Submit answer"); | ||
| 181 | }); | ||
| 182 | |||
| 183 | it("returns plural 'Submit answers' on the last question when there are multiple questions", () => { | ||
| 184 | expect( | ||
| 185 | formatPendingPrimaryActionLabel({ | ||
| 186 | compact: false, | ||
| 187 | isLastQuestion: true, | ||
| 188 | isResponding: false, | ||
| 189 | questionIndex: 1, | ||
| 190 | }), | ||
| 191 | ).toBe("Submit answers"); | ||
| 192 | }); | ||
| 193 | |||
| 194 | it("returns plural 'Submit answers' for higher question indices", () => { | ||
| 195 | expect( | ||
| 196 | formatPendingPrimaryActionLabel({ | ||
| 197 | compact: false, | ||
| 198 | isLastQuestion: true, | ||
| 199 | isResponding: false, | ||
| 200 | questionIndex: 5, | ||
| 201 | }), | ||
| 202 | ).toBe("Submit answers"); | ||
| 203 | }); | ||
| 204 | }); | ||
| 205 | |||
| 206 | describe("ComposerPrimaryActions", () => { | ||
| 207 | it("offers Stop generation while a running turn is waiting for user input", () => { | ||
| 208 | expect(renderPendingActions(true)).toContain('aria-label="Stop generation"'); | ||
| 209 | }); | ||
| 210 | |||
| 211 | it("does not offer Stop generation for a pending request without a running turn", () => { | ||
| 212 | expect(renderPendingActions(false)).not.toContain('aria-label="Stop generation"'); | ||
| 213 | }); | ||
| 214 | |||
| 215 | it("matches the small pending action size without changing the standalone size", () => { | ||
| 216 | expect(renderPendingActions(true)).toContain("size-8 sm:size-7"); | ||
| 217 | expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8"); | ||
| 218 | expect(renderStandaloneStop()).not.toContain("sm:size-7"); | ||
| 219 | }); | ||
| 220 | |||
| 221 | it("renders stage artwork inside the send button when artwork identification is active", () => { | ||
| 222 | stageArtworkState.mode = "artwork"; | ||
| 223 | stageArtworkState.variant = "nightly"; | ||
| 224 | |||
| 225 | const markup = renderSendButton(); | ||
| 226 | |||
| 227 | expect(markup).toContain("stage-nightly"); | ||
| 228 | expect(markup).toContain("bg-transparent text-white"); | ||
| 229 | expect(markup).not.toContain("bg-message-action text-message-action-foreground"); | ||
| 230 | }); | ||
| 231 | |||
| 232 | it("keeps the normal send-button fill when artwork identification is inactive", () => { | ||
| 233 | stageArtworkState.variant = "nightly"; | ||
| 234 | |||
| 235 | const markup = renderSendButton(); | ||
| 236 | |||
| 237 | expect(markup).not.toContain("stage-nightly"); | ||
| 238 | expect(markup).toContain("bg-message-action text-message-action-foreground"); | ||
| 239 | }); | ||
| 240 | |||
| 241 | it("only renders stop while running when Enter-to-send is available", () => { | ||
| 242 | const markup = renderRunningActions(false, true); | ||
| 243 | |||
| 244 | expect(markup).toContain('aria-label="Stop generation"'); | ||
| 245 | expect(markup).not.toContain('aria-label="Send message"'); | ||
| 246 | }); | ||
| 247 | |||
| 248 | it("renders send alongside stop while running when Enter-to-send is unavailable", () => { | ||
| 249 | const markup = renderRunningActions(true, true); | ||
| 250 | |||
| 251 | expect(markup).toContain('aria-label="Stop generation"'); | ||
| 252 | expect(markup).toContain('aria-label="Send message"'); | ||
| 253 | expect(markup).toContain('type="submit"'); | ||
| 254 | expect(markup).toContain("size-9 sm:size-8"); | ||
| 255 | }); | ||
| 256 | |||
| 257 | it("keeps stop as the only action while running with an empty composer", () => { | ||
| 258 | const markup = renderRunningActions(true, false); | ||
| 259 | |||
| 260 | expect(markup).toContain('aria-label="Stop generation"'); | ||
| 261 | expect(markup).not.toContain('aria-label="Send message"'); | ||
| 262 | }); | ||
| 263 | }); | ||
apps/web/src/components/chat/ComposerPrimaryActions.tsx+21-6| ... | @@ -28,6 +28,9 @@ interface ComposerPrimaryActionsProps { | ... | @@ -28,6 +28,9 @@ interface ComposerPrimaryActionsProps { |
| 28 | isPreparingWorktree: boolean; | 28 | isPreparingWorktree: boolean; |
| 29 | hasSendableContent: boolean; | 29 | hasSendableContent: boolean; |
| 30 | preserveComposerFocusOnPointerDown?: boolean; | 30 | preserveComposerFocusOnPointerDown?: boolean; |
| 31 | /** Enter-to-send is disabled on mobile viewports, where stop would otherwise | ||
| 32 | * be the only primary action and a running turn could not be steered. */ | ||
| 33 | showSendWhileRunning?: boolean; | ||
| 31 | onPreviousPendingQuestion: () => void; | 34 | onPreviousPendingQuestion: () => void; |
| 32 | onInterrupt: () => void; | 35 | onInterrupt: () => void; |
| 33 | onImplementPlanInNewThread: () => void; | 36 | onImplementPlanInNewThread: () => void; |
| ... | @@ -68,6 +71,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -68,6 +71,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 68 | isPreparingWorktree, | 71 | isPreparingWorktree, |
| 69 | hasSendableContent, | 72 | hasSendableContent, |
| 70 | preserveComposerFocusOnPointerDown = false, | 73 | preserveComposerFocusOnPointerDown = false, |
| 74 | showSendWhileRunning = false, | ||
| 71 | onPreviousPendingQuestion, | 75 | onPreviousPendingQuestion, |
| 72 | onInterrupt, | 76 | onInterrupt, |
| 73 | onImplementPlanInNewThread, | 77 | onImplementPlanInNewThread, |
| ... | @@ -86,7 +90,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -86,7 +90,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 86 | type="button" | 90 | type="button" |
| 87 | className={cn( | 91 | className={cn( |
| 88 | "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", | 92 | "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", |
| 89 | insidePendingAction ? "size-8 sm:size-7" : "size-8 sm:h-8 sm:w-8", | 93 | insidePendingAction |
| 94 | ? "size-8 sm:size-7" | ||
| 95 | : showSendWhileRunning && hasSendableContent | ||
| 96 | ? "size-9 sm:size-8" | ||
| 97 | : "size-8 sm:h-8 sm:w-8", | ||
| 90 | )} | 98 | )} |
| 91 | {...pointerFocusProps} | 99 | {...pointerFocusProps} |
| 92 | onClick={onInterrupt} | 100 | onClick={onInterrupt} |
| ... | @@ -153,10 +161,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -153,10 +161,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 153 | ); | 161 | ); |
| 154 | } | 162 | } |
| 155 | 163 | ||
| 156 | if (isRunning) { | ||
| 157 | return renderStopGenerationButton(false); | ||
| 158 | } | ||
| 159 | |||
| 160 | if (showPlanFollowUpPrompt) { | 164 | if (showPlanFollowUpPrompt) { |
| 161 | if (promptHasText) { | 165 | if (promptHasText) { |
| 162 | return ( | 166 | return ( |
| ... | @@ -214,7 +218,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -214,7 +218,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 214 | ); | 218 | ); |
| 215 | } | 219 | } |
| 216 | 220 | ||
| 217 | return ( | 221 | const sendButton = ( |
| 218 | <button | 222 | <button |
| 219 | type="submit" | 223 | type="submit" |
| 220 | className={cn( | 224 | className={cn( |
| ... | @@ -265,4 +269,15 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -265,4 +269,15 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 265 | )} | 269 | )} |
| 266 | </button> | 270 | </button> |
| 267 | ); | 271 | ); |
| 272 | |||
| 273 | if (!isRunning) { | ||
| 274 | return sendButton; | ||
| 275 | } | ||
| 276 | |||
| 277 | return ( | ||
| 278 | <> | ||
| 279 | {renderStopGenerationButton(false)} | ||
| 280 | {showSendWhileRunning && hasSendableContent ? sendButton : null} | ||
| 281 | </> | ||
| 282 | ); | ||
| 268 | }); | 283 | }); |