Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
isEnvironmentUnavailable: boolean;
hasSendableContent: boolean;
preserveComposerFocusOnPointerDown?: boolean;
showSendWhileRunning?: boolean;
onPreviousPendingQuestion: () => void;
onInterrupt: () => void;
onImplementPlanInNewThread: () => void;
Expand Down Expand Up @@ -453,6 +454,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}
Expand Down Expand Up @@ -3164,6 +3166,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isPreparingWorktree={isPreparingWorktree}
hasSendableContent={composerSendState.hasSendableContent}
preserveComposerFocusOnPointerDown={isMobileViewport}
showSendWhileRunning={isMobileViewport}
onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
onInterrupt={handleInterruptPrimaryAction}
onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";

import { formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions";
import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions";

const renderPrimaryActions = (
overrides: Partial<Parameters<typeof ComposerPrimaryActions>[0]> = {},
) =>
renderToStaticMarkup(
<ComposerPrimaryActions
compact={false}
pendingAction={null}
isRunning={false}
showPlanFollowUpPrompt={false}
promptHasText={false}
isSendBusy={false}
isConnecting={false}
isEnvironmentUnavailable={false}
isPreparingWorktree={false}
hasSendableContent={false}
onPreviousPendingQuestion={() => {}}
onInterrupt={() => {}}
onImplementPlanInNewThread={() => {}}
{...overrides}
/>,
);

describe("formatPendingPrimaryActionLabel", () => {
it("returns 'Submitting...' while responding", () => {
Expand Down Expand Up @@ -91,3 +114,35 @@ describe("formatPendingPrimaryActionLabel", () => {
).toBe("Submit answers");
});
});

describe("ComposerPrimaryActions send-while-running", () => {
it("only renders stop while running when Enter-to-send is available", () => {
const markup = renderPrimaryActions({ isRunning: true, hasSendableContent: 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 = renderPrimaryActions({
isRunning: true,
hasSendableContent: true,
showSendWhileRunning: true,
});

expect(markup).toContain('aria-label="Stop generation"');
expect(markup).toContain('aria-label="Send message"');
expect(markup).toContain('type="submit"');
});

it("keeps stop as the only action while running with an empty composer", () => {
const markup = renderPrimaryActions({
isRunning: true,
hasSendableContent: false,
showSendWhileRunning: true,
});

expect(markup).toContain('aria-label="Stop generation"');
expect(markup).not.toContain('aria-label="Send message"');
});
});
43 changes: 26 additions & 17 deletions apps/web/src/components/chat/ComposerPrimaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,6 +71,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
isPreparingWorktree,
hasSendableContent,
preserveComposerFocusOnPointerDown = false,
showSendWhileRunning = false,
onPreviousPendingQuestion,
onInterrupt,
onImplementPlanInNewThread,
Expand Down Expand Up @@ -132,22 +136,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
);
}

if (isRunning) {
return (
<button
type="button"
className="flex size-8 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 sm:h-8 sm:w-8"
{...pointerFocusProps}
onClick={onInterrupt}
aria-label="Stop generation"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
<rect x="2" y="2" width="8" height="8" rx="1.5" />
</svg>
</button>
);
}

if (showPlanFollowUpPrompt) {
if (promptHasText) {
return (
Expand Down Expand Up @@ -202,7 +190,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
);
}

return (
const sendButton = (
<button
type="submit"
className={cn(
Expand Down Expand Up @@ -253,4 +241,25 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
)}
</button>
);

if (!isRunning) {
return sendButton;
}

return (
<>
<button
type="button"
className="flex size-8 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 sm:h-8 sm:w-8"
{...pointerFocusProps}
onClick={onInterrupt}
aria-label="Stop generation"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
<rect x="2" y="2" width="8" height="8" rx="1.5" />
</svg>
</button>
{showSendWhileRunning && hasSendableContent ? sendButton : null}
</>
);
});
Loading