feat(app): gate return-key submit icon to new workspace composer

The new-workspace composer is a one-shot "create" submit, not a chat send, so
a return-key glyph reads more accurately there than an arrow. Add a submitIcon
prop on Composer/MessageInput (defaulting to "arrow") and opt NewWorkspace
into "return".
This commit is contained in:
Mohamed Boudra
2026-04-18 16:03:08 +07:00
parent b0e0786419
commit 5d76cc8840
6 changed files with 44 additions and 6 deletions
@@ -176,6 +176,7 @@ vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) => const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name }); React.createElement("span", { ...props, "data-icon": name });
return { return {
ArrowUp: createIcon("ArrowUp"),
CornerDownLeft: createIcon("CornerDownLeft"), CornerDownLeft: createIcon("CornerDownLeft"),
Square: createIcon("Square"), Square: createIcon("Square"),
Pencil: createIcon("Pencil"), Pencil: createIcon("Pencil"),
+3
View File
@@ -100,6 +100,7 @@ interface ComposerProps {
allowEmptySubmit?: boolean; allowEmptySubmit?: boolean;
/** Optional accessibility label for the primary submit button. */ /** Optional accessibility label for the primary submit button. */
submitButtonAccessibilityLabel?: string; submitButtonAccessibilityLabel?: string;
submitIcon?: "arrow" | "return";
/** Externally controlled loading state. When true, disables the submit button. */ /** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean; isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock"; submitBehavior?: "clear" | "preserve-and-lock";
@@ -142,6 +143,7 @@ export function Composer({
hasExternalContent = false, hasExternalContent = false,
allowEmptySubmit = false, allowEmptySubmit = false,
submitButtonAccessibilityLabel, submitButtonAccessibilityLabel,
submitIcon = "arrow",
isSubmitLoading = false, isSubmitLoading = false,
submitBehavior = "clear", submitBehavior = "clear",
blurOnSubmit = false, blurOnSubmit = false,
@@ -930,6 +932,7 @@ export function Composer({
hasExternalContent={hasExternalContent} hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit} allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel} submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitIcon={submitIcon}
isSubmitDisabled={isProcessing || isSubmitLoading} isSubmitDisabled={isProcessing || isSubmitLoading}
isSubmitLoading={isProcessing || isSubmitLoading} isSubmitLoading={isProcessing || isSubmitLoading}
attachments={selectedAttachments} attachments={selectedAttachments}
@@ -49,6 +49,7 @@ vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) => const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name }); React.createElement("span", { ...props, "data-icon": name });
return { return {
ArrowUp: createIcon("ArrowUp"),
Mic: createIcon("Mic"), Mic: createIcon("Mic"),
MicOff: createIcon("MicOff"), MicOff: createIcon("MicOff"),
CornerDownLeft: createIcon("CornerDownLeft"), CornerDownLeft: createIcon("CornerDownLeft"),
@@ -226,18 +227,27 @@ afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
function renderMessageInput(menuItems: AttachmentMenuItem[]) { interface RenderMessageInputOptions {
value?: string;
submitIcon?: "arrow" | "return";
}
function renderMessageInput(
menuItems: AttachmentMenuItem[],
{ value = "", submitIcon }: RenderMessageInputOptions = {},
) {
act(() => { act(() => {
root?.render( root?.render(
<MessageInput <MessageInput
value="" value={value}
onChangeText={vi.fn()} onChangeText={vi.fn()}
onSubmit={vi.fn()} onSubmit={vi.fn()}
attachments={[]} attachments={[]}
cwd="/repo" cwd="/repo"
attachmentMenuItems={menuItems} attachmentMenuItems={menuItems}
client={{ isConnected: true } as never} client={{ isConnected: true } as never}
isAgentRunning isAgentRunning={false}
submitIcon={submitIcon}
onQueue={vi.fn()} onQueue={vi.fn()}
/>, />,
); );
@@ -302,4 +312,16 @@ describe("MessageInput attachments", () => {
expect(queryAllByAriaLabel("Queue message")).toHaveLength(0); expect(queryAllByAriaLabel("Queue message")).toHaveLength(0);
}); });
it("uses ArrowUp by default and CornerDownLeft when return submit icon is requested", () => {
renderMessageInput([], { value: "Send this" });
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(1);
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(0);
renderMessageInput([], { value: "Create this", submitIcon: "return" });
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(0);
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(1);
});
}); });
@@ -20,7 +20,7 @@ import {
forwardRef, forwardRef,
} from "react"; } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation"; import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "./dictation-controls"; import { DictationOverlay } from "./dictation-controls";
@@ -85,6 +85,7 @@ export interface MessageInputProps {
allowEmptySubmit?: boolean; allowEmptySubmit?: boolean;
/** Optional accessibility label for the primary submit button. */ /** Optional accessibility label for the primary submit button. */
submitButtonAccessibilityLabel?: string; submitButtonAccessibilityLabel?: string;
submitIcon?: "arrow" | "return";
isSubmitDisabled?: boolean; isSubmitDisabled?: boolean;
isSubmitLoading?: boolean; isSubmitLoading?: boolean;
attachments: ComposerAttachment[]; attachments: ComposerAttachment[];
@@ -217,6 +218,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
hasExternalContent = false, hasExternalContent = false,
allowEmptySubmit = false, allowEmptySubmit = false,
submitButtonAccessibilityLabel, submitButtonAccessibilityLabel,
submitIcon = "arrow",
isSubmitDisabled = false, isSubmitDisabled = false,
isSubmitLoading = false, isSubmitLoading = false,
attachments, attachments,
@@ -1180,8 +1182,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
> >
{isSubmitLoading ? ( {isSubmitLoading ? (
<ActivityIndicator size="small" color="white" /> <ActivityIndicator size="small" color="white" />
) : ( ) : submitIcon === "return" ? (
<CornerDownLeft size={buttonIconSize} color="white" /> <CornerDownLeft size={buttonIconSize} color="white" />
) : (
<ArrowUp size={buttonIconSize} color="white" />
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
@@ -222,6 +222,7 @@ vi.mock("@/components/composer", () => ({
Composer: ({ Composer: ({
onSubmitMessage, onSubmitMessage,
submitBehavior, submitBehavior,
submitIcon,
isSubmitLoading, isSubmitLoading,
value, value,
onChangeText, onChangeText,
@@ -234,13 +235,18 @@ vi.mock("@/components/composer", () => ({
cwd: string; cwd: string;
}) => void; }) => void;
submitBehavior?: "clear" | "preserve-and-lock"; submitBehavior?: "clear" | "preserve-and-lock";
submitIcon?: "arrow" | "return";
isSubmitLoading?: boolean; isSubmitLoading?: boolean;
value: string; value: string;
onChangeText: (text: string) => void; onChangeText: (text: string) => void;
attachments: ComposerAttachment[]; attachments: ComposerAttachment[];
onChangeAttachments: (attachments: ComposerAttachment[]) => void; onChangeAttachments: (attachments: ComposerAttachment[]) => void;
}) => ( }) => (
<div data-testid="test-composer" data-submit-behavior={submitBehavior}> <div
data-testid="test-composer"
data-submit-behavior={submitBehavior}
data-submit-icon={submitIcon}
>
<textarea <textarea
aria-label="Message agent..." aria-label="Message agent..."
disabled={submitBehavior === "preserve-and-lock" && isSubmitLoading} disabled={submitBehavior === "preserve-and-lock" && isSubmitLoading}
@@ -759,6 +765,7 @@ describe("NewWorkspaceScreen picker payload", () => {
const textInput = document.querySelector('[aria-label="Message agent..."]'); const textInput = document.querySelector('[aria-label="Message agent..."]');
const removeButton = document.querySelector(`[aria-label="Remove PR #${prItem.number}"]`); const removeButton = document.querySelector(`[aria-label="Remove PR #${prItem.number}"]`);
expect(queryByTestId("test-composer")?.dataset.submitBehavior).toBe("preserve-and-lock"); expect(queryByTestId("test-composer")?.dataset.submitBehavior).toBe("preserve-and-lock");
expect(queryByTestId("test-composer")?.dataset.submitIcon).toBe("return");
expect(textInput).toHaveProperty("value", "please review this change"); expect(textInput).toHaveProperty("value", "please review this change");
expect(textInput).toHaveProperty("disabled", true); expect(textInput).toHaveProperty("disabled", true);
expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull(); expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull();
@@ -462,6 +462,7 @@ export function NewWorkspaceScreen({
onSubmitMessage={handleCreateChatAgent} onSubmitMessage={handleCreateChatAgent}
allowEmptySubmit={true} allowEmptySubmit={true}
submitButtonAccessibilityLabel="Create" submitButtonAccessibilityLabel="Create"
submitIcon="return"
isSubmitLoading={pendingAction === "chat"} isSubmitLoading={pendingAction === "chat"}
submitBehavior="preserve-and-lock" submitBehavior="preserve-and-lock"
blurOnSubmit={true} blurOnSubmit={true}