Skip to content

[Due for payment 2026-08-19] [$250] Add react-native-live-markdown to the Custom Agent Instructions input #96902

Description

@roryabraham

Problem

Agent instructions (the "prompt") are stored and rendered as Markdown everywhere they're displayed — e.g. the agent's own Account > Profile > AI Prompt view and any chat messages the agent posts — but the inputs used to write those instructions are plain TextInput components with no live markdown formatting:

  • src/pages/settings/Agents/Fields/EditPromptPage.tsx (Edit agent > Write custom instructions)
  • src/pages/settings/Agents/AddAgentPage.tsx (Create agent)
  • src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx (Workspace > Rules > Add agent rule > Write your own)

This means the user gets no visual feedback (bold, headers, lists, etc.) while authoring instructions, even though what they type is later parsed and rendered as Markdown.

Solution

Swap the plain TextInput InputComponent in the pages above for @expensify/react-native-live-markdown's MarkdownTextInput, consistent with how the chat composer (Composer) already uses it, so formatting renders live as the admin/user types their instructions.

Manual Test Steps

  1. Go to Account > Agents > Create agent, or edit an existing agent's instructions.
  2. Type Markdown syntax (e.g. *bold*, # heading, - list item) into the instructions input.
  3. Verify the formatting is rendered live in the input, matching the chat composer's behavior.
  4. Repeat for Workspace > Rules > Add agent rule > Write your own tab.
Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. self-assigned this
    on Jul 23, 2026
  2. added
    ExternalAdded to denote the issue can be worked on by a contributor
    BugSomething is broken. Auto assigns a BugZero manager.
    on Jul 23, 2026
  3. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Jul 23, 2026
  4. melvin-bot commented on Jul 23, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @Pujan92 (External)

  5. changed the title [-]Add react-native-live-markdown to the Custom Agent Instructions input[/-] [+][$250] Add react-native-live-markdown to the Custom Agent Instructions input[/+] on Jul 23, 2026
  6. melvin-bot commented on Jul 23, 2026

    @melvin-bot
  7. godzillaxbox19982014-lgtm commented on Jul 23, 2026

    @godzillaxbox19982014-lgtm

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    [$250] Add react-native-live-markdown to the Custom Agent Instructions input

    What is the root cause of that problem?

    I traced this to the path that handles TextInput, EditPromptPage, AddAgentPage:

    • src/libs/actions/Agent.ts:273 (params): const params = isCustomExpensifyAvatar ? {agentAccountID: accountID, customExpensifyAvatarID: update.customExpensifyAvatarID} : {agentAccountID: accountID, file: update.file};
    • src/libs/actions/Agent.ts:352 (set): return Onyx.set(ONYXKEYS.AGENT_NEW_AVATAR_DRAFT, {customExpensifyAvatarID});
    • src/components/AIFeaturesPromoModal.tsx:54 (translate): confirmText: canUseCustomAgent ? translate('common.next') : translate('aiFeaturesPromoModal.confirmText'),
    • src/libs/actions/Agent.ts:49 (if): if (customExpensifyAvatarID) {
    • src/libs/actions/Agent.ts:273 is the first concrete decision that matches TextInput, EditPromptPage, AddAgentPage, and src/libs/actions/Agent.ts:352 is the next matching consumer/mutation. The failing behavior is introduced at this handoff: the second path does not preserve the condition/state established by the first path for the repro case.

    What changes do you think we should make in order to solve the problem?

    • Change the issue-matching decision at src/libs/actions/Agent.ts:273 so the TextInput, EditPromptPage, AddAgentPage repro state is represented before src/libs/actions/Agent.ts:352 consumes it.
    • Keep the patch at this first decision/consumer boundary (add a local hasEverLoaded/hasInteracted guard) instead of masking the downstream UI symptom.
    • Keep the change inside the UI, Forms, Lists, Accessibility surface and avoid changing platform-specific variants, screen reader behaviour.

    What specific scenarios should we cover in automated tests to prevent reintroducing this issue in the future?

    1. Add or update a regression in tests/components/**/*.ts for the exact issue flow: [$250] Add react-native-live-markdown to the Custom Agent Instructions input.
    2. Cover the counter-case near src/libs/actions/Agent.ts:273 so valid existing TextInput, EditPromptPage, AddAgentPage behavior still works.
    3. Manually repeat the issue steps after the patch and confirm the visible state/action is no longer stale.

    What alternative solutions did you explore? (Optional)

    • I would not add a broad new condition outside these referenced paths; the safer fix is to keep TextInput, EditPromptPage, AddAgentPage consistent at the first read/write boundary shown above.
  8. abbasifaizan70 commented on Jul 23, 2026

    @abbasifaizan70
    Contributor

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    Agent instructions ("prompt") are parsed and rendered as Markdown everywhere they're displayed (e.g. Account > Profile > AI Prompt, chat messages the agent posts), but the three inputs used to author those instructions render as plain text with no live formatting:

    • src/pages/settings/Agents/Fields/EditPromptPage.tsx (Edit agent instructions)
    • src/pages/settings/Agents/AddAgentPage.tsx (Create agent)
    • src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx (Workspace > Rules > Add agent rule > Write your own)

    We need these inputs to render bold/headers/lists live as the admin types, consistent with the chat composer.

    What is the root cause of that problem?

    All three pages pass the shared TextInput component to InputWrapper as InputComponent={TextInput} without opting into markdown rendering:

    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={translate('editAgentPage.instructions')}
    accessibilityLabel={translate('editAgentPage.instructions')}
    role={CONST.ROLE.PRESENTATION}
    defaultValue={Str.htmlDecode(agentPrompt?.prompt ?? '')}
    multiline
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
    />

    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={translate('addAgentPage.instructions')}
    accessibilityLabel={translate('addAgentPage.instructions')}
    role={CONST.ROLE.PRESENTATION}
    defaultValue={defaultPrompt}
    multiline
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
    />

    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={describeRuleLabel}
    accessibilityLabel={describeRuleLabel}
    role={CONST.ROLE.PRESENTATION}
    onKeyPress={submitFormOnModEnter}
    multiline
    shouldSaveDraft
    shouldLabelStayOnSingleLine
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
    />

    TextInput's underlying BaseTextInput already has built-in support for live markdown: it accepts a type prop, and when type === 'markdown' it swaps its internal input implementation to RNMarkdownTextInput (the same @expensify/react-native-live-markdown-backed component the chat Composer uses), instead of the plain native input:

    import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
    import RNMaskedTextInput from '@components/RNMaskedTextInput';
    import RNTextInput from '@components/RNTextInput';
    import type {BaseTextInputProps, InputType} from './types';
    type InputComponentType = React.ComponentType<BaseTextInputProps>;
    const InputComponentMap = new Map<InputType, InputComponentType>([
    ['default', RNTextInput as InputComponentType],
    ['mask', RNMaskedTextInput as InputComponentType],
    ['markdown', RNMarkdownTextInput as InputComponentType],
    ]);

    Since none of the three pages pass type="markdown", BaseTextInput falls back to its default (non-markdown) implementation, so no live formatting is shown even though the exact same shared component supports it. This markdown mode is already proven out elsewhere in the app — Rule/TextBase.tsx enables it for merchant/category rule descriptions by passing type, autoGrowHeight, maxAutoGrowHeight, and shouldSubmitForm together:

    <InputWrapper
    hint={hint}
    InputComponent={TextInput}
    inputID={fieldID}
    name={fieldID}
    defaultValue={typeof currentValue === 'string' ? currentValue : undefined}
    label={label}
    accessibilityLabel={title}
    role={CONST.ROLE.PRESENTATION}
    ref={inputCallbackRef}
    type={isMarkdownEnabled ? 'markdown' : undefined}
    autoGrowHeight={isMarkdownEnabled}
    maxAutoGrowHeight={isMarkdownEnabled ? variables.textInputAutoGrowMaxHeight : undefined}
    shouldSubmitForm={isMarkdownEnabled}
    />

    What changes do you think we should make in order to solve the problem?

    Rather than introducing a new MarkdownTextInput InputComponent (which would require re-deriving all the Form/InputWrapper plumbing that BaseTextInput already handles for markdown), reuse the existing type="markdown" support on the shared TextInput in all three places, mirroring the pattern already used in Rule/TextBase.tsx.

    For EditPromptPage.tsx:

    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={translate('editAgentPage.instructions')}
    accessibilityLabel={translate('editAgentPage.instructions')}
    role={CONST.ROLE.PRESENTATION}
    defaultValue={Str.htmlDecode(agentPrompt?.prompt ?? '')}
    multiline
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
    />

    Add type="markdown" to the InputWrapper:

    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={translate('editAgentPage.instructions')}
    accessibilityLabel={translate('editAgentPage.instructions')}
    role={CONST.ROLE.PRESENTATION}
    type="markdown"
    defaultValue={Str.htmlDecode(agentPrompt?.prompt ?? '')}

    For AddAgentPage.tsx, the same change to the prompt InputWrapper (leave the FIRST_NAME input untouched):

    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={translate('addAgentPage.instructions')}
    accessibilityLabel={translate('addAgentPage.instructions')}
    role={CONST.ROLE.PRESENTATION}
    defaultValue={defaultPrompt}
    multiline
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
    />

    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={translate('addAgentPage.instructions')}
    accessibilityLabel={translate('addAgentPage.instructions')}
    role={CONST.ROLE.PRESENTATION}
    type="markdown"
    defaultValue={defaultPrompt}

    For AddAgentRuleWriteTab.tsx:

    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={describeRuleLabel}
    accessibilityLabel={describeRuleLabel}
    role={CONST.ROLE.PRESENTATION}
    onKeyPress={submitFormOnModEnter}
    multiline
    shouldSaveDraft
    shouldLabelStayOnSingleLine
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
    />

    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={describeRuleLabel}
    accessibilityLabel={describeRuleLabel}
    role={CONST.ROLE.PRESENTATION}
    type="markdown"
    onKeyPress={submitFormOnModEnter}

    Since all three inputs already use multiline with an explicit flex1 container (they fill a fixed-height region rather than auto-growing with content), there's no need to add autoGrowHeight/maxAutoGrowHeight like TextBase.tsx does — those two props only matter for auto-growing inputs. shouldSubmitForm also isn't needed here since none of these three inputs submit the form on Enter (the Ctrl/Cmd+Enter submission in EditPromptPage.tsx and AddAgentRuleWriteTab.tsx is handled separately via useKeyboardShortcut/onKeyPress).

    This keeps the fix minimal, reuses the existing, already-tested BaseTextInput markdown code path (no new component or bespoke wiring), and matches how Composer and Rule/TextBase.tsx already render live Markdown.

  9. MelvinBot commented on Jul 23, 2026

    @MelvinBot
    Contributor

    Issue Analysis

    Root Cause: This is a feature gap, not a regression. The shared TextInput (BaseTextInput) already supports live markdown — when it receives type="markdown" it swaps its inner input to RNMarkdownTextInput, which is backed by @expensify/react-native-live-markdown (the same library the chat Composer uses). See BaseTextInput/implementation/index.tsx:103 (const isMarkdownEnabled = type === 'markdown';). The three agent-instruction inputs pass InputComponent={TextInput} but never pass type="markdown", so they fall back to the plain (default) implementation and show no live formatting.

    Recommended Fix: Add type="markdown" to the prompt InputWrapper in all three pages — no new component needed. This mirrors the established pattern already used for private notes (PrivateNotesEditPage.tsx:192):

    Investigation details

    Why not swap in MarkdownTextInput directly (as the issue body suggests)? Doing so would require re-deriving all the FormProvider/InputWrapper plumbing that BaseTextInput already handles. The type="markdown" path is the app's established, already-tested way to get markdown form fields (private notes, task/room descriptions, Rule/TextBase.tsx), so it's the minimal change.

    Why no extra props? Rule/TextBase.tsx also passes autoGrowHeight/maxAutoGrowHeight, but those only matter for auto-growing inputs. All three agent inputs already use multiline inside a fixed flex1 container, so those aren't needed. shouldSubmitForm also isn't required — Ctrl/Cmd+Enter submission in EditPromptPage and AddAgentRuleWriteTab is handled separately.

    Type support: InputType = 'markdown' | 'mask' | 'default' in BaseTextInput/types.ts:14.

    On the existing proposals: abbasifaizan70 and lorretheboy both correctly identify the root cause and the type="markdown" fix — their analysis matches mine. The godzillaxbox19982014-lgtm proposal is off-base (it references unrelated avatar-upload code in Agent.ts and does not touch any of the three inputs).


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/96902#issuecomment-5062345311) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.

  10. wildan-m commented on Jul 23, 2026

    @wildan-m
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-07-23 19:20:03 UTC.

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    The three inputs used to author agent instructions are plain multiline TextInput components, so the admin/user gets no live Markdown formatting (bold, headings, lists, etc.) while typing — even though the same text is later parsed and rendered as Markdown in the agent's Account > Profile > AI Prompt view and in the messages the agent posts. The affected inputs are:

    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={translate('editAgentPage.instructions')}
    accessibilityLabel={translate('editAgentPage.instructions')}
    role={CONST.ROLE.PRESENTATION}
    defaultValue={Str.htmlDecode(agentPrompt?.prompt ?? '')}
    multiline
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
    />

    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={translate('addAgentPage.instructions')}
    accessibilityLabel={translate('addAgentPage.instructions')}
    role={CONST.ROLE.PRESENTATION}
    defaultValue={defaultPrompt}
    multiline
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
    />

    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={describeRuleLabel}
    accessibilityLabel={describeRuleLabel}
    role={CONST.ROLE.PRESENTATION}
    onKeyPress={submitFormOnModEnter}
    multiline
    shouldSaveDraft
    shouldLabelStayOnSingleLine
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
    />

    What is the root cause of that problem?

    Each of these inputs renders TextInput (@components/TextInput) as its InputComponent without opting into Markdown mode. TextInput/BaseTextInput already supports live Markdown: passing type="markdown" makes it use RNMarkdownTextInput (which wraps @expensify/react-native-live-markdown's MarkdownTextInput) instead of the plain RN TextInput, and applies the shared useMarkdownStyle styling.

    const InputComponent = InputComponentMap.get(type) ?? RNTextInput;
    const isMarkdownEnabled = type === 'markdown';
    const isAutoGrowHeightMarkdown = isMarkdownEnabled && autoGrowHeight;
    const helpMessageId = useId();
    const theme = useTheme();
    const styles = useThemeStyles();
    const markdownStyle = useMarkdownStyle(false, excludedMarkdownStyles);

    ['markdown', RNMarkdownTextInput as InputComponentType],

    Because none of the three agent-instruction inputs pass type="markdown", they fall back to the plain text input and render no live formatting.

    What changes do you think we should make in order to solve the problem?

    For each of the three InputWrappers listed above (in EditPromptPage.tsx, AddAgentPage.tsx, and AddAgentRuleWriteTab.tsx), enable Markdown mode by adding type="markdown". This is the exact pattern the codebase already uses for the workspace rule description inputs, where TextBase toggles Markdown via type={isMarkdownEnabled ? 'markdown' : undefined} together with autoGrowHeight:

    <InputWrapper
    hint={hint}
    InputComponent={TextInput}
    inputID={fieldID}
    name={fieldID}
    defaultValue={typeof currentValue === 'string' ? currentValue : undefined}
    label={label}
    accessibilityLabel={title}
    role={CONST.ROLE.PRESENTATION}
    ref={inputCallbackRef}
    type={isMarkdownEnabled ? 'markdown' : undefined}
    autoGrowHeight={isMarkdownEnabled}
    maxAutoGrowHeight={isMarkdownEnabled ? variables.textInputAutoGrowMaxHeight : undefined}
    shouldSubmitForm={isMarkdownEnabled}

    Concretely, on each of the three agent-instruction InputWrapper blocks, add type="markdown" and switch the sizing to the established Markdown-input layout: autoGrowHeight with maxAutoGrowHeight={variables.textInputAutoGrowMaxHeight} (the same combination used by every other Markdown authoring input, e.g. the money-request and room-description editors). The current flex1 full-height container/input styles should be dropped in favour of autoGrowHeight, because BaseTextInput itself omits flex1 on the inner input once Markdown is enabled, and no Markdown input in the app uses flex1 full-height sizing:

    !isMarkdownEnabled && styles.flex1,

    No new component is needed — this reuses the existing TextInput Markdown support, so the authoring inputs render the same live formatting as the chat composer.

    Compare branch: https://github.com/Expensify/App/compare/main...wildan-m:App:wildan/96902-agent-instructions-markdown?expand=1

    What alternative solutions did you explore? (Optional)

    Directly importing MarkdownTextInput from @expensify/react-native-live-markdown and passing it as the InputComponent. This was rejected because it bypasses BaseTextInput's existing Markdown wiring (RNMarkdownTextInput + useMarkdownStyle + HTML-paste handling), duplicating styling logic and diverging from the established type="markdown" convention already used elsewhere in the app.

  11. dilshodmackbook-sketch commented on Jul 23, 2026

    @dilshodmackbook-sketch
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-07-23 19:17:53 UTC.

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    Agent instructions (the "prompt") are parsed and rendered as Markdown everywhere they're displayed, but the three inputs used to author those instructions render as plain text with no live formatting, so the user gets no visual feedback while writing:

    • src/pages/settings/Agents/Fields/EditPromptPage.tsx (Edit agent instructions)
    • src/pages/settings/Agents/AddAgentPage.tsx (Create agent)
    • src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx (Workspace > Rules > Add agent rule > Write your own)

    What is the root cause of that problem?

    Not a defect — the three instruction inputs simply never opt into the markdown input variant, even though the App's own TextInput already supports it. All three render the plain variant with InputComponent={TextInput}, multiline, and no type:

    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={translate('editAgentPage.instructions')}
    accessibilityLabel={translate('editAgentPage.instructions')}
    role={CONST.ROLE.PRESENTATION}
    defaultValue={Str.htmlDecode(agentPrompt?.prompt ?? '')}
    multiline
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
    />

    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={translate('addAgentPage.instructions')}
    accessibilityLabel={translate('addAgentPage.instructions')}
    role={CONST.ROLE.PRESENTATION}
    defaultValue={defaultPrompt}
    multiline
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
    />

    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={describeRuleLabel}
    accessibilityLabel={describeRuleLabel}
    role={CONST.ROLE.PRESENTATION}
    onKeyPress={submitFormOnModEnter}
    multiline
    shouldSaveDraft
    shouldLabelStayOnSingleLine
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
    />

    BaseTextInput selects its underlying input from the type prop:

    const InputComponent = InputComponentMap.get(type) ?? RNTextInput;
    const isMarkdownEnabled = type === 'markdown';
    const isAutoGrowHeightMarkdown = isMarkdownEnabled && autoGrowHeight;
    const helpMessageId = useId();
    const theme = useTheme();
    const styles = useThemeStyles();
    const markdownStyle = useMarkdownStyle(false, excludedMarkdownStyles);

    const InputComponent = InputComponentMap.get(type) ?? RNTextInput;
    const isMarkdownEnabled = type === 'markdown';
    const isAutoGrowHeightMarkdown = isMarkdownEnabled && autoGrowHeight;
    ...
    const markdownStyle = useMarkdownStyle(false, excludedMarkdownStyles);

    and 'markdown' maps to RNMarkdownTextInput (which wraps MarkdownTextInput from @expensify/react-native-live-markdown):

    import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
    import RNMaskedTextInput from '@components/RNMaskedTextInput';
    import RNTextInput from '@components/RNTextInput';
    import type {BaseTextInputProps, InputType} from './types';
    type InputComponentType = React.ComponentType<BaseTextInputProps>;
    const InputComponentMap = new Map<InputType, InputComponentType>([
    ['default', RNTextInput as InputComponentType],
    ['mask', RNMaskedTextInput as InputComponentType],
    ['markdown', RNMarkdownTextInput as InputComponentType],
    ]);

    Since type defaults to 'default', these three inputs get RNTextInput and no live formatting.

    One note for the implementation: the issue's phrasing ("swap the plain TextInput InputComponent for MarkdownTextInput") shouldn't be taken literally — replacing InputComponent would drop the label, container styling, error display, and form wiring that TextInput provides. The established pattern is to keep InputComponent={TextInput} and add type="markdown", exactly as Rule/TextBase.tsx and TaskDescriptionPage/RoomDescriptionPage already do.

    What changes do you think we should make in order to solve the problem?

    Opt each of the three inputs into the markdown variant by adding type="markdown" to the prompt InputWrapper (leave the FIRST_NAME input in AddAgentPage untouched). For EditPromptPage.tsx:

    <InputWrapper
        InputComponent={TextInput}
        inputID={INPUT_IDS.PROMPT}
        label={translate('editAgentPage.instructions')}
        accessibilityLabel={translate('editAgentPage.instructions')}
        role={CONST.ROLE.PRESENTATION}
        type="markdown"
        defaultValue={Str.htmlDecode(agentPrompt?.prompt ?? '')}
        multiline
        containerStyles={[styles.flex1]}
        touchableInputWrapperStyle={[styles.flex1]}
        textInputContainerStyles={[styles.flex1]}
        inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
    />

    with the identical one-line addition on the prompt InputWrapper in AddAgentPage.tsx (~L162-L174) and AddAgentRuleWriteTab.tsx (~L86-L100).

    Three things the implementer needs to handle beyond the prop:

    1. The Ctrl+Enter submit handler in EditPromptPage reads the raw DOM node:

      useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, (e) => {
      if (Platform.OS !== 'web') {
      return;
      }
      const textarea = e?.target as HTMLTextAreaElement;
      if (!textarea) {
      return;
      }
      const errors = validate({[INPUT_IDS.PROMPT]: textarea.value.trim()});
      if (Object.keys(errors).length > 0) {
      return;
      }
      handleSubmit({[INPUT_IDS.PROMPT]: textarea.value.trim()});
      });

      const textarea = e?.target as HTMLTextAreaElement;
      ...
      handleSubmit({[INPUT_IDS.PROMPT]: textarea.value.trim()});

      MarkdownTextInput doesn't render a <textarea> on web, so textarea.value will be undefined and the shortcut silently breaks. This should read the current form value (via the form draft / useOnyx on EDIT_AGENT_PROMPT_FORM) or an input ref's value instead of the event target. AddAgentRuleWriteTab's submitFormOnModEnter submits through formRef so it's fine, but should be re-verified.

    2. The flex-fill layout — BaseTextInput branches on !isMarkdownEnabled && styles.flex1 for the inner input wrapper, so the full-height flex-fill layout these inputs rely on behaves differently under markdown, and the markdown branch only constrains height via getMarkdownMaxHeight when autoGrowHeight is set:

      <View
      style={[
      !isMarkdownEnabled && styles.flex1,

      The proven markdown-in-a-form pattern (Rule/TextBase.tsx) pairs type="markdown" with autoGrowHeight + maxAutoGrowHeight. The agent pages should be checked in both portrait and landscape (they already branch on useIsInLandscapeMode) so the input still fills the screen and doesn't collapse to a single line.

    3. Which styles to render — excludedMarkdownStyles on BaseTextInput lets specific markdown styles be suppressed. Since instructions are prose, mention/emoji styling may not be desirable; worth confirming with the assignees whether the composer's full style set is intended or a subset should be excluded.

    What specific scenarios should we cover in automated tests to prevent reintroducing this issue in the future?

    1. Render each of the three pages and assert the prompt input mounts the markdown variant (RNMarkdownTextInput) rather than the default RNTextInput.
    2. Seed EditPromptPage with a stored prompt containing markdown and assert the defaultValue loads with formatting already applied.
    3. Assert Ctrl/Cmd+Enter still submits the form on web from both EditPromptPage and the Add agent rule write tab after the switch to MarkdownTextInput.
    4. Manually repeat the issue's steps and confirm formatting renders live in all three inputs, matching the chat composer.

    What alternative solutions did you explore? (Optional)

    Rendering a separate live-preview pane beside the plain input rather than formatting in place, which sidesteps the layout and Ctrl+Enter concerns entirely — but it diverges from how every other markdown-authored field in the app behaves and doubles the vertical space on a screen that's already full-height, so I'd keep the in-place type="markdown" approach.

  12. KJ21-ENG commented on Jul 23, 2026

    @KJ21-ENG
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-07-23 19:45:42 UTC.

    Note: This isn't a duplicate of the flagged proposal — that
    proposal changes only the three issue-listed TextInput call sites and rewrites
    EditPromptPage's Ctrl+Enter path when markdown mode is enabled; this proposal
    covers all five authoring paths and preserves the existing capture-phase shortcut.

    Update after C+ review: Added the mentionReport WYSIWYG guard, covered
    preventDefault() in every direct Cmd/Ctrl+Enter handler, and documented the
    issue body's - list item limitation and the separate dependency work it would require.

    Proposal

    What is the root cause of that problem?

    The agent-instruction and agent-rule editors use the shared TextInput without selecting its markdown mode. BaseTextInput defaults type to default, so these fields instantiate RNTextInput; the existing markdown type instead selects the app's RNMarkdownTextInput wrapper and supplies the shared ExpensiMark parser and markdown styles. The core issue is therefore an authoring-control configuration omission, not a persistence problem.

    function BaseTextInput({
    label = '',
    /**
    * To be able to function as either controlled or uncontrolled component we should not
    * assign a default prop value for `value` or `defaultValue` props
    */
    value = undefined,
    defaultValue = undefined,
    placeholder = '',
    errorText = '',
    icon = null,
    iconLeft = null,
    includeIconPadding = true,
    textInputContainerStyles,
    shouldApplyPaddingToContainer = true,
    touchableInputWrapperStyle,
    containerStyles,
    inputStyle,
    forceActiveLabel = false,
    disableKeyboard = false,
    autoGrow = false,
    autoGrowHeight = false,
    maxAutoGrowHeight,
    hideFocusedState = false,
    maxLength = undefined,
    hint = '',
    shouldRenderHintAsHTML = false,
    onInputChange = () => {},
    multiline = false,
    shouldLabelStayOnSingleLine = false,
    shouldInterceptSwipe = false,
    autoCorrect = true,
    prefixCharacter = '',
    suffixCharacter = '',
    inputID,
    type = 'default',
    excludedMarkdownStyles = [],
    shouldShowClearButton = false,
    shouldHideClearButton = true,
    shouldUseDisabledStyles = true,
    prefixContainerStyle = [],
    prefixStyle = [],
    suffixContainerStyle = [],
    suffixStyle = [],
    contentWidth,
    loadingSpinnerStyle,
    uncontrolled = false,
    placeholderTextColor,
    onClearInput,
    iconContainerStyle,
    clearButtonStyle,
    clearButtonIconSize,
    shouldUseDefaultLineHeightForPrefix = true,
    ref,
    sentryLabel,
    rightHandSideComponent,
    role,
    ...inputProps
    }: BaseTextInputProps) {
    const InputComponent = InputComponentMap.get(type) ?? RNTextInput;
    const isMarkdownEnabled = type === 'markdown';
    const isAutoGrowHeightMarkdown = isMarkdownEnabled && autoGrowHeight;
    const helpMessageId = useId();
    const theme = useTheme();
    const styles = useThemeStyles();
    const markdownStyle = useMarkdownStyle(false, excludedMarkdownStyles);

    import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
    import RNMaskedTextInput from '@components/RNMaskedTextInput';
    import RNTextInput from '@components/RNTextInput';
    import type {BaseTextInputProps, InputType} from './types';
    type InputComponentType = React.ComponentType<BaseTextInputProps>;
    const InputComponentMap = new Map<InputType, InputComponentType>([
    ['default', RNTextInput as InputComponentType],
    ['mask', RNMaskedTextInput as InputComponentType],
    ['markdown', RNMarkdownTextInput as InputComponentType],
    ]);

    // If `parser` prop was passed down we use it directly, otherwise we default to parsing with ExpensiMark
    const parserWorklet = useCallback(
    (text: string) => {
    'worklet';
    if (parser) {
    return parser(text);
    }
    return parseExpensiMarkWithShortMentions(text, mentionsSharedVal.get(), currentUserMentions);
    },
    [currentUserMentions, mentionsSharedVal, parser],
    );
    useEffect(() => {
    scheduleOnLiveMarkdownRuntime(() => {
    'worklet';
    mentionsSharedVal.set(availableLoginsList);
    });
    }, [availableLoginsList, mentionsSharedVal]);
    return (
    // We need this wrapper view in order to apply Fullstory masking since the Markdown input
    // only pass down specific props on Web. The 'display: "contents"' style is also applied
    // so the View won't interfere with the layout tree.
    <View
    style={styles.dContents}
    fsClass={forwardedFSClass}
    >
    <AnimatedMarkdownTextInput
    allowFontScaling={false}
    textBreakStrategy="simple"
    keyboardAppearance={theme.colorScheme}
    parser={parserWorklet}
    ref={inputRef}
    formatSelection={toggleSelectionFormat}
    // eslint-disable-next-line react/forbid-component-props
    fsClass={forwardedFSClass}
    {...props}
    /**
    * If maxLength is not set, we should set it to CONST.MAX_COMMENT_LENGTH + 1, to avoid parsing markdown for large text
    */
    maxLength={maxLength ?? CONST.MAX_COMMENT_LENGTH + 1}
    onBlur={handleBlur}

    The three inputs named in the issue all omit type="markdown":

    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={translate('editAgentPage.instructions')}
    accessibilityLabel={translate('editAgentPage.instructions')}
    role={CONST.ROLE.PRESENTATION}
    defaultValue={Str.htmlDecode(agentPrompt?.prompt ?? '')}
    multiline
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}

    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={translate('addAgentPage.instructions')}
    accessibilityLabel={translate('addAgentPage.instructions')}
    role={CONST.ROLE.PRESENTATION}
    defaultValue={defaultPrompt}
    multiline
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}

    <View style={inputWrapperStyles}>
    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={describeRuleLabel}
    accessibilityLabel={describeRuleLabel}
    role={CONST.ROLE.PRESENTATION}
    onKeyPress={submitFormOnModEnter}
    multiline
    shouldSaveDraft
    shouldLabelStayOnSingleLine
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
    />

    The same gap is present in the other two editors for the same data: an agent editing its own AI prompt and an admin editing an existing agent rule. Enabling markdown only on the issue-listed entry screens would therefore make the authoring experience route-dependent.

    <OfflineWithFeedback pendingAction={agentPrompt?.pendingAction}>
    <TextInput
    ref={inputRef}
    label={translate('profilePage.aiPromptSection.prompt')}
    accessibilityLabel={translate('profilePage.aiPromptSection.prompt')}
    value={draftPrompt}
    onChangeText={handleChangeText}
    onKeyPress={handleKeyPress}
    multiline
    autoGrowHeight
    maxAutoGrowHeight={variables.lineHeightNormal * MAX_VISIBLE_PROMPT_LINES}
    errorText={errorText}
    containerStyles={[styles.mb3]}
    testID="ai-prompt-input"
    onFocus={handleInputFocus}
    />

    const handleSave = () => {
    if (isSaving && isUserInitiatedSave) {
    return;
    }
    const trimmed = draftPrompt.trim();
    if (!trimmed) {
    setShowEmptyError(true);
    inputRef.current?.focus();
    return;
    }
    if (containsHtmlTag(trimmed)) {
    setShowHtmlError(true);
    inputRef.current?.focus();
    return;
    }
    dismissInput();
    updateAgentPrompt(accountID, trimmed, agentPrompt?.prompt ?? '');
    // Offline: treat the optimistic write as the final state for UX purposes. The request will be
    // replayed on reconnect without save-button feedback since the user already saw "Saved".
    if (isOffline) {
    triggerSavedConfirmation();
    } else {
    setIsUserInitiatedSave(true);
    }
    };
    const handleChangeText = (text: string) => {
    setDraftPrompt(text);
    if (showEmptyError && text.trim()) {
    setShowEmptyError(false);
    }
    if (showHtmlError && !containsHtmlTag(text)) {
    setShowHtmlError(false);
    }
    };
    const handleKeyPress = (e: TextInputKeyPressEvent) => {
    const event = e as unknown as KeyboardEvent;
    if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
    handleSave();
    }
    };

    const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => {
    if (!('key' in event)) {
    return;
    }
    if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
    formRef.current?.submit();
    }
    };
    const validate = (values: FormOnyxValues<EditAgentRuleFormID>): FormInputErrors<EditAgentRuleFormID> => {
    const errors: FormInputErrors<EditAgentRuleFormID> = {};
    if (!values[INPUT_IDS.PROMPT].trim()) {
    errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired');
    }
    return errors;
    };
    const saveRule = (values: FormOnyxValues<EditAgentRuleFormID>): void => {
    const newPrompt = values[INPUT_IDS.PROMPT];
    const previousPrompt = agentRule?.prompt ?? '';
    if (newPrompt !== previousPrompt) {
    updatePolicyAgentRule(policyID, ruleID, newPrompt, previousPrompt);
    }
    Navigation.goBack();

    <View style={styles.flex1}>
    <View style={inputWrapperStyles}>
    <InputWrapper
    InputComponent={TextInput}
    inputID={INPUT_IDS.PROMPT}
    label={describeRuleLabel}
    accessibilityLabel={describeRuleLabel}
    role={CONST.ROLE.PRESENTATION}
    onKeyPress={submitFormOnModEnter}
    defaultValue={agentRule.prompt}
    multiline
    shouldLabelStayOnSingleLine
    containerStyles={[styles.flex1]}
    touchableInputWrapperStyle={[styles.flex1]}
    textInputContainerStyles={[styles.flex1]}
    inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
    />

    Two compatibility details must be handled when these controls switch to markdown:

    1. The live parser recognizes #room as a mentionReport range and applies mention styling. These settings surfaces do not provide a report context that can resolve the same room mention when the saved prompt is displayed, so it falls back to plain text. Without excluding that style, the input would visually promise formatting that the saved display cannot reproduce.

    function useMarkdownStyle(hasMessageOnlyEmojis: boolean, excludeStyles: Array<keyof MarkdownStyle> = defaultEmptyArray): MarkdownStyle {
    const theme = useTheme();
    const emojiFontSize = hasMessageOnlyEmojis ? variables.fontSizeOnlyEmojis : variables.fontSizeEmojisWithinText;
    // this map is used to reset the styles that are not needed - passing undefined value can break the native side
    const nonStylingDefaultValues: Record<string, string | number> = useMemo(
    () => ({
    color: theme.text,
    backgroundColor: 'transparent',
    marginLeft: 0,
    paddingLeft: 0,
    borderColor: 'transparent',
    borderWidth: 0,
    }),
    [theme],
    );
    const markdownStyle = useMemo(() => {
    const styling = {
    syntax: {
    color: theme.syntax,
    },
    link: {
    color: theme.link,
    },
    h1: {
    fontSize: variables.fontSizeLarge,
    },
    emoji: {
    ...FontUtils.fontFamily.platform.CUSTOM_EMOJI_FONT,
    fontSize: emojiFontSize,
    lineHeight: variables.lineHeightXLarge,
    },
    blockquote: {
    borderColor: theme.border,
    borderWidth: 4,
    marginLeft: 0,
    paddingLeft: 6,
    /**
    * since blockquote has `inline-block` display -> padding-right is needed to prevent cursor overlapping
    * with last character of the text node.
    * As long as paddingRight > cursor.width, cursor will be displayed correctly.
    */
    paddingRight: 1,
    },
    code: {
    fontFamily: FontUtils.fontFamily.platform.MONOSPACE.fontFamily,
    fontSize: 13, // TODO: should be 15 if inside h1, see StyleUtils.getCodeFontSize
    color: theme.text,
    paddingHorizontal: 5,
    borderColor: theme.border,
    backgroundColor: theme.textBackground,
    h1NestedFontSize: 15,
    },
    pre: {
    ...FontUtils.fontFamily.platform.MONOSPACE,
    fontSize: 13,
    color: theme.text,
    paddingVertical: 8,
    paddingHorizontal: 12,
    borderColor: theme.border,
    backgroundColor: theme.textBackground,
    },
    mentionHere: {
    color: theme.ourMentionText,
    backgroundColor: theme.ourMentionBG,
    borderRadius: variables.componentBorderRadiusSmall,
    },
    mentionUser: {
    color: theme.mentionText,
    backgroundColor: theme.mentionBG,
    borderRadius: variables.componentBorderRadiusSmall,
    },
    mentionReport: {
    color: theme.mentionText,
    backgroundColor: theme.mentionBG,
    },
    inlineImage: {
    minWidth: variables.inlineImagePreviewMinSize,
    minHeight: variables.inlineImagePreviewMinSize,
    maxWidth: variables.inlineImagePreviewMaxSize,
    maxHeight: variables.inlineImagePreviewMaxSize,
    borderRadius: variables.componentBorderRadius,
    marginTop: 4,
    },
    loadingIndicator: {
    primaryColor: theme.spinner,
    secondaryColor: `${theme.spinner}33`,
    },
    loadingIndicatorContainer: {},
    };
    if (excludeStyles.length) {
    for (const key of excludeStyles) {
    const style: Record<string, unknown> = styling[key];
    if (style) {
    for (const styleKey of Object.keys(style)) {
    style[styleKey] = nonStylingDefaultValues[styleKey] ?? style[styleKey];
    }
    }
    }
    }
    return styling;
    }, [theme, emojiFontSize, excludeStyles, nonStylingDefaultValues]);
    return markdownStyle;
    }

    const html = useMemo(() => {
    if (!title || !shouldParseTitle) {
    return '';
    }
    return Parser.replace(title, {shouldEscapeText, disabledRules: excludedMarkdownRules});
    }, [title, shouldParseTitle, shouldEscapeText, excludedMarkdownRules]);

    function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRendererProps}: MentionReportRendererProps) {
    const styles = useThemeStyles();
    const StyleUtils = useStyleUtils();
    const htmlAttributeReportID = tnode.attributes.reportid;
    const {currentReportID: currentReportIDContext, exactlyMatch, policyID} = useContext(MentionReportContext);
    const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
    const {currentReportID} = useCurrentReportIDState();
    // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
    const currentReportIDValue = currentReportIDContext || currentReportID;
    const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentReportIDValue}`);
    // When we invite someone to a room they don't have the policy object, but we still want them to be able to see and click on report mentions, so we only check if the policyID in the report is from a workspace
    const isGroupPolicyReport = useMemo(
    () => (!!currentReport && !isEmptyObject(currentReport) && !!currentReport.policyID && currentReport.policyID !== CONST.POLICY.ID_FAKE) || !!policyID,
    [currentReport, policyID],
    );
    const mentionDetails = getReportMentionDetails(htmlAttributeReportID, currentReport, reports, tnode, policyID);
    if (!mentionDetails) {
    return null;
    }
    const {reportID, mentionDisplayText} = mentionDetails;
    let navigationRoute: Route | undefined = reportID ? ROUTES.REPORT_WITH_ID.getRoute(reportID) : undefined;
    const backTo = Navigation.getActiveRoute();
    if (isSearchTopmostFullScreenRoute()) {
    navigationRoute = reportID ? ROUTES.SEARCH_REPORT.getRoute({reportID, backTo}) : undefined;
    }
    const isCurrentRoomMention = reportID === currentReportIDValue;
    const flattenStyle = StyleSheet.flatten(style as TextStyle);
    const {color, ...styleWithoutColor} = flattenStyle;
    return (
    <Text
    {...defaultRendererProps}
    style={
    isGroupPolicyReport && (!exactlyMatch || navigationRoute)
    ? [styles.link, styleWithoutColor, StyleUtils.getMentionStyle(isCurrentRoomMention), {color: StyleUtils.getMentionTextColor(isCurrentRoomMention)}]
    : [flattenStyle]
    }
    suppressHighlighting
    onPress={
    navigationRoute && isGroupPolicyReport
    ? (event) => {
    event.preventDefault();
    Navigation.navigate(navigationRoute);
    }
    : undefined
    }
    role={isGroupPolicyReport ? CONST.ROLE.LINK : undefined}
    accessibilityLabel={isGroupPolicyReport ? `/${navigationRoute}` : undefined}
    >
    #{mentionDisplayText}
    </Text>

    1. On web, MarkdownTextInput invokes the supplied onKeyPress and then inserts \n for Enter unless that event was prevented. The add-rule, edit-rule, and controlled Profile → AI Prompt editors each save from a direct onKeyPress Cmd/Ctrl+Enter handler without calling preventDefault(). The two rule screens can briefly insert an unsaved newline while navigating; the profile editor remains mounted, so it can leave the controlled draft different from the value that was just saved.

    const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => {
    if (!('key' in event)) {
    return;
    }
    if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
    formRef.current?.submit();
    }

    const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => {
    if (!('key' in event)) {
    return;
    }
    if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
    formRef.current?.submit();
    }

    const handleKeyPress = (e: TextInputKeyPressEvent) => {
    const event = e as unknown as KeyboardEvent;
    if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
    handleSave();
    }

    https://github.com/Expensify/react-native-live-markdown/blob/e6995801c611503a2836435d11a5fb90a6e5ed3e/src/MarkdownTextInput.web.tsx#L538-L559

    This is not regression-shaped: history shows each affected field was created with the default input during its agent or agent-rule feature introduction, rather than markdown support being removed later.

    What changes do you think we should make in order to solve the problem?

    Add both type="markdown" and excludedMarkdownStyles={['mentionReport']} to the prompt TextInput in all five authoring paths. This reuses the existing TextInput integration instead of replacing the form component directly, preserving the current labels, validation/error UI, draft handling, sizing, and controlled/uncontrolled value contracts while switching the underlying implementation to RNMarkdownTextInput.

    excludedMarkdownStyles only neutralizes the visual style for mentionReport; it does not change the raw prompt text that is validated or saved.

    InputWrapper will keep registering the shared TextInput exactly as it does now and forward both markdown props to it.

    function InputWrapper<TInput extends ValidInputs, TValue extends ValueTypeKey>({ref, ...props}: InputWrapperProps<TInput, TValue>) {
    const {InputComponent, inputID, valueType = 'string', shouldSubmitForm: propShouldSubmitForm, ...rest} = props as InputComponentBaseProps;
    const {registerInput} = useContext(FormContext);
    const {shouldSetTouchedOnBlurOnly, submitBehavior, shouldSubmitForm} = computeComponentSpecificRegistrationParams(props as InputComponentBaseProps);
    const {key, ...registerInputProps} = registerInput(inputID, shouldSubmitForm, {ref, valueType, ...rest, shouldSetTouchedOnBlurOnly, submitBehavior});
    return (
    <InputComponent
    key={key}
    // TODO: Sometimes we return too many props with register input, so we need to consider if it's better to make the returned type more general and disregard the issue, or we would like to omit the unused props somehow.
    {...registerInputProps}
    />
    );

    In all three controls whose Cmd/Ctrl+Enter behavior is implemented through a direct onKeyPress handler:

    • AddAgentRuleWriteTab
    • EditAgentRulePage
    • AgentAIPromptSection

    call event.preventDefault() inside the modifier+Enter branch before submitting or saving. Ordinary Enter remains unchanged and continues inserting a newline.

    Keep EditPromptPage's existing global useKeyboardShortcut path unchanged. It is different from the three direct handlers: useKeyboardShortcut prevents the shortcut's default action by default, react-native-key-command observes it during capture, and the live-markdown element maintains its current raw .value. The current handler can therefore continue validating and submitting the latest prompt without a form-ref rewrite.

    export default function useKeyboardShortcut(shortcut: Shortcut, callback: (e?: GestureResponderEvent | KeyboardEvent) => void, config: KeyboardShortcutConfig = {}) {
    const {
    captureOnInputs = true,
    shouldBubble = false,
    priority = 0,
    shouldPreventDefault = true,
    // The "excludedNodes" array needs to be stable to prevent the "useEffect" hook from being recreated unnecessarily.
    // Hence the use of CONST.EMPTY_ARRAY.
    excludedNodes = CONST.EMPTY_ARRAY,
    isActive = true,
    // This flag is used to prevent auto submit form when press enter key on selection modal.
    shouldStopPropagation = false,
    } = config;

    https://github.com/Expensify/react-native-key-command/blob/1.0.14/src/KeyCommand/index.js#L198-L202

    https://github.com/Expensify/react-native-live-markdown/blob/e6995801c611503a2836435d11a5fb90a6e5ed3e/src/MarkdownTextInput.web.tsx#L153-L165

    https://github.com/Expensify/react-native-live-markdown/blob/e6995801c611503a2836435d11a5fb90a6e5ed3e/src/MarkdownTextInput.web.tsx#L175-L195

    Keep the agent-name field and the agent-rule suggestions search field as normal text inputs because those values are not markdown instructions. Keep the existing full-height multiline/flex1 layouts on the form pages and the existing controlled auto-grow layout in AgentAIPromptSection.

    Test coverage:

    • Update the existing Add Agent and Edit Prompt tests to assert that the prompt input receives type="markdown" and excludedMarkdownStyles={['mentionReport']}, while the agent-name input stays plain.
    • Add focused render tests for the add-rule, edit-rule, and profile prompt editors, including the Rules Revamp layout branch.
    • Add web regression coverage for all three direct Cmd/Ctrl+Enter handlers: each should save exactly once, prevent the post-submit newline, and leave ordinary Enter behavior unchanged.
    • Keep a separate Edit Prompt regression confirming its existing global shortcut submits the current raw value exactly once.
    • Manually verify bold, headings, blockquotes, links, validation, draft restoration, Save/Create, layout, and Cmd/Ctrl+Enter behavior on web, iOS, and Android.

    What alternative solutions did you explore? (Optional)

    I considered passing the package's MarkdownTextInput directly to InputWrapper, but that would bypass the app's established TextInput shell and form-specific behavior. InputWrapper also specifically recognizes the shared TextInput when configuring multiline form submission. The existing type="markdown" path already provides the intended library component with ExpensiMark parsing and shared styling, so enabling it at each prompt field is smaller and consistent with other markdown-enabled forms.

    function computeComponentSpecificRegistrationParams({
    InputComponent,
    shouldSubmitForm,
    multiline,
    autoGrowHeight,
    submitBehavior,
    }: InputComponentBaseProps): ComputedComponentSpecificRegistrationParams {
    if (textInputBasedComponents.has(InputComponent)) {
    const isEffectivelyMultiline = !!multiline || !!autoGrowHeight;
    // If the user can use the hardware keyboard, they have access to an alternative way of inserting a new line
    // (like a Shift+Enter keyboard shortcut). For simplicity, we assume that when there's no touch screen, it's a
    // desktop setup with a keyboard.
    const canUseHardwareKeyboard = !canUseTouchScreen();
    // We want to avoid a situation when the user can't insert a new line. For single-line inputs, it's not a problem and we
    // force-enable form submission. For multi-line inputs, ensure that it was requested to enable form submission for this specific
    // input and that alternative ways exist to add a new line.
    const shouldReallySubmitForm = isEffectivelyMultiline ? !!shouldSubmitForm && canUseHardwareKeyboard : true;
    return {
    // There are inputs that don't have onBlur methods, to simulate the behavior of onBlur in e.g. checkbox, we had to
    // use different methods like onPress. This introduced a problem that inputs that have the onBlur method were
    // calling some methods too early or twice, so we had to add this check to prevent that side effect.
    // For now this side effect happened only in `TextInput` components.
    shouldSetTouchedOnBlurOnly: true,
    submitBehavior: isEffectivelyMultiline && shouldReallySubmitForm ? 'blurAndSubmit' : submitBehavior,
    shouldSubmitForm: shouldReallySubmitForm,
    };
    }
    return {
    shouldSetTouchedOnBlurOnly: false,
    // Forward the originally provided value
    submitBehavior,
    shouldSubmitForm: !!shouldSubmitForm,
    };

    inputID={INPUT_IDS.DESCRIPTION}
    name={INPUT_IDS.DESCRIPTION}
    label={translate('newTaskPage.descriptionOptional')}
    accessibilityLabel={translate('newTaskPage.descriptionOptional')}
    defaultValue={Parser.htmlToMarkdown(report?.description ?? '')}
    ref={(element: AnimatedTextInputRef | null) => {
    if (!element) {
    return;
    }
    if (!inputRef.current) {
    updateMultilineInputRange(inputRef.current);
    }
    inputRef.current = element;
    }}
    autoGrowHeight
    maxAutoGrowHeight={variables.textInputAutoGrowMaxHeight}
    shouldSubmitForm
    type="markdown"
    />

    I also considered making markdown the global TextInput default, but that would change unrelated fields. Scoping the type to all five known prompt editors fixes every current authoring path without affecting normal text inputs.

    The issue's manual test step also names - list item. Version 0.1.333 of react-native-live-markdown has no unordered-list or list-item range/style, so type="markdown" cannot make that syntax render as a live list; this matches the current Composer rather than the literal list expectation in the issue body.

    https://github.com/Expensify/react-native-live-markdown/blob/e6995801c611503a2836435d11a5fb90a6e5ed3e/src/commonTypes.ts#L1-L18

    If live unordered lists are required as part of this issue, they need a separate coordinated dependency change:

    1. Add unordered-list Markdown-to-HTML parsing and HTML-to-Markdown round-tripping in expensify-common.
    2. Add list/list-item ranges, nesting metadata, and rendering behavior to react-native-live-markdown for web and native while preserving raw values, selection, and cursor behavior.
    3. Bump both dependencies in App and verify the Composer and all five agent editors, including typing, editing, paste round-trips, nested lists, and saved rendering.

    That work affects every Markdown surface in App, so it should be explicitly confirmed rather than silently folded into this five-editor configuration fix.

  13. 57 remaining items

  14. melvin-bot commented on Aug 18, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  15. FitseTLT commented on Aug 18, 2026

    @FitseTLT
    Contributor

    @mallenexpensify pls consider in the payment the additional fix here which was internally confirmed to be implemented here.

  16. melvin-bot commented on Aug 18, 2026

    @melvin-bot

    Payment Summary

    Resolving PRs:

    BugZero Checklist (@mallenexpensify)

    • I have confirmed assignees, roles, and Upwork contracts look correct
    • I have paid out Upwork contracts / manual NewDot requests
  17. mallenexpensify commented on Aug 20, 2026

    @mallenexpensify
    Contributor

    @mallenexpensify pls consider in the payment the additional fix #97876 (comment) which was internally confirmed to be implemented #97876 (comment).

    @FitseTLT is there a link to another PR? IF so, I'd think that would be considered a followup and due $125 (paid as part of this issue or another, if there is one)

    @Pujan92 from the C+ doc

    If there are posts on the PR or issue that reference a regression or deploy blocker, C+ posts on the issue and confirms regression (which will reduce pay by 50%) or provides reasoning why they shouldn’t be considered. If the C+ doesn’t update the issue, the contributor manager making the payment will assume any mention of a regression or deploy blockers reduces the issue amount.

  18. FitseTLT commented on Aug 20, 2026

    @FitseTLT
    Contributor

    @mallenexpensify I included it in the pr after I got the confirmation. 👍

  19. Pujan92 commented on Aug 21, 2026

    @Pujan92
    Contributor

    @Pujan92 from the C+ doc

    If there are posts on the PR or issue that reference a regression or deploy blocker, C+ posts on the issue and confirms regression (which will reduce pay by 50%) or provides reasoning why they shouldn’t be considered. If the C+ doesn’t update the issue, the contributor manager making the payment will assume any mention of a regression or deploy blockers reduces the issue amount.

    @mallenexpensify #98325 isn't a regression of our PR but an extra page where we also need to apply the markdown change. It is fixed in PR #98380.

  20. mallenexpensify commented on Aug 21, 2026

    @mallenexpensify
    Contributor

    @MelvinBot who is due how much here?

  21. MelvinBot commented on Aug 21, 2026

    @MelvinBot
    Contributor

    Two people are due, $250 each ($500 total), both via NewDot — matching the Payment Summary:

    No reductions apply:

    So: pay FitseTLT $250 and Pujan92 $250 via NewDot, no adjustments.


    view run

  22. mallenexpensify commented on Aug 21, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor: @FitseTLT due $250 via NewDot
    Contributor+: @Pujan92 due $250 via NewDot

  23. flaviadefaria commented on Sep 2, 2026

    @flaviadefaria
    Contributor

    Approved: @FitseTLT due $250 via NewDot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions