Launch a terminal instead of a chat from New workspace (#2941)
* feat(app): launch a terminal from the New workspace screen
New workspace only ever started a chat agent. Empty workspaces existed but
were reachable only by submitting an empty composer, which nothing advertised,
and terminals could not be started from this screen at all. People concluded
Paseo did not support terminals.
Adds a launch chip to the meta row, shaped from the same ComboboxTrigger as
its neighbours, that chooses between Chat and a terminal. Terminal profiles
mark where a typed prompt goes with a {{{prompt}}} sentinel in their command
or args, substituted client-side before create_terminal_request, so the daemon
learns nothing new and the protocol is untouched. A profile without the
sentinel takes no input: the composer goes read-only and shows the exact
command, so there is nothing to type wrong.
Prompt forms come from each CLI's own --help. claude, codex, and pi take a
trailing positional; opencode's positional is the project directory, so it
uses --prompt=. Profiles predating the sentinel adopt the right form on read,
keyed on the command's base name rather than the profile id, because profiles
created in settings get generated ids.
* fix(app): stop existing launchers passing the prompt sentinel literally
Adoption made every caller of resolveTerminalProfiles see sentinel-bearing
args, but only New workspace substituted them. Pinned targets and the
workspace terminal menu forwarded profile.args straight through, so launching
Claude from an existing workspace ran `claude "{{{prompt}}}"` with that text as
the prompt.
Every launcher now goes through resolveTerminalProfileLaunch. Surfaces with
nowhere to type pass an empty prompt, which drops the prompt-only arg and
gives back the bare command they had before.
Also from the same review:
- Adoption skipped when args lead with a subcommand. `opencode run` takes a
positional message rather than --prompt, and `codex login` takes no prompt at
all. Only the first arg is checked, so `pi --thinking high` still adopts.
- Settings edits the persisted list again, not the adopted view. It writes the
whole list back on any change, so resolving first baked read-time adoption
into the user's config the moment they reordered a row.
- A hidden draft no longer names a workspace. Text typed for a prompt-taking
profile stayed in state after switching to a read-only one and still seeded
the generated title and branch.
- Windows .cmd escaping now has a round-trip test: a real cmd.exe runs a real
.cmd shim that echoes its argv, covering metacharacters, an injection
attempt, and newlines. The existing tests only asserted the command line we
generate.
- Fixed an ENOTEMPTY flake in the terminal suite. kill() only signals, so the
shell was still writing into $ZDOTDIR while its directory was being removed.
Cleanup waits for exit instead.
* fix(app): keep the host and isolation accessible names stable
Wiring the new tooltips also replaced these two accessible names with the
tooltip copy, which broke `sidebar-project-grouping` at the host picker: its
selector is `getByRole("button", { name: "Host", exact: true })` and the button
had become "Choose the host". It timed out on both attempts in CI.
An accessible name identifies a control; a tooltip explains it. Conflating them
renamed a control that other surfaces address by name. The names go back to
what they were and the tooltips keep the "Choose the ..." copy.
* test(server): stop temp-dir teardown failing unrelated terminal tests
The ENOTEMPTY flake survived both earlier attempts. Waiting for the session to
exit shrank the window; retrying the remove did not close it either. The writes
come from processes zsh forks on the way out, which outlive the pty and answer
to nothing the test holds.
Teardown is now best-effort and logs what it left behind. A temp directory we
cannot remove is not a defect in the terminal code under test, and failing on it
reports the race against whichever assertion ran last, which is exactly how this
surfaced: as a flake in an unrelated zsh title test. 10 consecutive runs green.
Also replaces a cast in the Windows .cmd helper with a real narrow, so a change
to ResolvedTerminalCommand fails loudly there. Test files are excluded from
typecheck (tsconfig.server.json), so a cast in one is never checked at all.
* fix(app): keep terminal launch policy in its owning workflows
Profile adoption now parses known global option values before deciding whether argv already contains a positional. Workspace naming is derived inside the terminal workflow so hidden launch-only drafts cannot affect branch or title generation.
* fix(server): preserve cmd shim arguments through both parses
npm command shims forward %*, so metacharacters lose one escape layer before the target process receives argv. Exercise the production node-pty string command line against a forwarding shim, and only tolerate the known ENOTEMPTY teardown race.
* fix(server): stabilize Windows subprocess tests
* test(server): isolate Windows argv recorder output
* test(server): allow native watcher integration to settle
This commit is contained in:
@@ -89,6 +89,7 @@ import {
|
||||
persistAttachmentFromFileUri,
|
||||
} from "@/attachments/service";
|
||||
import { resolveAgentControlsMode } from "@/composer/agent-controls/mode";
|
||||
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
@@ -186,7 +187,21 @@ function resolveCompactLayout(override: boolean | undefined, formFactor: boolean
|
||||
return override ?? formFactor;
|
||||
}
|
||||
|
||||
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean, t: TFunction): string {
|
||||
function resolveMessagePlaceholder(
|
||||
inputMode: ComposerInputMode,
|
||||
isDesktopWebBreakpoint: boolean,
|
||||
t: TFunction,
|
||||
override: string | undefined,
|
||||
): string {
|
||||
// A terminal placeholder names what it launches ("Prompt Codex", "Run a
|
||||
// command"), which depends on the selected profile. Only the caller knows
|
||||
// that, so it wins when supplied.
|
||||
if (override !== undefined) {
|
||||
return override;
|
||||
}
|
||||
if (inputMode === "terminal") {
|
||||
return t("composer.placeholders.terminal");
|
||||
}
|
||||
return isDesktopWebBreakpoint
|
||||
? t("composer.placeholders.desktop")
|
||||
: t("composer.placeholders.mobile");
|
||||
@@ -280,10 +295,12 @@ interface RenderLeftContentArgs {
|
||||
focusInput: () => void;
|
||||
isCompactLayout: boolean;
|
||||
isPaneFocused: boolean;
|
||||
showAgentControls: boolean;
|
||||
}
|
||||
|
||||
function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
|
||||
function renderLeftContent(args: RenderLeftContentArgs): ReactElement | null {
|
||||
const { agentControls, agentId, serverId, focusInput, isCompactLayout, isPaneFocused } = args;
|
||||
if (!args.showAgentControls) return null;
|
||||
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
|
||||
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
|
||||
}
|
||||
@@ -858,6 +875,18 @@ interface ComposerProps {
|
||||
externalKeyboardShift?: boolean;
|
||||
/** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */
|
||||
isCompactLayout?: boolean;
|
||||
/**
|
||||
* What this composer is for. Terminal drops the chat-agent affordances and
|
||||
* uses the terminal font; see `@/composer/input-mode`. Callers set the mode
|
||||
* and nothing else — never branch on it at the call site.
|
||||
*/
|
||||
inputMode?: ComposerInputMode;
|
||||
/** Renders `value` as static text on the same surface, for content there is nothing to type into. */
|
||||
readOnly?: boolean;
|
||||
/** Replaces the submit icon with this label, still inside the composer's own toolbar row. */
|
||||
submitLabel?: string;
|
||||
/** Overrides the mode's default placeholder, for text only the caller can build. */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
|
||||
@@ -942,6 +971,7 @@ interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
|
||||
isAgentRunning: boolean;
|
||||
hasSendableContent: boolean;
|
||||
isCompact: boolean;
|
||||
showVoice: boolean;
|
||||
}
|
||||
|
||||
function ComposerRightControlsSlot({
|
||||
@@ -950,11 +980,12 @@ function ComposerRightControlsSlot({
|
||||
isAgentRunning,
|
||||
hasSendableContent,
|
||||
isCompact,
|
||||
showVoice,
|
||||
...voiceProps
|
||||
}: ComposerRightControlsSlotProps) {
|
||||
const hideVoiceForCompactInput = isCompact && hasSendableContent;
|
||||
const showVoiceModeButton =
|
||||
!isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput;
|
||||
showVoice && !isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput;
|
||||
if (!showVoiceModeButton) return null;
|
||||
return (
|
||||
<View style={styles.rightControls}>
|
||||
@@ -1043,7 +1074,12 @@ export function Composer({
|
||||
inputWrapperStyle,
|
||||
externalKeyboardShift,
|
||||
isCompactLayout: isCompactLayoutOverride,
|
||||
inputMode = "chat",
|
||||
readOnly = false,
|
||||
submitLabel,
|
||||
placeholder,
|
||||
}: ComposerProps) {
|
||||
const mode = resolveComposerInputMode(inputMode);
|
||||
const { t } = useTranslation();
|
||||
const buttonIconSize = resolveComposerButtonIconSize();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
@@ -1076,7 +1112,7 @@ export function Composer({
|
||||
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
|
||||
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
|
||||
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder);
|
||||
const userInput = value;
|
||||
const setUserInput = onChangeText;
|
||||
const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys);
|
||||
@@ -1743,6 +1779,7 @@ export function Composer({
|
||||
isAgentRunning={isAgentRunning}
|
||||
hasSendableContent={hasSendableContent}
|
||||
isCompact={isCompactLayout}
|
||||
showVoice={mode.showVoice}
|
||||
buttonIconSize={buttonIconSize}
|
||||
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
|
||||
isConnected={isConnected}
|
||||
@@ -1762,6 +1799,7 @@ export function Composer({
|
||||
isCompactLayout,
|
||||
isVoiceModeForAgent,
|
||||
isVoiceSwitching,
|
||||
mode.showVoice,
|
||||
realtimeVoiceButtonStyle,
|
||||
t,
|
||||
voiceToggleKeys,
|
||||
@@ -1925,8 +1963,17 @@ export function Composer({
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
isPaneFocused,
|
||||
showAgentControls: mode.showAgentControls,
|
||||
}),
|
||||
[agentControls, agentId, focusInput, isCompactLayout, isPaneFocused, serverId],
|
||||
[
|
||||
agentControls,
|
||||
agentId,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
isPaneFocused,
|
||||
mode.showAgentControls,
|
||||
serverId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||
@@ -2051,7 +2098,7 @@ export function Composer({
|
||||
const githubEmptyText = githubSearchResultsQuery.isFetching
|
||||
? t("composer.github.searching")
|
||||
: t("composer.github.noResults");
|
||||
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
|
||||
const autocompleteVisible = autocomplete.isVisible && isPaneFocused && mode.showAutocomplete;
|
||||
|
||||
return (
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||
@@ -2118,6 +2165,9 @@ export function Composer({
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
inputMode={inputMode}
|
||||
readOnly={readOnly}
|
||||
submitLabel={submitLabel}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* What a composer is being used for. `Composer` and `MessageInput` are one
|
||||
* component with two jobs, not two components: a terminal prompt still wants
|
||||
* the same bordered surface, grow-on-newline, submit button, focus handling,
|
||||
* and keyboard shift that a chat message gets.
|
||||
*
|
||||
* Callers pick a mode and nothing else. What a mode implies lives here, so the
|
||||
* two presentations cannot drift apart one prop at a time.
|
||||
*/
|
||||
export type ComposerInputMode = "chat" | "terminal";
|
||||
|
||||
export interface ComposerInputModePresentation {
|
||||
/** Attachments, dictation/voice, slash-and-@ autocomplete, and the model/mode
|
||||
* chips are all chat-agent affordances. A terminal prompt becomes argv. */
|
||||
showAttachments: boolean;
|
||||
showVoice: boolean;
|
||||
showAutocomplete: boolean;
|
||||
showAgentControls: boolean;
|
||||
/** Argv is read character by character, so it gets the terminal's own font. */
|
||||
isMonospace: boolean;
|
||||
/** i18n key for the text input's accessible name. */
|
||||
accessibilityLabelKey: string;
|
||||
}
|
||||
|
||||
const PRESENTATION_BY_MODE: Record<ComposerInputMode, ComposerInputModePresentation> = {
|
||||
chat: {
|
||||
showAttachments: true,
|
||||
showVoice: true,
|
||||
showAutocomplete: true,
|
||||
showAgentControls: true,
|
||||
isMonospace: false,
|
||||
accessibilityLabelKey: "composer.input.accessibilityLabel",
|
||||
},
|
||||
terminal: {
|
||||
showAttachments: false,
|
||||
showVoice: false,
|
||||
showAutocomplete: false,
|
||||
showAgentControls: false,
|
||||
isMonospace: true,
|
||||
accessibilityLabelKey: "composer.input.terminalAccessibilityLabel",
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveComposerInputMode(mode: ComposerInputMode): ComposerInputModePresentation {
|
||||
return PRESENTATION_BY_MODE[mode];
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useComposerHeightMirror } from "./height-mirror";
|
||||
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
|
||||
import {
|
||||
resolveSendTooltipLabel,
|
||||
resolveSubmitAccessibilityLabel,
|
||||
@@ -142,6 +143,12 @@ export interface MessageInputProps {
|
||||
inputWrapperStyle?: import("react-native").ViewStyle;
|
||||
/** Content rendered inside the bordered input surface, above the text input (e.g. attachment pills). */
|
||||
attachmentSlot?: React.ReactNode;
|
||||
/** What this composer is for. See `@/composer/input-mode` for what each mode implies. */
|
||||
inputMode?: ComposerInputMode;
|
||||
/** Renders `value` as static text on the same surface, for content there is nothing to type into. */
|
||||
readOnly?: boolean;
|
||||
/** Replaces the submit icon with this label, still inside the composer's own toolbar row. */
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export interface MessageInputRef {
|
||||
@@ -220,6 +227,7 @@ function AttachmentMenuList({ items }: { items: AttachmentMenuItem[] }) {
|
||||
}
|
||||
|
||||
function AttachmentDropdown({
|
||||
visible,
|
||||
isConnected,
|
||||
disabled,
|
||||
attachButtonStyle,
|
||||
@@ -227,6 +235,7 @@ function AttachmentDropdown({
|
||||
attachmentMenuItems,
|
||||
addAttachmentLabel,
|
||||
}: {
|
||||
visible: boolean;
|
||||
isConnected: boolean;
|
||||
disabled: boolean;
|
||||
attachButtonStyle: React.ComponentProps<typeof DropdownMenuTrigger>["style"];
|
||||
@@ -235,6 +244,7 @@ function AttachmentDropdown({
|
||||
addAttachmentLabel: string;
|
||||
}) {
|
||||
const isButtonDisabled = !isConnected || disabled;
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
@@ -323,15 +333,20 @@ function SendTooltipBody({
|
||||
function SendButtonContent({
|
||||
isSubmitLoading,
|
||||
submitIcon,
|
||||
submitLabel,
|
||||
buttonIconSize,
|
||||
}: {
|
||||
isSubmitLoading: boolean;
|
||||
submitIcon: "arrow" | "return";
|
||||
submitLabel: string | undefined;
|
||||
buttonIconSize: number;
|
||||
}) {
|
||||
if (isSubmitLoading) {
|
||||
return <ThemedLoadingSpinner size="small" uniProps={iconAccentForegroundMapping} />;
|
||||
}
|
||||
if (submitLabel) {
|
||||
return <Text style={styles.sendButtonLabel}>{submitLabel}</Text>;
|
||||
}
|
||||
if (submitIcon === "return") {
|
||||
return <ThemedCornerDownLeft size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
|
||||
}
|
||||
@@ -570,7 +585,75 @@ function FocusHint({
|
||||
);
|
||||
}
|
||||
|
||||
interface ComposerTextSurfaceProps {
|
||||
readOnly: boolean;
|
||||
value: string;
|
||||
textInputRef: React.Ref<TextInput>;
|
||||
textInputStyle: React.ComponentProps<typeof ThemedTextInput>["style"];
|
||||
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
|
||||
placeholder: string;
|
||||
accessibilityLabel: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
editable: boolean;
|
||||
scrollEnabled: boolean;
|
||||
autoFocus: boolean;
|
||||
onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
|
||||
onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined;
|
||||
onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void;
|
||||
focusHintVisible: boolean;
|
||||
focusInputKeys: ShortcutChord | null | undefined;
|
||||
focusHintLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer's content: an editable input, or static text when there is
|
||||
* nothing to type. Both sit in the same bordered surface, so read-only is a
|
||||
* state of this composer rather than a second one.
|
||||
*/
|
||||
function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement {
|
||||
if (props.readOnly) {
|
||||
return (
|
||||
<View style={styles.textInputScrollWrapper}>
|
||||
<Text style={props.readOnlyTextStyle} testID="composer-readonly-content">
|
||||
{props.value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.textInputScrollWrapper}>
|
||||
<ThemedTextInput
|
||||
ref={props.textInputRef}
|
||||
dataSet={COMPOSER_INPUT_DATASET}
|
||||
value={props.value}
|
||||
onChangeText={props.onChangeText}
|
||||
placeholder={props.placeholder}
|
||||
uniProps={textInputPlaceholderColorMapping}
|
||||
accessibilityLabel={props.accessibilityLabel}
|
||||
onFocus={props.onFocus}
|
||||
onBlur={props.onBlur}
|
||||
style={props.textInputStyle}
|
||||
multiline
|
||||
scrollEnabled={props.scrollEnabled}
|
||||
onContentSizeChange={props.onContentSizeChange}
|
||||
editable={props.editable}
|
||||
onKeyPress={props.onKeyPress}
|
||||
onSelectionChange={props.onSelectionChange}
|
||||
autoFocus={props.autoFocus}
|
||||
/>
|
||||
<FocusHint
|
||||
visible={props.focusHintVisible}
|
||||
focusInputKeys={props.focusInputKeys}
|
||||
label={props.focusHintLabel}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function VoiceButtonTooltip({
|
||||
visible,
|
||||
onVoicePress,
|
||||
isDictationStartEnabled,
|
||||
voiceButtonAccessibilityLabel,
|
||||
@@ -581,6 +664,7 @@ function VoiceButtonTooltip({
|
||||
voiceMuteToggleKeys,
|
||||
dictationToggleKeys,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onVoicePress: () => void;
|
||||
isDictationStartEnabled: boolean;
|
||||
voiceButtonAccessibilityLabel: string;
|
||||
@@ -592,6 +676,7 @@ function VoiceButtonTooltip({
|
||||
dictationToggleKeys: ShortcutChord | null | undefined;
|
||||
}) {
|
||||
const shortcut = isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys;
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
@@ -620,6 +705,7 @@ function SendButtonTooltip({
|
||||
sendButtonCombinedStyle,
|
||||
isSubmitLoading,
|
||||
submitIcon,
|
||||
submitLabel,
|
||||
submitButtonTestID,
|
||||
buttonIconSize,
|
||||
sendKeys,
|
||||
@@ -634,6 +720,7 @@ function SendButtonTooltip({
|
||||
sendButtonCombinedStyle: React.ComponentProps<typeof TooltipTrigger>["style"];
|
||||
isSubmitLoading: boolean;
|
||||
submitIcon: "arrow" | "return";
|
||||
submitLabel: string | undefined;
|
||||
submitButtonTestID: string | undefined;
|
||||
buttonIconSize: number;
|
||||
sendKeys: ShortcutChord | null | undefined;
|
||||
@@ -653,6 +740,7 @@ function SendButtonTooltip({
|
||||
<SendButtonContent
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitIcon={submitIcon}
|
||||
submitLabel={submitLabel}
|
||||
buttonIconSize={buttonIconSize}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
@@ -954,6 +1042,9 @@ interface ResolvedMessageInputProps {
|
||||
onHeightChange: ((height: number) => void) | undefined;
|
||||
inputWrapperStyle: import("react-native").ViewStyle | undefined;
|
||||
attachmentSlot: React.ReactNode;
|
||||
inputMode: ComposerInputMode;
|
||||
readOnly: boolean;
|
||||
submitLabel: string | undefined;
|
||||
}
|
||||
|
||||
function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInputProps {
|
||||
@@ -997,6 +1088,9 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
||||
onHeightChange: props.onHeightChange,
|
||||
inputWrapperStyle: props.inputWrapperStyle,
|
||||
attachmentSlot: props.attachmentSlot,
|
||||
inputMode: props.inputMode ?? "chat",
|
||||
readOnly: props.readOnly ?? false,
|
||||
submitLabel: props.submitLabel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1048,7 +1142,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onHeightChange,
|
||||
inputWrapperStyle,
|
||||
attachmentSlot,
|
||||
inputMode,
|
||||
readOnly,
|
||||
submitLabel,
|
||||
} = resolveMessageInputProps(props);
|
||||
const mode = resolveComposerInputMode(inputMode);
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
@@ -1546,18 +1644,38 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const inputWrapperCombinedStyle = useMemo(
|
||||
() => [
|
||||
styles.inputWrapper,
|
||||
readOnly && styles.inputWrapperReadOnly,
|
||||
inputWrapperStyle,
|
||||
{ opacity: surfacePresentation.input.opacity },
|
||||
],
|
||||
[inputWrapperStyle, surfacePresentation.input.opacity],
|
||||
[inputWrapperStyle, readOnly, surfacePresentation.input.opacity],
|
||||
);
|
||||
// `withUnistyles` maps this component's `style` into a `.hash > *` child
|
||||
// rule, which ties on specificity with react-native-web's own
|
||||
// `.css-textinput-*` class and loses on source order — so a themed
|
||||
// `fontFamily` here is silently dropped while every other property lands.
|
||||
// An inline style outranks both classes. See docs/unistyles.md.
|
||||
const textInputStyle = useMemo(
|
||||
() => [styles.textInput, computeTextInputHeightStyle(inputHeight, maxInputHeight)],
|
||||
[inputHeight, maxInputHeight],
|
||||
() => [
|
||||
styles.textInput,
|
||||
mode.isMonospace && styles.textInputMonospace,
|
||||
computeTextInputHeightStyle(inputHeight, maxInputHeight),
|
||||
],
|
||||
[inputHeight, maxInputHeight, mode.isMonospace],
|
||||
);
|
||||
// Static content has no textarea to mirror, so it grows with its own text
|
||||
// instead of the measured input height.
|
||||
const readOnlyTextStyle = useMemo(
|
||||
() => [styles.textInput, mode.isMonospace && styles.textInputMonospace, styles.readOnlyText],
|
||||
[mode.isMonospace],
|
||||
);
|
||||
const sendButtonCombinedStyle = useMemo(
|
||||
() => [styles.sendButton, isSendButtonDisabled && styles.buttonDisabled],
|
||||
[isSendButtonDisabled],
|
||||
() => [
|
||||
styles.sendButton,
|
||||
submitLabel ? styles.sendButtonLabeled : undefined,
|
||||
isSendButtonDisabled && styles.buttonDisabled,
|
||||
],
|
||||
[isSendButtonDisabled, submitLabel],
|
||||
);
|
||||
const overlayContainerStyle = useMemo(
|
||||
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
|
||||
@@ -1597,40 +1715,36 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
>
|
||||
{attachmentSlot}
|
||||
{/* Text input */}
|
||||
<View style={styles.textInputScrollWrapper}>
|
||||
<ThemedTextInput
|
||||
ref={textInputRef}
|
||||
dataSet={COMPOSER_INPUT_DATASET}
|
||||
value={value}
|
||||
onChangeText={handleInputChange}
|
||||
placeholder={placeholder ?? t("composer.placeholders.fallback")}
|
||||
uniProps={textInputPlaceholderColorMapping}
|
||||
accessibilityLabel={t("composer.input.accessibilityLabel")}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
style={textInputStyle}
|
||||
multiline
|
||||
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
autoFocus={isWeb && autoFocus}
|
||||
/>
|
||||
<FocusHint
|
||||
visible={isWeb && isPaneFocused && !isInputFocused && !value}
|
||||
focusInputKeys={focusInputKeys}
|
||||
label={t("composer.input.focusHint", {
|
||||
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
<ComposerTextSurface
|
||||
readOnly={readOnly}
|
||||
value={value}
|
||||
textInputRef={textInputRef}
|
||||
textInputStyle={textInputStyle}
|
||||
readOnlyTextStyle={readOnlyTextStyle}
|
||||
placeholder={placeholder ?? t("composer.placeholders.fallback")}
|
||||
accessibilityLabel={t(mode.accessibilityLabelKey)}
|
||||
onChangeText={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true}
|
||||
autoFocus={isWeb && autoFocus}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
focusHintVisible={isWeb && isPaneFocused && !isInputFocused && !value}
|
||||
focusInputKeys={focusInputKeys}
|
||||
focusHintLabel={t("composer.input.focusHint", {
|
||||
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Button row */}
|
||||
<View style={styles.buttonRow}>
|
||||
{/* Toolbar left: attachment button + agent controls */}
|
||||
<View style={styles.leftButtonGroup}>
|
||||
<AttachmentDropdown
|
||||
visible={mode.showAttachments}
|
||||
isConnected={isConnected}
|
||||
disabled={disabled}
|
||||
attachButtonStyle={attachButtonStyle}
|
||||
@@ -1645,6 +1759,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
<View style={styles.rightButtonGroup}>
|
||||
{beforeVoiceContent}
|
||||
<VoiceButtonTooltip
|
||||
visible={mode.showVoice}
|
||||
onVoicePress={handleVoicePress}
|
||||
isDictationStartEnabled={isDictationStartEnabled}
|
||||
voiceButtonAccessibilityLabel={voiceButtonAccessibilityLabel}
|
||||
@@ -1668,6 +1783,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
sendButtonCombinedStyle={sendButtonCombinedStyle}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitIcon={submitIcon}
|
||||
submitLabel={submitLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
buttonIconSize={buttonIconSize}
|
||||
sendKeys={DEFAULT_SEND_KEYS}
|
||||
@@ -1731,6 +1847,11 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
// Dotted says "this surface is the same box, but there is nothing to type
|
||||
// into it" without swapping the border colour, which reads as an error state.
|
||||
inputWrapperReadOnly: {
|
||||
borderStyle: "dotted",
|
||||
},
|
||||
textInputScrollWrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
@@ -1756,6 +1877,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
} as object)
|
||||
: {}),
|
||||
},
|
||||
textInputMonospace: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
},
|
||||
readOnlyText: {
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
@@ -1808,6 +1936,17 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
justifyContent: "center",
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sendButtonLabeled: {
|
||||
width: "auto",
|
||||
minWidth: 28,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
sendButtonLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.accentForeground,
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user