Repository navigation
[Due for payment 2026-08-19] [$250] Add react-native-live-markdown to the Custom Agent Instructions input #96902
Description
Activity
- addedExternalAdded to denote the issue can be worked on by a contributorAdded to denote the issue can be worked on by a contributorBugSomething is broken. Auto assigns a BugZero manager.Something is broken. Auto assigns a BugZero manager.
on Jul 23, 2026 - addedHelp WantedApply this label when an issue is open to proposals by contributorsApply this label when an issue is open to proposals by contributors
on Jul 23, 2026 Triggered auto assignment to Contributor-plus team member for initial proposal review - @Pujan92 (
External)- 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 Job added to Upwork: https://www.upwork.com/jobs/~022080369860454201517
godzillaxbox19982014-lgtm commented
on Jul 23, 2026 More actionsProposal
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:273is the first concrete decision that matchesTextInput, EditPromptPage, AddAgentPage, andsrc/libs/actions/Agent.ts:352is 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:273so theTextInput, EditPromptPage, AddAgentPagerepro state is represented beforesrc/libs/actions/Agent.ts:352consumes 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?
- Add or update a regression in
tests/components/**/*.tsfor the exact issue flow: [$250] Add react-native-live-markdown to the Custom Agent Instructions input. - Cover the counter-case near
src/libs/actions/Agent.ts:273so valid existingTextInput, EditPromptPage, AddAgentPagebehavior still works. - 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, AddAgentPageconsistent at the first read/write boundary shown above.
- src/libs/actions/Agent.ts:273 (
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
TextInputcomponent toInputWrapperasInputComponent={TextInput}without opting into markdown rendering:App/src/pages/settings/Agents/Fields/EditPromptPage.tsx
Lines 95 to 107 in ea855a7
<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]} />
App/src/pages/settings/Agents/AddAgentPage.tsx
Lines 162 to 174 in ea855a7
<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]} />
App/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx
Lines 86 to 100 in ea855a7
<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 underlyingBaseTextInputalready has built-in support for live markdown: it accepts atypeprop, and whentype === 'markdown'it swaps its internal input implementation toRNMarkdownTextInput(the same@expensify/react-native-live-markdown-backed component the chatComposeruses), instead of the plain native input:App/src/components/TextInput/BaseTextInput/implementations.ts
Lines 1 to 13 in ea855a7
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",BaseTextInputfalls 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.tsxenables it for merchant/category rule descriptions by passingtype,autoGrowHeight,maxAutoGrowHeight, andshouldSubmitFormtogether:App/src/components/Rule/TextBase.tsx
Lines 99 to 113 in ea855a7
<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
MarkdownTextInputInputComponent(which would require re-deriving all the Form/InputWrapperplumbing thatBaseTextInputalready handles for markdown), reuse the existingtype="markdown"support on the sharedTextInputin all three places, mirroring the pattern already used inRule/TextBase.tsx.For
EditPromptPage.tsx:App/src/pages/settings/Agents/Fields/EditPromptPage.tsx
Lines 95 to 107 in ea855a7
<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 theInputWrapper: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 promptInputWrapper(leave theFIRST_NAMEinput untouched):App/src/pages/settings/Agents/AddAgentPage.tsx
Lines 162 to 174 in ea855a7
<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:App/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx
Lines 86 to 100 in ea855a7
<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
multilinewith an explicitflex1container (they fill a fixed-height region rather than auto-growing with content), there's no need to addautoGrowHeight/maxAutoGrowHeightlikeTextBase.tsxdoes — those two props only matter for auto-growing inputs.shouldSubmitFormalso isn't needed here since none of these three inputs submit the form on Enter (the Ctrl/Cmd+Enter submission inEditPromptPage.tsxandAddAgentRuleWriteTab.tsxis handled separately viauseKeyboardShortcut/onKeyPress).This keeps the fix minimal, reuses the existing, already-tested
BaseTextInputmarkdown code path (no new component or bespoke wiring), and matches howComposerandRule/TextBase.tsxalready render live Markdown.Issue Analysis
Root Cause: This is a feature gap, not a regression. The shared
TextInput(BaseTextInput) already supports live markdown — when it receivestype="markdown"it swaps its inner input toRNMarkdownTextInput, which is backed by@expensify/react-native-live-markdown(the same library the chatComposeruses). SeeBaseTextInput/implementation/index.tsx:103(const isMarkdownEnabled = type === 'markdown';). The three agent-instruction inputs passInputComponent={TextInput}but never passtype="markdown", so they fall back to the plain (default) implementation and show no live formatting.Recommended Fix: Add
type="markdown"to the promptInputWrapperin all three pages — no new component needed. This mirrors the established pattern already used for private notes (PrivateNotesEditPage.tsx:192):EditPromptPage.tsx:96AddAgentPage.tsx:163(the prompt input — leave the agent-name input at L152 as a plain input)AddAgentRuleWriteTab.tsx:87
Investigation details
Why not swap in
MarkdownTextInputdirectly (as the issue body suggests)? Doing so would require re-deriving all theFormProvider/InputWrapperplumbing thatBaseTextInputalready handles. Thetype="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.tsxalso passesautoGrowHeight/maxAutoGrowHeight, but those only matter for auto-growing inputs. All three agent inputs already usemultilineinside a fixedflex1container, so those aren't needed.shouldSubmitFormalso isn't required — Ctrl/Cmd+Enter submission inEditPromptPageandAddAgentRuleWriteTabis handled separately.Type support:
InputType = 'markdown' | 'mask' | 'default'inBaseTextInput/types.ts:14.On the existing proposals:
abbasifaizan70andlorretheboyboth correctly identify the root cause and thetype="markdown"fix — their analysis matches mine. Thegodzillaxbox19982014-lgtmproposal is off-base (it references unrelated avatar-upload code inAgent.tsand 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.🚨 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
TextInputcomponents, 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'sAccount > Profile > AI Promptview and in the messages the agent posts. The affected inputs are:App/src/pages/settings/Agents/Fields/EditPromptPage.tsx
Lines 95 to 107 in ea855a7
<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]} /> App/src/pages/settings/Agents/AddAgentPage.tsx
Lines 162 to 174 in ea855a7
<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]} /> App/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx
Lines 86 to 100 in ea855a7
<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 itsInputComponentwithout opting into Markdown mode.TextInput/BaseTextInputalready supports live Markdown: passingtype="markdown"makes it useRNMarkdownTextInput(which wraps@expensify/react-native-live-markdown'sMarkdownTextInput) instead of the plain RNTextInput, and applies the shareduseMarkdownStylestyling.App/src/components/TextInput/BaseTextInput/implementation/index.tsx
Lines 102 to 109 in ea855a7
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 (inEditPromptPage.tsx,AddAgentPage.tsx, andAddAgentRuleWriteTab.tsx), enable Markdown mode by addingtype="markdown". This is the exact pattern the codebase already uses for the workspace rule description inputs, whereTextBasetoggles Markdown viatype={isMarkdownEnabled ? 'markdown' : undefined}together withautoGrowHeight:App/src/components/Rule/TextBase.tsx
Lines 99 to 112 in ea855a7
<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
InputWrapperblocks, addtype="markdown"and switch the sizing to the established Markdown-input layout:autoGrowHeightwithmaxAutoGrowHeight={variables.textInputAutoGrowMaxHeight}(the same combination used by every other Markdown authoring input, e.g. the money-request and room-description editors). The currentflex1full-height container/input styles should be dropped in favour ofautoGrowHeight, becauseBaseTextInputitself omitsflex1on the inner input once Markdown is enabled, and no Markdown input in the app usesflex1full-height sizing:!isMarkdownEnabled && styles.flex1, No new component is needed — this reuses the existing
TextInputMarkdown 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
MarkdownTextInputfrom@expensify/react-native-live-markdownand passing it as theInputComponent. This was rejected because it bypassesBaseTextInput's existing Markdown wiring (RNMarkdownTextInput+useMarkdownStyle+ HTML-paste handling), duplicating styling logic and diverging from the establishedtype="markdown"convention already used elsewhere in the app.dilshodmackbook-sketch commented
on Jul 23, 2026 ContributorMore actions🚨 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
TextInputalready supports it. All three render the plain variant withInputComponent={TextInput},multiline, and notype:App/src/pages/settings/Agents/Fields/EditPromptPage.tsx
Lines 95 to 107 in d21dd3d
<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]} />
App/src/pages/settings/Agents/AddAgentPage.tsx
Lines 162 to 174 in d21dd3d
<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]} />
App/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx
Lines 86 to 100 in d21dd3d
<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]} /> BaseTextInputselects its underlying input from thetypeprop:App/src/components/TextInput/BaseTextInput/implementation/index.tsx
Lines 102 to 109 in d21dd3d
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 toRNMarkdownTextInput(which wrapsMarkdownTextInputfrom@expensify/react-native-live-markdown):App/src/components/TextInput/BaseTextInput/implementations.ts
Lines 1 to 13 in d21dd3d
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
typedefaults to'default', these three inputs getRNTextInputand no live formatting.One note for the implementation: the issue's phrasing ("swap the plain
TextInputInputComponentforMarkdownTextInput") shouldn't be taken literally — replacingInputComponentwould drop the label, container styling, error display, and form wiring thatTextInputprovides. The established pattern is to keepInputComponent={TextInput}and addtype="markdown", exactly asRule/TextBase.tsxandTaskDescriptionPage/RoomDescriptionPagealready 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 promptInputWrapper(leave theFIRST_NAMEinput inAddAgentPageuntouched). ForEditPromptPage.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
InputWrapperinAddAgentPage.tsx(~L162-L174) andAddAgentRuleWriteTab.tsx(~L86-L100).Three things the implementer needs to handle beyond the prop:
-
The Ctrl+Enter submit handler in
EditPromptPagereads the raw DOM node:App/src/pages/settings/Agents/Fields/EditPromptPage.tsx
Lines 50 to 67 in d21dd3d
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()});
MarkdownTextInputdoesn't render a<textarea>on web, sotextarea.valuewill beundefinedand the shortcut silently breaks. This should read the current form value (via the form draft /useOnyxonEDIT_AGENT_PROMPT_FORM) or an input ref's value instead of the event target.AddAgentRuleWriteTab'ssubmitFormOnModEntersubmits throughformRefso it's fine, but should be re-verified. -
The flex-fill layout —
BaseTextInputbranches on!isMarkdownEnabled && styles.flex1for 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 viagetMarkdownMaxHeightwhenautoGrowHeightis set:App/src/components/TextInput/BaseTextInput/implementation/index.tsx
Lines 433 to 435 in d21dd3d
<View style={[ !isMarkdownEnabled && styles.flex1, The proven markdown-in-a-form pattern (
Rule/TextBase.tsx) pairstype="markdown"withautoGrowHeight+maxAutoGrowHeight. The agent pages should be checked in both portrait and landscape (they already branch onuseIsInLandscapeMode) so the input still fills the screen and doesn't collapse to a single line. -
Which styles to render —
excludedMarkdownStylesonBaseTextInputlets 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?
- Render each of the three pages and assert the prompt input mounts the markdown variant (
RNMarkdownTextInput) rather than the defaultRNTextInput. - Seed
EditPromptPagewith a stored prompt containing markdown and assert thedefaultValueloads with formatting already applied. - Assert Ctrl/Cmd+Enter still submits the form on web from both
EditPromptPageand the Add agent rule write tab after the switch toMarkdownTextInput. - 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.🚨 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-listedTextInputcall 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
mentionReportWYSIWYG guard, covered
preventDefault()in every direct Cmd/Ctrl+Enter handler, and documented the
issue body's- list itemlimitation 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
TextInputwithout selecting its markdown mode.BaseTextInputdefaultstypetodefault, so these fields instantiateRNTextInput; the existingmarkdowntype instead selects the app'sRNMarkdownTextInputwrapper and supplies the shared ExpensiMark parser and markdown styles. The core issue is therefore an authoring-control configuration omission, not a persistence problem.App/src/components/TextInput/BaseTextInput/implementation/index.tsx
Lines 43 to 109 in b444723
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); App/src/components/TextInput/BaseTextInput/implementations.ts
Lines 1 to 13 in b444723
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], ]); App/src/components/RNMarkdownTextInput.tsx
Lines 61 to 105 in b444723
// 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":App/src/pages/settings/Agents/Fields/EditPromptPage.tsx
Lines 95 to 106 in b444723
<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]} App/src/pages/settings/Agents/AddAgentPage.tsx
Lines 162 to 173 in b444723
<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]} App/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx
Lines 85 to 100 in b444723
<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.
App/src/pages/settings/Profile/AgentAIPromptSection.tsx
Lines 218 to 233 in b444723
<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} /> App/src/pages/settings/Profile/AgentAIPromptSection.tsx
Lines 164 to 207 in b444723
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(); } }; App/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx
Lines 58 to 81 in b444723
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(); App/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx
Lines 152 to 168 in b444723
<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:
- The live parser recognizes
#roomas amentionReportrange 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.
App/src/hooks/useMarkdownStyle.ts
Lines 12 to 119 in b444723
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; } App/src/components/MenuItem.tsx
Lines 741 to 746 in b444723
const html = useMemo(() => { if (!title || !shouldParseTitle) { return ''; } return Parser.replace(title, {shouldEscapeText, disabledRules: excludedMarkdownRules}); }, [title, shouldParseTitle, shouldEscapeText, excludedMarkdownRules]); App/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx
Lines 29 to 84 in b444723
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> - On web,
MarkdownTextInputinvokes the suppliedonKeyPressand then inserts\nfor Enter unless that event was prevented. The add-rule, edit-rule, and controlled Profile → AI Prompt editors each save from a directonKeyPressCmd/Ctrl+Enter handler without callingpreventDefault(). 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.
App/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx
Lines 41 to 47 in b444723
const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { if (!('key' in event)) { return; } if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { formRef.current?.submit(); } App/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx
Lines 58 to 64 in b444723
const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { if (!('key' in event)) { return; } if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { formRef.current?.submit(); } App/src/pages/settings/Profile/AgentAIPromptSection.tsx
Lines 202 to 206 in b444723
const handleKeyPress = (e: TextInputKeyPressEvent) => { const event = e as unknown as KeyboardEvent; if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { handleSave(); } 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"andexcludedMarkdownStyles={['mentionReport']}to the promptTextInputin all five authoring paths. This reuses the existingTextInputintegration 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 toRNMarkdownTextInput.excludedMarkdownStylesonly neutralizes the visual style formentionReport; it does not change the raw prompt text that is validated or saved.InputWrapperwill keep registering the sharedTextInputexactly as it does now and forward both markdown props to it.App/src/components/Form/InputWrapper.tsx
Lines 85 to 99 in b444723
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
onKeyPresshandler:AddAgentRuleWriteTabEditAgentRulePageAgentAIPromptSection
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 globaluseKeyboardShortcutpath unchanged. It is different from the three direct handlers:useKeyboardShortcutprevents the shortcut's default action by default,react-native-key-commandobserves 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.App/src/hooks/useKeyboardShortcut.ts
Lines 34 to 48 in b444723
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; 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/flex1layouts on the form pages and the existing controlled auto-grow layout inAgentAIPromptSection.Test coverage:
- Update the existing Add Agent and Edit Prompt tests to assert that the prompt input receives
type="markdown"andexcludedMarkdownStyles={['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
MarkdownTextInputdirectly toInputWrapper, but that would bypass the app's establishedTextInputshell and form-specific behavior.InputWrapperalso specifically recognizes the sharedTextInputwhen configuring multiline form submission. The existingtype="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.App/src/components/Form/InputWrapper.tsx
Lines 29 to 65 in b444723
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, }; App/src/pages/tasks/TaskDescriptionPage.tsx
Lines 126 to 144 in b444723
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
TextInputdefault, 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. Version0.1.333ofreact-native-live-markdownhas no unordered-list or list-item range/style, sotype="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.If live unordered lists are required as part of this issue, they need a separate coordinated dependency change:
- Add unordered-list Markdown-to-HTML parsing and HTML-to-Markdown round-tripping in
expensify-common. - Add list/list-item ranges, nesting metadata, and rendering behavior to
react-native-live-markdownfor web and native while preserving raw values, selection, and cursor behavior. - 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.
- The live parser recognizes
57 remaining items
Triggered auto assignment to @mallenexpensify (
Awaiting Payment)@mallenexpensify pls consider in the payment the additional fix here which was internally confirmed to be implemented here.
Payment Summary
Resolving PRs:
-
Fix - Add react-native-live-markdown to the Custom Agent Instructions input #97876
-
Reviewer: @Pujan92 owed $250 via NewDot
-
Reviewer: @FitseTLT owed $250 via NewDot
BugZero Checklist (@mallenexpensify)
- I have confirmed assignees, roles, and Upwork contracts look correct
- I have paid out Upwork contracts / manual NewDot requests
-
@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.
@mallenexpensify I included it in the pr after I got the confirmation. 👍
@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.
@MelvinBot who is due how much here?
Reacted by melvin-botTwo people are due, $250 each ($500 total), both via NewDot — matching the Payment Summary:
FitseTLT— Contributor: $250. Author + assignee of the resolving PR Fix - Add react-native-live-markdown to the Custom Agent Instructions input #97876 (merged 2026-08-06).Pujan92— C+ reviewer: $250.
No reductions apply:
- Deploy blocker
#98325→ no 50% cut.Pujan92confirmed it isn't a regression of PR Fix - Add react-native-live-markdown to the Custom Agent Instructions input #97876 — it's a separate page that needed the same markdown change, fixed internally byroryabrahamin Parse markdown in agent custom instructions on the profile page #98380. Per the C+ doc, a confirmed non-regression means no pay reduction. - No separate $125 follow-up owed. The "additional fix"
FitseTLTflagged was folded into PR Fix - Add react-native-live-markdown to the Custom Agent Instructions input #97876 itself (not a separate PR), so it's already covered by the $250 —FitseTLTclarified this.
So: pay
FitseTLT$250 andPujan92$250 via NewDot, no adjustments.
Approved: @FitseTLT due $250 via NewDot
Problem
Agent instructions (the "prompt") are stored and rendered as Markdown everywhere they're displayed — e.g. the agent's own
Account > Profile > AI Promptview and any chat messages the agent posts — but the inputs used to write those instructions are plainTextInputcomponents 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
TextInputInputComponentin the pages above for@expensify/react-native-live-markdown'sMarkdownTextInput, consistent with how the chat composer (Composer) already uses it, so formatting renders live as the admin/user types their instructions.Manual Test Steps
*bold*,# heading,- list item) into the instructions input.Upwork Automation - Do Not Edit
Issue Owner
Current Issue Owner: @mallenexpensify