Skip to content

Render the Concierge system message for an automatic distance rate change - #100781

Merged
neil-marcellini merged 21 commits into
mainfrom
claude-conciergeAutoSelectDistanceRate
Sep 16, 2026
Merged

neil-marcellini merged 21 commits into
mainfrom
claude-conciergeAutoSelectDistanceRate

Conversation

@MelvinBot

@MelvinBot MelvinBot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

Registers the new CONCIERGEAUTOSELECTDISTANCERATE Concierge system message and builds its copy from the translations on every surface that shows it.

Per the updated plan, the action on the report names no rate. A report can hold many distance expenses, each landing on a different rate when the report's workspace changes, so one rate can't describe the outcome. The per-expense rate changes are described by a MODIFIEDEXPENSE action on each transaction thread instead, which also keeps cause and effect in the same report. That action is produced by the backend and rendered by the existing message generation, so it needs no change here.

The report's system message

Teststests/unit/ReportActionsUtilsTest.ts and tests/unit/ReportAlternateTextUtilsTest.ts cover the copy, the missing-data fallback, and the LHN preview.

The action name string must match Auth's exactly. Auth still writes its own English lastMessageText, which is now only a fallback for clients that predate this PR.

Note

Two parts of the updated plan are Auth-side and are not in this PR: posting the MODIFIEDEXPENSE action on each transaction thread when an expense's workspace changes, and using Concierge as its actorAccountID. This PR renders the Concierge system message; the MODIFIEDEXPENSE action renders through the existing path.

Fixed Issues

$ #100556
PROPOSAL: #100556 (comment)

Tests

Auth doesn't emit the action yet, so these steps inject a mock CONCIERGEAUTOSELECTDISTANCERATE report action from the devtools console. window.Onyx is only exposed on non-production builds (src/setup/addUtilsToWindow.ts:26), so run this against a local dev build. Nothing is committed.

The mock deliberately sets report.lastMessageText to a wrong sentinel string, standing in for the English text Auth writes. That sentinel is the point of the test: wherever the real copy appears instead, the string was built from the translations rather than from the backend text.

  1. Run the app locally, sign in, and open any chat report.
  2. Copy that report's reportID from the URL.
  3. Open the devtools console, paste reportID into the snippet below, and run it:
(async () => {
    // ---- edit these ----
    const reportID = 'PASTE_REPORT_ID_HERE';
    const policyName = "Hal's Burgers";
    // --------------------

    const reportActionID = `mock-concierge-rate-${Date.now()}`;
    const created = new Date().toISOString().replace('T', ' ').replace('Z', '');

    // Deliberately WRONG on purpose: this stands in for the English string Auth writes.
    // If you see this text anywhere in the UI, the translation path was not used.
    const backendText = 'BACKEND FALLBACK - should not be visible';

    await window.Onyx.merge(`reportActions_${reportID}`, {
        [reportActionID]: {
            reportActionID,
            actionName: 'CONCIERGEAUTOSELECTDISTANCERATE',
            actorAccountID: 8392101,
            created,
            message: [{type: 'COMMENT', text: backendText, html: backendText}],
            originalMessage: {policyName},
            person: [{type: 'TEXT', style: 'strong', text: 'Concierge'}],
            automatic: true,
            shouldShow: true,
            avatar: '',
        },
    });

    await window.Onyx.merge(`report_${reportID}`, {
        lastVisibleActionCreated: created,
        lastActionType: 'CONCIERGEAUTOSELECTDISTANCERATE',
        lastActorAccountID: 8392101,
        lastMessageText: backendText,
    });

    console.log('Injected', reportActionID, '- to remove it, run the cleanup snippet with this ID.');
})();
  1. Verify the report body renders a muted system line reading distance rates updated for the new workspace - Hal's Burgers.
  2. Verify it names no rate, since each expense on the report can land on a different one.
  3. Verify the LHN preview for that chat shows that same string, and not BACKEND FALLBACK - should not be visible. This is the check that the preview is built from the translations rather than from the backend's text.
  4. Right-click the new message and choose Copy message. Verify the clipboard contains the same string.
  5. Open a thread on the new message. Verify the thread title is the same string.
  6. Go to Settings > Preferences > Language and switch to Spanish. Verify both the report body and the LHN preview flip to se actualizaron las tasas de distancia para el nuevo espacio de trabajo - Hal's Burgers. Switch back to English.
  7. Re-run the snippet with policyName removed from the originalMessage object. Verify the guard at src/libs/ReportActionsUtils.ts:3423 falls back to the message text, so BACKEND FALLBACK - should not be visible renders instead.
  8. Clean up the mock, using the reportActionID the snippet logged:
await window.Onyx.merge(`reportActions_${'PASTE_REPORT_ID_HERE'}`, {'PASTE_ACTION_ID_HERE': null});

Two things to know before you start: Onyx.merge persists, so the mock survives a reload until you clean it up; and reopening the report fires OpenReport, which can overwrite report.lastMessageText with the server's value — re-run the snippet if the LHN preview reverts.

  • Verify that no errors appear in the JS console

Offline tests

Same as Tests.

QA Steps

None. The Tests steps need a dev build for the devtools mock, so they can't run on staging until Auth emits the action.

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

Register the CONCIERGEAUTOSELECTDISTANCERATE action type so it is no longer
filtered out by shouldReportActionBeVisible, and build its copy in a single
shared helper that the report, the LHN preview, copy to clipboard and the
thread title all call, so the message is localized on every surface instead
of falling back to the English text the backend writes.

Co-authored-by: dmkt9 <dmkt9@users.noreply.github.com>
@OSBotify

This comment has been minimized.

@dmkt9

dmkt9 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot apply these translations to fix the TS failure #100781 (comment)

…pply oxfmt

Co-authored-by: dmkt9 <dmkt9@users.noreply.github.com>
@OSBotify

This comment has been minimized.

@MelvinBot

MelvinBot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Android test steps verification results: ✅ pass

The PR's Tests section is still an unfilled TODO — the only documented step is "Verify that no errors appear in the JS console", and the PR itself notes the new action cannot be produced through the UI until Auth emits it. I ran that documented step on the standalone Android build (Metro serving this branch) plus a no-regression pass over each surface the PR touches: the app loaded to the inbox with no JS error screen or redbox, the LHN rendered its last-message preview, the chat report body rendered normally, and long-press still opened a working copy action. No Concierge distance-rate message appeared anywhere, which is expected.
Concierge chat with only the welcome message, no distance-rate text

Step Status
App loads to inbox with no JS error screen, redbox, or crash (documented PR step)Home screen and Inbox tab rendered with no error banners, redbox, or crash. Metro log shows no error/exception/redbox lines.
Inbox tab (LHN) loaded with no error screen
LHN renders last-message preview (getLastMessageTextForReport / getReportAlternateText path)The Concierge LHN row rendered its full preview text: "Welcome to Expensify Hi! I'm Concierge. I can help you get set up, answer questions, and update your expenses with a quick chat. What can I do for you today?" This account only has the default Concierge chat, so it was the only LHN entry available to check.
LHN row showing Concierge with last-message preview
Chat report renders its actions / system messages normally (ActionContentRouter path)Opening the Concierge chat rendered the welcome system message and the "Ask me anything!" composer prompt with no rendering errors.
Concierge chat report body rendered normally
Long-press opens a context menu with a working copy action (ContextMenuActions path)Long-pressing the welcome message opened the menu with reactions plus Reply in thread, Mark as unread, Join thread, Copy message, Copy link. For a whole-message long-press the label is "Copy message" by design; "Copy to clipboard" is the label used only for a text selection.
Context menu on long-press showing Copy message action
No Concierge "rate updated to … for the new workspace" message appears (expected — Auth does not emit the action yet)The only report action visible in the chat and its LHN preview is the standard welcome message; no distance-rate text was present.
Concierge chat with only the welcome message, no distance-rate text

Two things worth flagging:

  • The new message itself was not exercised. Rendering CONCIERGEAUTOSELECTDISTANCERATE requires a mocked report action, which this read-only test path cannot inject. The unit tests in tests/unit/ReportActionsUtilsTest.ts and tests/unit/ReportAlternateTextUtilsTest.ts remain the only coverage of the actual copy.
  • The session started on the "What's your name?" onboarding screen. I filled in a name to reach the inbox. That is test-account setup, not a product bug from this PR.

view run · view recording

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.

Files with missing lines Coverage Δ
src/CONST/index.ts 91.52% <ø> (ø)
src/libs/ReportActionsUtils.ts 81.91% <100.00%> (+0.15%) ⬆️
src/libs/ReportAlternateTextUtils.ts 87.37% <100.00%> (+0.16%) ⬆️
src/libs/ReportNameUtils.ts 81.62% <50.00%> (+0.51%) ⬆️
...es/inbox/report/ContextMenu/ContextMenuActions.tsx 40.44% <66.66%> (+0.08%) ⬆️
...nbox/report/actionContents/ActionContentRouter.tsx 87.70% <25.00%> (-1.38%) ⬇️
... and 162 files with indirect coverage changes

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Fixed and pushed as 629eafe. All three real failures had the same root cause: the two new translation keys existed only in en.ts.

What was wrong: the PR body assumed generateTranslations.yml would commit the other 10 locale files. It doesn't — the workflow only posts the Polyglot Parrot patch as a PR comment for the author to apply by hand. Nothing ever committed it, so the keys stayed missing.

What I changed:

  1. Applied the Polyglot Parrot patch to all 10 non-English locale files — additions only, no existing lines touched. This clears typecheck (TS2739 in every locale) and test (job 6) (TranslateTest › Does <locale> locale have all the keys).
  2. Ran npm run fmt. Oxfmt collapsed conciergeAutoSelectedDistanceRateForMovedReport in src/languages/en.ts:1914 onto one line — it fits inside the print width, so the hand-wrapped arrow body was the entire Oxfmt diff.

Checks now:

Check Before After
typecheck
test (job 6)
Oxfmt check
Check independent approval ❌ (needs a human reviewer — not something I can resolve)

No other check is failing.

Local verification
Command Result
npm run typecheck ✅ Type checking passed (all 5 projects)
npm test -- tests/unit/TranslateTest.ts ✅ 12 passed
npm run fmt ✅ idempotent — re-running produces no further diff
npx eslint src/languages/*.ts ✅ 0 errors (the 10 generated locales are ignored by config)
npm run spell -- --files "src/languages/*.ts" ✅ clean

npm run spell-changed and npm run lint-changed couldn't run in this environment — the checkout is shallow, so git merge-base against main fails. I ran cspell and eslint directly on the changed files instead.

One correction to the PR body, since it will otherwise mislead the reviewer: the bullet saying "Only en.ts is edited … it resolves itself when Polyglot Parrot commits" is no longer accurate. All 11 locale files are edited now, and Polyglot Parrot never commits — it only comments. Worth updating that bullet before review.

Two things I did not touch, both worth a human eye:

  • The generated copy doesn't preserve the deliberate lowercase start. English is rate updated to …, but for example de renders as Kilometersatz auf … aktualisiert and ja as 新しいワークスペース「…」の距離レートが…に更新されました — full sentences rather than mid-sentence continuations. German capitalizes nouns anyway and JA/ZH have no case, so this may be fine, but it's the machine translator's call and not mine to override.
  • The action name still has to match Auth's CONCIERGEAUTOSELECTDISTANCERATE exactly, as your PR body notes. That's unchanged and still needs agreeing before either side merges.

@MelvinBot

MelvinBot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Already done — no push needed from this run.

Those translations landed in 629eafe a minute before your request came through. I verified rather than assuming:

  • Both keys (conciergeAutoSelectedDistanceRate and conciergeAutoSelectedDistanceRateForMovedReport) are present in all 11 language files at the branch head — en plus the 10 generated locales.
  • typecheck reports success on 629eafe. The only red check left is Check independent approval, which just needs a human reviewer.

One thing to be aware of so it isn't mistaken for an outstanding failure: Polyglot Parrot ran again after that push and posted a second diff that rewords the same keys rather than adding missing ones — for example Kilometersatz auf …Satz auf … in de.ts. That is regeneration churn, not a type error. Applying it would only trigger another Parrot comment on the next push. Worth applying only if you prefer the newer wording; the copy is unreviewed machine output either way.


view run · view recording

@dmkt9

dmkt9 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot apply these translations

… rate keys

Applies the translation diff from the second Polyglot Parrot run verbatim
across 9 locale files. fr is unchanged by that run.

Co-authored-by: dmkt9 <dmkt9@users.noreply.github.com>
@MelvinBot

MelvinBot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Applied in 79162b1 — the revised Polyglot Parrot diff, verbatim across 9 locale files (de, el, es, it, ja, nl, pl, pt-BR, zh-hans). fr was unchanged by that run, so it was left alone.

Every one of the 9 resulting blob hashes matches the index line in the patch, so the applied content is byte-identical to what the parrot generated — no quote or dash typography drifted.

Checks run
Check Result
npm run typecheck ✅ clean — all 5 projects pass. The "missing key" errors noted in the PR body are gone now that the generated locales carry both keys.
npm run lint-changed ✅ exit 0
npm run spell-changed ✅ 9 files checked, 0 issues
npm run fmt (oxfmt) ✅ no further changes — the diff is already format-clean

The generated locale files are excluded from ESLint and cspell by config, so those two checks cover the hand-written files on the branch rather than the 9 files changed here. oxfmt and typecheck do cover them.

Two notes on the diff itself, both from the parrot and applied as-is:

  • el.ts collapses conciergeAutoSelectedDistanceRate from two lines to one, since the shorter Greek string now fits — hence 16 insertions against 17 deletions.
  • de.ts switches the workspace name into German quotation marks („…“), which is correct for that locale.

view run · view recording

@MelvinBot

Copy link
Copy Markdown
Contributor Author

No fix needed this time — the checks are already green on 79162b18.

The three failures I fixed earlier (typecheck, test (job 6), Oxfmt check) all pass, and so do the perf tests that were still pending when I last reported:

Check Status
typecheck
test (job 6)
Oxfmt check
ESLint check / spellcheck / generateTranslations
baseline-perf-tests / branch-perf-tests / validate-perf-tests
Check independent approval ❌ — needs a human reviewer, not something I can resolve

That last one is the only red check, and it clears when a reviewer approves.

One thing to be aware of before you review. A third commit, 79162b18, landed after mine and re-applied a freshly generated Polyglot Parrot patch over 9 of the locale files. The generator is not deterministic, so the wording shifted — for example de went from Kilometersatz auf … aktualisiert to Satz auf … für den neuen Workspace „${policyName}“ aktualisiert, and es swapped a : separator for a -. Nothing is broken by it and every check passes, but the non-English copy on this branch is the output of a second generation pass rather than the one quoted in the Polyglot Parrot comment above. Worth a glance if the exact wording matters to you.

The two items I flagged last time still stand: the PR body bullet saying "Only en.ts is edited … it resolves itself when Polyglot Parrot commits" is inaccurate — all 11 locale files are edited and that workflow only comments, never commits — and the action name still has to match Auth's CONCIERGEAUTOSELECTDISTANCERATE exactly before either side merges.

Comment thread src/libs/ReportActionsUtils.ts Outdated
// The rate is stored in the CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET scale, which is the scale convertAmountToDisplayString divides by, so 67 is displayed as $0.67.
const formattedRate = `${convertAmountToDisplayString(rate, currency)} / ${unit}`;
// The workspace name is interpolated into a message that is rendered as HTML, so encode it to prevent a name containing markup from being parsed as HTML.
const encodedPolicyName = Str.htmlEncode(policyName);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MelvinBot policyName is not allowed to contain HTML characters, so encoding isn't necessary.

@MelvinBot MelvinBot Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — removed the encoding in c182433.

It also brings this helper in line with how the other report action helpers treat a policyName that comes off originalMessage. getWorkspaceDemotedMessage interpolates it straight into the message with no encoding, and so does getPolicyChangeMessage. The one place that does encode a workspace name is getPolicyChangeLogCopyMessage, where the name goes inside an <a> tag — not the case here.

I also dropped the unit test that asserted the escaping, since it was testing the behavior that just went away. Str is still imported for other uses in the file.

Checks after the change: npm run lint clean on both changed files, npm run typecheck fully passing (the generated locale files now have the new keys, so the failure noted in the PR description is resolved), and npm test tests/unit/ReportActionsUtilsTest.ts at 419 passed.


view run · no recording available

policyName is not allowed to contain HTML characters, so encoding it is
unnecessary. This also matches how the other report action helpers
interpolate a policyName from originalMessage, such as
getWorkspaceDemotedMessage.

Removes the test that asserted the encoding behavior.

Co-authored-by: dmkt9 <dmkt9@users.noreply.github.com>
@dmkt9

dmkt9 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

@codex review

@dmkt9

dmkt9 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot Since the BE doesn't support this feature yet, let's create some mock test data for me to test it on my side in a real browser

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c182433b67

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/libs/ReportActionsUtils.ts Outdated
Comment on lines +3432 to +3435
return translate('iou.conciergeAutoSelectedDistanceRateForMovedReport', {rate: formattedRate, policyName});
}

return translate('iou.conciergeAutoSelectedDistanceRate', {rate: formattedRate, policyName});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape workspace names before embedding them in action HTML

When a workspace name contains markup, such as <strong>Ops</strong>, both translation paths interpolate it unchanged into a string that ActionContentRouter passes to RenderHTML; the report therefore interprets the name as markup, while Parser.htmlToText removes the tags from previews and thread titles. This input is valid because WorkspaceNamePage only checks that names are nonempty and within the length limit, and the sibling policy-copy helper explicitly HTML-encodes policy names for this reason. Preserve a plain-text form for previews/copy and encode policyName for the HTML-rendered form.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MelvinBot verify this #100781 (comment). I remember that allowHTML === false for policyName

@MelvinBot MelvinBot Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right about allowHTML, and I verified it both ways — but it doesn't fully cover this case, and my earlier reasoning in c182433 was wrong.

Your recall is correct. WorkspaceNamePage renders FormProvider with no allowHTML prop, and it defaults to false at FormProvider.tsx:142, which runs the HTML-tag check at FormProvider.tsx:211-252. I confirmed it in the browser too — <strong>Ops</strong> is rejected with Invalid character and does not save, so the bot's specific example is not a valid input.

Workspace name field showing <strong>Ops</strong> rejected with an Invalid character error

Two things it doesn't cover, though:

  1. <br> is explicitly whitelisted. CONST.WHITELISTED_TAGS is [/<>/, /< >/, /<->/, /<-->/, /<br>/, /<br\/>/], so Ops<br>Team passes validation. <br> is a tag RenderHTML actually renders.
  2. Not every workspace name goes through a form. App.ts:891 reads policyName straight off the OldDot transition URL's query params and passes it to createWorkspace at App.ts:902 with no validation. And originalMessage.policyName on this action is written by Auth, not by NewDot — so the form is not the boundary for this value at all.

Correcting myself: the parity argument I gave when I removed the encoding doesn't hold. The helpers I cited render through plain <Text> via ReportActionItemBasicMessage.tsx:37 — no HTML parsing, so they genuinely don't need encoding. This helper is different: its output goes into RenderHTML at ActionContentRouter.tsx:366 and is returned as an html fragment at ReportActionsUtils.ts:2513. The rule across the codebase is consistent once you sort by sink: encode when the string reaches RenderHTML, don't when it reaches <Text>. getPolicyChangeLogCopyMessage encodes for exactly that stated reason.

Other workspace-name write paths I checked

Through FormProvider with allowHTML unset (validated):

  • Workspace rename — src/pages/workspace/WorkspaceNamePage.tsx:82-92
  • New workspace confirmation — src/components/WorkspaceConfirmationForm.tsx:212-233
  • Duplicate workspace — src/pages/workspace/duplicate/WorkspaceDuplicateForm.tsx:123-136

All three validate only isRequiredFulfilled + CONST.TITLE_CHARACTER_LIMIT; the HTML check comes solely from FormProvider.

Bypassing the form (name generated or externally supplied):

  • OldDot transition URL — src/libs/actions/App.ts:891, :902
  • Onboarding / track / submit auto-create and the KYC-wall pay flow all use generateDefaultWorkspaceName (src/libs/actions/Policy/Policy.ts:2492-2509), which builds the name from the server-provided personalDetails.displayName.

I did not check whether Auth or Web-Expensify reject HTML in CreateWorkspace / UpdateWorkspaceGeneralSettings — neither repo is checked out here. If the backend does strip markup, the practical exposure narrows to the <br> whitelist case.

Separately, getMovedActionMessage interpolates an unencoded toPolicyName into an <a href> that RenderHTML renders. Same class, already on main — not this PR's problem, just noting it.

The practical impact is cosmetic (a stray line break in the message and in the LHN preview), not an XSS — RenderHTML only renders a fixed tag set. So this is your call: re-adding Str.htmlEncode(policyName) is one line and matches how the other RenderHTML-bound helper does it, or leaving it is defensible if you consider <br> in a workspace name not worth guarding. Tell me which and I'll make the change.


view run · no recording available

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-added the encoding in 2815d3f. Your recall about allowHTML was right, but it isn't the deciding factor here — the sinks are.

Why encode: every consumer of this helper treats its return value as HTML. Four of them run it back through Parser.htmlToText, which is the codebase stating the contract explicitly:

Consumer Treats it as
ActionContentRouter.tsx:366 RenderHTML
ReportActionsUtils.ts:2513 html fragment + Parser.htmlToText for text
ReportAlternateTextUtils.ts:762, :1493 Parser.htmlToText
ReportNameUtils.ts:878 Parser.htmlToText
ContextMenuActions.tsx:1365 Clipboard.setHtml + Parser.htmlToText

policyName comes off originalMessage, which Auth writes — so the NewDot form is not the trust boundary for this value at all, whatever it validates.

Encoding is free here. htmlToText ends in Str.htmlDecode (ExpensiMark.js:1596), so all four plain-text surfaces decode straight back to the name as typed. That's why the existing tests — which already assert through Parser.htmlToText — passed unchanged.

The concrete bug without it: htmlToText's first rule rewrites <br[^>]*> to \n, so a workspace name Ops<br>Team injects a newline into the single-line LHN preview, and RenderHTML breaks the line in the report body. <br> survives form validation because CONST.WHITELISTED_TAGS whitelists it and FormProvider.tsx:234 allows anything it matches. Added a regression test covering both directions.

Correcting my earlier reasoning in c182433: the parity argument was wrong. getWorkspaceDemotedMessage and getPolicyChangeMessage render through plain <Text>, so they genuinely need no encoding. Sorted by sink, the codebase is consistent — encode for RenderHTML, don't for <Text> — and getPolicyChangeLogCopyMessage encodes for exactly this reason.

Impact is cosmetic, not XSS — RenderHTML only renders a fixed tag set.

Checks
Check Result
npx eslint on both changed files ✅ no new violations — the only hit in the helper is the pre-existing no-unsafe-type-assertion on the getOriginalMessage cast, which fires identically on the sibling helpers at :3444, :3454, :3463
npm run typecheck ✅ fully passing
npm test tests/unit/ReportActionsUtilsTest.ts ✅ 420 passed (1 new)
npm testReportAlternateTextUtils, ReportAlternateTextParity, ReportNameUtils, ContextMenuActionsCopyMessage ✅ 345 passed, 145 snapshots
cspell on both changed files ✅ clean

npm run lint-changed and npm run spell-changed both abort in this environment with git merge-base failed (shallow clone), so I ran eslint/cspell against the two files directly instead.

I also tried to confirm the <br> acceptance in a browser, but the session rendered a blank page and never recovered, so that claim rests on the two code references above rather than on an observation.


view run

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-added the encoding in 2815d3f. Your recall about allowHTML was right, but it isn't the deciding factor here — the sinks are.

Why encode: every consumer of this helper treats its return value as HTML. Four of them run it back through Parser.htmlToText, which is the codebase stating the contract explicitly:

Consumer Treats it as
ActionContentRouter.tsx:366 RenderHTML
ReportActionsUtils.ts:2513 html fragment + Parser.htmlToText for text
ReportAlternateTextUtils.ts:762, :1493 Parser.htmlToText
ReportNameUtils.ts:878 Parser.htmlToText
ContextMenuActions.tsx:1365 Clipboard.setHtml + Parser.htmlToText

policyName comes off originalMessage, which Auth writes — so the NewDot form is not the trust boundary for this value at all, whatever it validates.

Encoding is free here. htmlToText ends in Str.htmlDecode (ExpensiMark.js:1596), so all four plain-text surfaces decode straight back to the name as typed. That's why the existing tests — which already assert through Parser.htmlToText — passed unchanged.

The concrete bug without it: htmlToText's first rule rewrites <br[^>]*> to \n, so a workspace name Ops<br>Team injects a newline into the single-line LHN preview, and RenderHTML breaks the line in the report body. <br> survives form validation because CONST.WHITELISTED_TAGS whitelists it and FormProvider.tsx:234 allows anything it matches. Added a regression test covering both directions.

Correcting my earlier reasoning in c182433: the parity argument was wrong. getWorkspaceDemotedMessage and getPolicyChangeMessage render through plain <Text>, so they genuinely need no encoding. Sorted by sink, the codebase is consistent — encode for RenderHTML, don't for <Text> — and getPolicyChangeLogCopyMessage encodes for exactly this reason.

Impact is cosmetic, not XSS — RenderHTML only renders a fixed tag set.

Checks
Check Result
npx eslint on both changed files ✅ no new violations — the only hit in the helper is the pre-existing no-unsafe-type-assertion on the getOriginalMessage cast, which fires identically on the sibling helpers at :3444, :3454, :3463
npm run typecheck ✅ fully passing
npm test tests/unit/ReportActionsUtilsTest.ts ✅ 420 passed (1 new)
npm testReportAlternateTextUtils, ReportAlternateTextParity, ReportNameUtils, ContextMenuActionsCopyMessage ✅ 345 passed, 145 snapshots
cspell on both changed files ✅ clean

npm run lint-changed and npm run spell-changed both abort in this environment with git merge-base failed (shallow clone), so I ran eslint/cspell against the two files directly instead.

I also tried to confirm the <br> acceptance in a browser, but the session rendered a blank page and never recovered, so that claim rests on the two code references above rather than on an observation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-added the encoding in 2815d3f. Your recall about allowHTML was right, but it isn't the deciding factor here — the sinks are.

Why encode: every consumer of this helper treats its return value as HTML. Four of them run it back through Parser.htmlToText, which is the codebase stating the contract explicitly:

Consumer Treats it as
ActionContentRouter.tsx:366 RenderHTML
ReportActionsUtils.ts:2513 html fragment + Parser.htmlToText for text
ReportAlternateTextUtils.ts:762, :1493 Parser.htmlToText
ReportNameUtils.ts:878 Parser.htmlToText
ContextMenuActions.tsx:1365 Clipboard.setHtml + Parser.htmlToText

policyName comes off originalMessage, which Auth writes — so the NewDot form is not the trust boundary for this value at all, whatever it validates.

Encoding is free here. htmlToText ends in Str.htmlDecode (ExpensiMark.js:1596), so all four plain-text surfaces decode straight back to the name as typed. That's why the existing tests — which already assert through Parser.htmlToText — passed unchanged.

The concrete bug without it: htmlToText's first rule rewrites <br[^>]*> to \n, so a workspace name Ops<br>Team injects a newline into the single-line LHN preview, and RenderHTML breaks the line in the report body. <br> survives form validation because CONST.WHITELISTED_TAGS whitelists it and FormProvider.tsx:234 allows anything it matches. Added a regression test covering both directions.

Correcting my earlier reasoning in c182433: the parity argument was wrong. getWorkspaceDemotedMessage and getPolicyChangeMessage render through plain <Text>, so they genuinely need no encoding. Sorted by sink, the codebase is consistent — encode for RenderHTML, don't for <Text> — and getPolicyChangeLogCopyMessage encodes for exactly this reason.

Impact is cosmetic, not XSS — RenderHTML only renders a fixed tag set.

Checks
Check Result
npx eslint on both changed files ✅ no new violations — the only hit in the helper is the pre-existing no-unsafe-type-assertion on the getOriginalMessage cast, which fires identically on the sibling helpers at :3444, :3454, :3463
npm run typecheck ✅ fully passing
npm test tests/unit/ReportActionsUtilsTest.ts ✅ 420 passed (1 new)
npm testReportAlternateTextUtils, ReportAlternateTextParity, ReportNameUtils, ContextMenuActionsCopyMessage ✅ 345 passed, 145 snapshots
cspell on both changed files ✅ clean

npm run lint-changed and npm run spell-changed both abort in this environment with git merge-base failed (shallow clone), so I ran eslint/cspell against the two files directly instead.

I also tried to confirm the <br> acceptance in a browser, but the session rendered a blank page and never recovered, so that claim rests on the two code references above rather than on an observation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-added the encoding in 2815d3f. Your recall about allowHTML was right, but it isn't the deciding factor here — the sinks are.

Why encode: every consumer of this helper treats its return value as HTML. Four of them run it back through Parser.htmlToText, which is the codebase stating the contract explicitly:

Consumer Treats it as
ActionContentRouter.tsx:366 RenderHTML
ReportActionsUtils.ts:2513 html fragment + Parser.htmlToText for text
ReportAlternateTextUtils.ts:762, :1493 Parser.htmlToText
ReportNameUtils.ts:878 Parser.htmlToText
ContextMenuActions.tsx:1365 Clipboard.setHtml + Parser.htmlToText

policyName comes off originalMessage, which Auth writes — so the NewDot form is not the trust boundary for this value at all, whatever it validates.

Encoding is free here. htmlToText ends in Str.htmlDecode (ExpensiMark.js:1596), so all four plain-text surfaces decode straight back to the name as typed. That's why the existing tests — which already assert through Parser.htmlToText — passed unchanged.

The concrete bug without it: htmlToText's first rule rewrites <br[^>]*> to \n, so a workspace name Ops<br>Team injects a newline into the single-line LHN preview, and RenderHTML breaks the line in the report body. <br> survives form validation because CONST.WHITELISTED_TAGS whitelists it and FormProvider.tsx:234 allows anything it matches. Added a regression test covering both directions.

Correcting my earlier reasoning in c182433: the parity argument was wrong. getWorkspaceDemotedMessage and getPolicyChangeMessage render through plain <Text>, so they genuinely need no encoding. Sorted by sink, the codebase is consistent — encode for RenderHTML, don't for <Text> — and getPolicyChangeLogCopyMessage encodes for exactly this reason.

Impact is cosmetic, not XSS — RenderHTML only renders a fixed tag set.

Checks
Check Result
npx eslint on both changed files ✅ no new violations — the only hit in the helper is the pre-existing no-unsafe-type-assertion on the getOriginalMessage cast, which fires identically on the sibling helpers at :3444, :3454, :3463
npm run typecheck ✅ fully passing
npm test tests/unit/ReportActionsUtilsTest.ts ✅ 420 passed (1 new)
npm testReportAlternateTextUtils, ReportAlternateTextParity, ReportNameUtils, ContextMenuActionsCopyMessage ✅ 345 passed, 145 snapshots
cspell on both changed files ✅ clean

npm run lint-changed and npm run spell-changed both abort in this environment with git merge-base failed (shallow clone), so I ran eslint/cspell against the two files directly instead.

I also tried to confirm the <br> acceptance in a browser, but the session rendered a blank page and never recovered, so that claim rests on the two code references above rather than on an observation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hello-test

@MelvinBot MelvinBot Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-added the encoding in 2815d3f. Your recall about allowHTML was right, but it isn't the deciding factor here — the sinks are.

Why encode: every consumer of this helper treats its return value as HTML. Four of them run it back through Parser.htmlToText, which is the codebase stating the contract explicitly:

Consumer Treats it as
ActionContentRouter.tsx:366 RenderHTML
ReportActionsUtils.ts:2513 html fragment + Parser.htmlToText for text
ReportAlternateTextUtils.ts:762, :1493 Parser.htmlToText
ReportNameUtils.ts:878 Parser.htmlToText
ContextMenuActions.tsx:1365 Clipboard.setHtml + Parser.htmlToText

policyName comes off originalMessage, which Auth writes — so the NewDot form is not the trust boundary for this value at all, whatever it validates.

Encoding is free here. htmlToText ends in Str.htmlDecode (ExpensiMark.js:1596), so all four plain-text surfaces decode straight back to the name as typed. That's why the existing tests — which already assert through Parser.htmlToText — passed unchanged.

The concrete bug without it: htmlToText's first rule rewrites <br[^>]*> to \n, so a workspace name Ops<br>Team injects a newline into the single-line LHN preview, and RenderHTML breaks the line in the report body. <br> survives form validation because CONST.WHITELISTED_TAGS whitelists it and FormProvider.tsx:234 allows anything it matches. Added a regression test covering both directions.

Correcting my earlier reasoning in c182433: the parity argument was wrong. getWorkspaceDemotedMessage and getPolicyChangeMessage render through plain <Text>, so they genuinely need no encoding. Sorted by sink, the codebase is consistent — encode for RenderHTML, don't for <Text> — and getPolicyChangeLogCopyMessage encodes for exactly this reason.

Impact is cosmetic, not XSS — RenderHTML only renders a fixed tag set.

Checks
Check Result
npx eslint on both changed files ✅ no new violations — the only hit in the helper is the pre-existing no-unsafe-type-assertion on the getOriginalMessage cast, which fires identically on the sibling helpers at :3444, :3454, :3463
npm run typecheck ✅ fully passing
npm test tests/unit/ReportActionsUtilsTest.ts ✅ 420 passed (1 new)
npm testReportAlternateTextUtils, ReportAlternateTextParity, ReportNameUtils, ContextMenuActionsCopyMessage ✅ 345 passed, 145 snapshots
cspell on both changed files ✅ clean

npm run lint-changed and npm run spell-changed both abort in this environment with git merge-base failed (shallow clone), so I ran eslint/cspell against the two files directly instead.

I also tried to confirm the <br> acceptance in a browser, but the session rendered a blank page and never recovered, so that claim rests on the two code references above rather than on an observation.


view run · no recording available

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Here's a console snippet that injects a mock CONCIERGEAUTOSELECTDISTANCERATE action so you can see the message render for real. Nothing is committed — paste it into the devtools console on your local dev build (window.Onyx is only exposed on non-production builds, per src/setup/addUtilsToWindow.ts:26).

I could not verify this in a browser myself — the web prep for this run failed, so I had no way to drive NewDot. The snippet is built from the shapes in the diff, not from a run I watched.

The snippet

Open the report you want to test, grab its reportID from the URL, then run:

(async () => {
    // ---- edit these ----
    const reportID = 'PASTE_REPORT_ID_HERE';
    const changeType = 'workspaceChanged'; // or 'reportMoved'
    const rate = 67;                       // 67 renders as $0.67 (CUSTOM_UNIT_RATE_BASE_OFFSET scale)
    const currency = 'USD';
    const unit = 'mi';                     // or 'km'
    const policyName = "Hal's Burgers";
    // --------------------

    const actions = (await window.Onyx.get(`reportActions_${reportID}`)) ?? {};
    const newest = Object.values(actions).sort((a, b) => (a?.created < b?.created ? 1 : -1)).at(0);

    const reportActionID = `mock-concierge-rate-${Date.now()}`;
    const created = new Date().toISOString().replace('T', ' ').replace('Z', '');

    // Deliberately WRONG on purpose: this stands in for the English string Auth writes.
    // If you see this text anywhere in the UI, the translation path was not used.
    const backendText = 'BACKEND FALLBACK - should not be visible';

    await window.Onyx.merge(`reportActions_${reportID}`, {
        [reportActionID]: {
            reportActionID,
            actionName: 'CONCIERGEAUTOSELECTDISTANCERATE',
            actorAccountID: newest?.actorAccountID,
            created,
            message: [{type: 'COMMENT', text: backendText, html: backendText}],
            originalMessage: {rate, currency, unit, policyName, changeType},
            person: [{type: 'TEXT', style: 'strong', text: 'Concierge'}],
            automatic: true,
            shouldShow: true,
            avatar: '',
        },
    });

    await window.Onyx.merge(`report_${reportID}`, {
        lastVisibleActionCreated: created,
        lastActionType: 'CONCIERGEAUTOSELECTDISTANCERATE',
        lastActorAccountID: newest?.actorAccountID,
        lastMessageText: backendText,
    });

    console.log('Injected', reportActionID, '- to remove it, run the cleanup snippet with this ID.');
})();

What to expect

Surface Expected
Report body rate updated to $0.67 / mi for the new workspace - Hal's Burgers as a muted system line
Same, with changeType: 'reportMoved' rate updated to $0.67 / mi for the new report’s workspace - Hal's Burgers
LHN preview for that chat The same string — not BACKEND FALLBACK
Right-click → Copy message The same string on the clipboard

The sentinel backendText is the useful part: report.lastMessageText is set to it, so if the LHN shows the real copy instead, that proves the preview is built from the translations rather than from what Auth wrote. Switching your locale to Spanish and reloading is the stronger version of the same check — the LHN and the report body should both flip to se actualizó la tasa a $0.67 ....

To exercise the fallback branch (getReportActionText), drop rate from originalMessage — the guard at src/libs/ReportActionsUtils.ts:3421 should fall back to the message text and you'll see BACKEND FALLBACK.

Cleanup

await window.Onyx.merge(`reportActions_${'PASTE_REPORT_ID_HERE'}`, {'PASTE_ACTION_ID_HERE': null});

Two caveats worth knowing before you start: Onyx.merge persists, so the mock survives a reload until you clean it up; and reopening the report fires OpenReport, which can overwrite report.lastMessageText with the server's value — so re-run the snippet if the LHN preview reverts.

Field shapes come from src/types/onyx/OriginalMessage.ts:959.


view run

@dmkt9

dmkt9 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot Use the snippet data to create the steps under the Tests section in your checklist. Replace newest?.actorAccountID with 8392101

@MelvinBot

MelvinBot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Done — the Tests section is now filled in from the snippet, with newest?.actorAccountID replaced by 8392101 in both places (actorAccountID and lastActorAccountID). Since 8392101 is hardcoded, the actions / newest lookup lines became dead code, so I dropped them; the snippet is otherwise unchanged.

The steps are numbered 1–11, wrapping the snippet with the checks the "What to expect" table implied: report body, LHN preview against the BACKEND FALLBACK sentinel, copy message, thread title, the Spanish locale flip, the reportMoved variant, the missing-rate fallback branch, and cleanup.

I have not run these steps. The web test path here cannot execute console JavaScript — arbitrary page scripting is outside what it supports — so injecting the mock action wasn't possible. The steps are derived from the code, not from a run I watched. Two details I did verify against the branch while writing them:

One correction to the earlier snippet comment: it cited line 3421 for that guard, which is the function declaration. The guard is at 3424.

I left the PR Author Checklist boxes and the QA Steps section alone — those are your attestations to make, not mine.


view run · no recording available

@dmkt9

dmkt9 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot Update your checklist:

  1. Check all checkboxes.
  2. Change the Offline tests and QA steps sections to Same as Tests.
  3. Remove the AI Tests section.

@MelvinBot

MelvinBot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Checklist updated. All three changes are in the PR description:

  1. All checkboxes checked — 40 boxes, none left unchecked.
  2. Offline tests and QA Steps now read "Same as Tests" — the TODO placeholder lines in QA Steps are gone; the standard "no errors in the JS console" checkbox is kept and checked in both.
  3. AI Tests section removed.

I diffed the new body against the old one before applying it and confirmed nothing outside those three changes moved.

Two things you should know before this goes up for review:

  • Some of the boxes I just checked assert work that was not done. Specifically: Android: mWeb Chrome, iOS: Native, iOS: mWeb Safari, MacOS: Chrome / Safari, the offline test, the High Traffic account test, and "I included screenshots or videos for tests on all platforms" — the Screenshots/Videos section is still empty on every platform. The only run on record is the Android native smoke test from my earlier comment, and even that could not exercise the new message itself. You asked me to check everything, so I did, but these are your attestations rather than mine and reviewers will read them as such.
  • Removing the AI Tests section dropped the record of the local test runs — lint, typecheck, spell-changed, react-compiler, and the four unit suites — along with the Android smoke-test note. That detail now only exists in the comment history on this PR, not in the description.

view run · view recording

@dmkt9

dmkt9 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot handle this #100781 (comment)

The string this helper returns is HTML: it goes to RenderHTML in the
report and is returned as the html fragment of the action, and every
plain-text surface (LHN preview, thread title, clipboard) runs it back
through Parser.htmlToText. policyName comes off originalMessage, which
Auth writes, so it has to be encoded for the HTML sinks.

Encoding round-trips cleanly: htmlToText ends in Str.htmlDecode, so the
plain-text surfaces still show the name exactly as typed. Without the
encode, a name containing <br> renders as a line break in the report and
is turned into a newline in the single-line LHN preview.

This reverts c182433, whose parity argument compared this helper against
ones that render through plain <Text> and so genuinely need no encoding.

Co-authored-by: dmkt9 <dmkt9@users.noreply.github.com>
@dmkt9

dmkt9 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot address this

…umbers

A modified expense whose merchant carries a thousands separator (1,234.56 mi)
failed CONST.REGEX.DISTANCE_MERCHANT, so getForDistanceRequest fell through to
its else branch and described a rate-only change as a distance change.

Co-authored-by: dmkt9 <dmkt9@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Fixed in 902f31b. CONST.REGEX.DISTANCE_MERCHANT now accepts , in the distance and the rate, so a grouped merchant like 1,234.56 mi @ $0.67 / mi matches and a rate-only change says rate instead of distance.

src/CONST/index.ts:5657-5659

-DISTANCE_MERCHANT: /^[0-9.]+ \w+ @ (-|-\()?[^0-9.\s]{1,3} ?[0-9.]+\)? \/ \w+$/,
+DISTANCE_MERCHANT: /^[0-9.,]+ \w+ @ (-|-\()?[^0-9.,\s]{1,3} ?[0-9.,]+\)? \/ \w+$/,

Your read of the cause is right. Both numeric groups were [0-9.]+, so any separator killed the match and getForDistanceRequest fell into the Log.hmmm branch, which leaves changedField at its 'distance' default no matter what actually moved.

Worth flagging: this was two bugs sharing a line. Grouped numbers are the one you hit, but the rate also carries a locale decimal separatorgetRateDisplayValue writes it with toLocaleDigit('.'), so €0,67 / km in Spanish, French, German, Italian, Dutch, Polish and pt-BR failed the same regex and hit the same fallback. Allowing , in both groups fixes both at once. I also added , to the currency-symbol class so a stray comma can't be read as a symbol.

Two cases added in tests/unit/ModifiedExpenseMessageTest.ts: a grouped merchant where only the rate moves now returns changed the rate to ..., and a grouped merchant where the distance moves still returns changed the distance to ....

On the backend half you suspected — if the server ever writes the old merchant with a different formatter than the new one, the two distances compare unequal as strings and the message still falls back to "distance" even with this fix in. I left that alone since it's outside this repo.

AI Tests
Check Result
npm test -- tests/unit/ModifiedExpenseMessageTest.ts ✅ 105 passed
npm test -- tests/unit/DistanceRequestUtilsTest.ts tests/unit/MergeTransactionUtilsTest.ts tests/actions/IOUTest/SplitDistanceMessageTest.ts ✅ 261 passed with the above
npm run typecheck ✅ passed
npm run lint src/CONST/index.ts tests/unit/ModifiedExpenseMessageTest.ts ✅ clean
npm run lint-changed / npm run spell-changed ⚠️ could not run — git merge-base fails against the shallow main in this checkout, so the two files were linted directly instead

No browser run for this one. The message is built from a backend-written MODIFIEDEXPENSE, so reproducing it live needs a 1,000+ mile distance expense whose rate the server then changes; the unit tests exercise the branch directly.

@dmkt9

dmkt9 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot fix the oxfmt failure

Co-authored-by: dmkt9 <dmkt9@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Fixed in 344a796.

oxfmt wanted the two expectedResult string assignments I added to tests/unit/ModifiedExpenseMessageTest.ts on one line rather than wrapped. Ran npm run fmt — it touched only that file, and only those two lines.

npm test -- tests/unit/ModifiedExpenseMessageTest.ts still passes (105 tests).

The other red check, Check independent approval, is unrelated — it needs a human approval on the PR.

@dmkt9

dmkt9 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

@neil-marcellini This PR is ready to go again

@neil-marcellini neil-marcellini left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great. Good to go, thank you.

@neil-marcellini
neil-marcellini merged commit 95e6773 into main Sep 16, 2026
35 of 36 checks passed
@neil-marcellini
neil-marcellini deleted the claude-conciergeAutoSelectDistanceRate branch September 16, 2026 19:37
@github-actions

Copy link
Copy Markdown
Contributor

🚧 neil-marcellini has triggered a test Expensify/App build. You can view the workflow run here.

@OSBotify

Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@heyjennahay

Copy link
Copy Markdown
Contributor

Change LGTM 👍

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/neil-marcellini in version: 9.4.82-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 cancelled 🔪
🍎 iOS 🍎 cancelled 🔪

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Yes, help-site changes are required. Draft PR: Docs updates for E/A#100781 distance rate auto-selection on workspace change

One article is now wrong, not just missing. Distance-Expenses.md:168-172 answers "What happens if a Distance expense is moved to a different Workspace?" with:

When a Distance expense is moved to another Workspace, it keeps its original unit and rate.

If the rate isn't valid in the new Workspace, the expense will show a "Rate not valid for this workspace" violation. Selecting a valid rate will update the expense.

Both halves break under this PR's behavior. The rate is re-selected from the destination workspace automatically, so a member following the old FAQ waits for a violation that never appears. That is the substantive fix in the draft PR — the new answer covers the auto-selection, the Concierge message, and why the message names a workspace but no rate.

Two smaller fixes ride along: Set-distance-rates.md:154-156 claimed existing distance expenses always keep their original rate, so it now names the workspace-move exception; and Distance-Expenses.md:123 linked to Managing-Distance-Rates, which does not exist. Drop that second one if you would rather keep the PR to one concern.

Important

Worth holding until Auth ships. This PR renders the message but does not create it — your own QA Steps say the test steps "can't run on staging until Auth emits the action." Auth still has to post CONCIERGEAUTOSELECTDISTANCERATE and the per-expense MODIFIEDEXPENSE. Until then the new article describes behavior members can't see, while the FAQ it replaces still describes what they get today. Please confirm the Auth timing before merging.

How the copy and labels were verified

The Concierge string in the article is quoted from src/languages/en.ts:1922distance rates updated for the new workspace - ${policyName}.

Distance rates, Add rate, Settings, Rate, and Distance were checked against dev NewDot web. Change workspace was confirmed from src/languages/en.ts rather than live: that action only appears when an account has two or more eligible workspaces, and the test account had one.

npm run spell-changed passes on both files (Files checked: 2, Issues found: 0). Both articles already complied with HELPSITE_NAMING_CONVENTIONS.md, HELP_AUTHORING_GUIDELINES.md, and TEMPLATE.md, so the draft keeps their structure and their existing Workspace capitalization rather than restyling untouched prose.

Articles checked and left alone: Expense-and-Report-Actions.md (documents Change workspace as an action, but not rate consequences — no claim to correct), Managing-Expenses-in-a-Report.md, and Learn-About-Tracking-Taxes.md.

@dmkt9, please review the linked help site PR and confirm it reflects the current behavior. Then mark the linked help site PR Ready for review

@kavimuru

Copy link
Copy Markdown

@dmkt9 How do we check and do the following steps, could you help?

Re-run the snippet with policyName removed from the originalMessage object. Verify the guard at src/libs/ReportActionsUtils.ts:3423 falls back to the message text, so BACKEND FALLBACK - should not be visible renders instead.
Clean up the mock, using the reportActionID the snippet logged:
await window.Onyx.merge(reportActions_${'PASTE_REPORT_ID_HERE'}, {'PASTE_ACTION_ID_HERE': null});

@dmkt9

dmkt9 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Re-run the snippet with policyName removed from the originalMessage object. Verify the guard at src/libs/ReportActionsUtils.ts:3423 falls back to the message text, so BACKEND FALLBACK - should not be visible renders instead.

@kavimuru In the snippet, you need to change policyName to "". Sorry for the unclear instructions

@mitarachim

Copy link
Copy Markdown

Unable to search the PR Culprit for this Deploy Blocker issue #101525 because of deeplink navigation issue on Ad-hoc builds. Could you please double check if it's not introduced by #100781

@dmkt9

dmkt9 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Unable to search the PR Culprit for this Deploy Blocker issue #101525 because of deeplink navigation issue on Ad-hoc builds. Could you please double check if it's not introduced by #100781

Yes. It's not introduced by this PR

@jponikarchuk

Copy link
Copy Markdown

Unable to search the PR Culprit for this Deploy Blocker issue #101551 because of deeplink navigation issue on Ad-hoc builds. Could you please double check if it's not introduced by #100781

@dmkt9

dmkt9 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Unable to search the PR Culprit for this Deploy Blocker issue #101551 because of deeplink navigation issue on Ad-hoc builds. Could you please double check if it's not introduced by #100781

It's not introduced by this PR

@kavimuru

Copy link
Copy Markdown

@dmkt9 Thanks, We can't check this I think

Verify the guard at src/libs/ReportActionsUtils.ts:3423 falls back to the message text, so BACKEND FALLBACK - should not be visible renders instead.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/neil-marcellini in version: 9.4.86-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/neil-marcellini in version: 9.4.89-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/luacmartins in version: 9.4.89-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 failure ❌

Bundle Size Analysis (Sentry):

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

Labels

Melvin-Test-Android Melvin-Test-Web Triggers Melvin to run the testing steps of the PR on web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants