Skip to content

feat: message search within conversation - #622

Open
sakebomb wants to merge 1 commit into
a-ghorbani:mainfrom
sakebomb:feat/message-search
Open

feat: message search within conversation#622
sakebomb wants to merge 1 commit into
a-ghorbani:mainfrom
sakebomb:feat/message-search

Conversation

@sakebomb

@sakebomb sakebomb commented Mar 5, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds in-conversation message search. Matches are highlighted in place (find-in-page) — the full conversation stays visible and a match count is shown, rather than filtering the message list.
  • Search bar appears via a toolbar icon with auto-focus, a match-count / "No results" indicator, and a close button.
  • Matching text is highlighted with <mark> tags via SearchQueryContext (avoids prop drilling through ChatView → Message → TextMessage → MarkdownView).

Why find-in-page rather than filtering the list

ChatView's messages prop is conversation truth — it drives the empty state, the context banners, and the suggested-prompts row (whose onSelect sends a message). Swapping it for a filtered list made a zero-result search render the new-chat state, as if the chat had been deleted. Highlighting in place never touches that prop, so all of those derivations stay anchored to the real conversation.

Implementation details

  • ChatSessionStore: isSearchMode, searchQuery, enterSearchMode() (also exits edit mode), exitSearchMode(), setSearchQuery(), and a searchMatchCount getter. Count derives from derivedText, so assistant replies (assistant_turn rows, whose text column is empty) are searched via their step content. Search state resets on session create, switch, and reset.
  • ChatSearchBar: search input, match-count / no-results indicator, close button.
  • HeaderRight: search icon toggle (visible when a session is active).
  • ChatScreen: passes the unfiltered conversation and the search bar via customContent; wraps ChatView in SearchQueryContext.Provider.
  • MarkdownView: consumes SearchQueryContext and wraps matches in <mark> after markdown→HTML. The highlighter decodes the basic HTML entities before matching and re-encodes after (so a query with ', &, <, > highlights and agrees with the count), skips code/pre regions, and no longer leaks on a self-closing <code/>.
  • MarkdownProvider: registers the mark element model and styles it with dedicated searchHighlight / onSearchHighlight theme tokens that clear ~3:1 against the message background in both themes (the old tertiaryContainer was invisible in light mode).
  • Theme tokens: searchHighlight / onSearchHighlight added to the token palette and SemanticColors.
  • l10n: chat.searchMessages, chat.noResults, chat.closeSearch in en.json.

Test plan

  • Store tests: enter/exit, query setting, searchMatchCount (user + assistant_turn fixtures, whitespace/not-in-mode gating), edit-mode exit on open, and search-state reset on create + session switch. Mutation-verified: reverting to the msg.type === 'text' filter turns the assistant-reply test red.
  • MarkdownView tests: entity-safe highlight (apostrophe), self-closing <code/>, code-block skip, and a render test asserting the searchHighlight token is applied (mutation-verified against tertiaryContainer).
  • ChatSearchBar component tests.
  • tsc --noEmit clean · eslint clean · l10n:validate valid.
  • Full suite: 269 suites / 4186 passed, 2 skipped. No native changes.
  • Manual device pass of the search bar (iOS/Android).

Acceptance criteria (#603)

  • Search bar accessible from the chat view — header search icon toggles the bar.
  • ⚠️ Message list filters to matching messages in real time — satisfied as find-in-page: matches are highlighted in place with a live count, rather than filtering the list. This is a deliberate deviation from the AC's literal wording — filtering ChatView's messages prop corrupts its conversation-truth derivations (empty state, banners, suggested-prompts), which was blocking bug [feat] Add n_gpu_layer param to the settings #2. Flagging for explicit sign-off; happy to add prev/next jump or revisit if you'd prefer literal list-filtering.
  • Query term highlighted within matching messages — via <mark> + dedicated theme tokens.
  • Clear/exit search restores the normal viewexitSearchMode.
  • Pure JS, no new native dependencies — no native changes in the diff.

Deferred (non-blocking, follow-up)

Perf debounce / query-as-prop, the a11y set (BackHandler, live region, hitSlop, match-count wording), and minor polish (l10n dedup, autoFocus prop, spellCheck, StyleSheet→createStyles).

Closes #603

@sakebomb
sakebomb force-pushed the feat/message-search branch from 8eb4a41 to 3838f07 Compare July 23, 2026 19:50

@a-ghorbani a-ghorbani left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for this — the HTML-splitting highlighter is careful work (entity and code-region guards are more than most attempts do), and the store/component split is clean.

I ran the branch on an iOS simulator against a seeded session to check the behaviour rather than argue from the code alone. Four issues are visually reproducible; the screenshots are attached inline where the code causes them.

The seeded conversation used for every capture, oldest first:

# author content
1 user How do leaves make energy?
2 assistant (assistant_turn) Leaves use chlorophyll inside the chloroplasts to turn sunlight into chemical energy.
3 user I don't remember the gradient rule for water uptake
4 assistant (assistant_turn) The concentration gradient drives water uptake through the roots.

Blocking

  1. Search never matches assistant replies — they are assistant_turn rows, not text.
  2. Filtering the messages prop makes a zero-result search render the new-chat empty state.

Should fix before merge

  1. The highlight background is invisible in the light theme (it works in dark).
  2. Queries containing ', &, <, > are counted but never highlighted.
  3. createNewSession doesn't reset search state.
  4. A self-closing <code/> silently disables highlighting for the rest of a message.

Non-visual items (performance, tests, a11y, minor polish) are in a follow-up comment so this review stays readable.

Verification on the branch: tsc --noEmit clean, eslint 0 errors, jest 263 suites / 3975 tests green. No native changes. Note the branch is 37 commits behind main and currently conflicts in ChatScreen.tsx.

Generated by PocketPal Dev Team

Comment thread src/store/ChatSessionStore.ts Outdated

const query = this.searchQuery.toLowerCase().trim();
return messages.filter(
msg => msg.type === 'text' && msg.text.toLowerCase().includes(query),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Blocking — search can never match an assistant reply.

Every assistant reply the current pipeline produces is an assistant_turn row, created at src/hooks/useChatSession.ts:198-203. MessageType.AssistantTurn has no text field — the content lives in steps[].content. So msg.type === 'text' && msg.text only ever matches user messages.

ChatSessionRepository.ts:472-478 states the contract:

For assistant_turn rows the text column stays empty; every consumer routes through derivedText(message).

Reproduced on a simulator. The word "energy" appears in both a user message and the assistant reply — the count reads 1 and the reply is gone:

energy

The helper is already imported in this file (line 18) and used at line 482:

return messages.filter(msg =>
  derivedText(msg).toLowerCase().includes(query),
);

Worth fixing the test alongside it: ChatSessionStore.test.ts:2590 ("excludes non-text messages") uses an image row, so it passes both before and after this change and doesn't pin the behaviour. An assistant_turn fixture would.

One heads-up: fixing this puts large assistant bodies into the highlight path for the first time, which is what makes the re-render cost in my follow-up comment real. Worth doing both together.

Comment thread src/screens/ChatScreen/ChatScreen.tsx Outdated
// Compute messages and search match count
const messages =
chatSessionStore.isSearchMode && chatSessionStore.searchQuery.trim()
? chatSessionStore.filteredSessionMessages

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Blocking — swapping the messages prop breaks derivations that treat it as conversation truth.

ChatView.messages isn't just the render list. Filtering it also changes:

  • ChatView.tsx:810newestMessageId = messages[0].id, the active-vs-persisted predicate
  • ChatView.tsx:1104-1119 — the html-preview soft cap, which counts assistant_turn rows
  • BannerRow.tsx:39messages.find(m => m.type === 'assistant_turn'), so the context-limit / heavy-talent banners go dead during search
  • ChatView.tsx:998 + 912-932messages.length === 0 renders ListEmptyComponent
  • ChatView.tsx:1184 — the same condition floats the pal's suggested-prompts row, whose onSelect sends a message

The last two are the visible one. Searching a word that only exists in an assistant reply gives "No results" and replaces the conversation with the new-chat empty state — it reads as though the chat was deleted:

no results

(This sim had no model downloaded, hence the "No Models Available" variant; with a model you'd get the greeting bubble and suggested prompts instead.)

Suggestion: keep messages unfiltered and apply the search at the row-render layer, or give search its own results mode with its own empty state and the composer suppressed. The composer staying live is its own trap — you can send a message during search and it'll never appear, because the reply is an assistant_turn and gets filtered out.

() => ({
...createTagsStyles(theme),
mark: {
backgroundColor: theme.colors.tertiaryContainer,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The highlight background is invisible in the light theme.

Measured from the screenshots below (sampled pixels, not estimates):

theme mark background vs bubble background contrast
light #F1F3FF #F2F2F2 1.01:1
dark #016665 #212121 2.37:1

Same query, same message, light then dark:

light
dark

Dark is a proper highlight. In light the only cue is the text colour shifting to #013332 against normal #111111 — 1.37:1, a hue shift you can find if you already know where to look, but not something the eye can scan to. Worth being precise: it isn't literally nothing in light mode, but it doesn't function as a highlight.

tertiaryContainer is a container token, not a highlight token, and it happens to be near-white in light. Dedicated searchHighlight / onSearchHighlight tokens clearing ~3:1 in both themes would fix it.

Minor: borderRadius isn't honoured on nested RN Text, so it's inert here.

if (entity) {
return entity;
}
return plain.replace(matchRegex, '<mark>$1</mark>');

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Queries containing ', &, < or > are counted but never highlighted.

The filter in the store matches the raw markdown, while this runs on marked() output, where those characters are already escaped. The entity guard above then skips them by design, so the two can't agree.

don't is the everyday case — marked turns it into don&#39;t. The message is listed and the count says 1, but nothing is highlighted:

apostrophe

Confirmed by pixel-sampling both captures: the mark background #F1F3FF is present in the gradient screenshot and absent everywhere in this one.

Same class of mismatch applies to markdown syntax and to matches that only exist inside a code block.

Fix is either decoding entities before matching and re-escaping on emit, or making the store filter operate on the same text this function sees. Either way the count and the highlight should come from one definition of "match".

Note the test never highlights inside HTML entities currently locks the behaviour in, so it'd need to move with the fix.

(_full, tag: string, text: string) => {
if (tag) {
const lower = tag.toLowerCase();
if (/^<(pre|code)[\s/>]/.test(lower)) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

A self-closing <code/> leaks codeDepth and disables highlighting for the rest of the message.

/^<(pre|code)[\s/>]/ matches <code/>, which increments the depth with no closing tag to unwind it. marked.use({}) has no sanitiser, so raw HTML from a model reaches this string:

highlightSearchMatches('<p><code/>hello world and more hello</p>', 'hello')
// → unchanged, no marks at all

highlightSearchMatches('<p><span class="code">hello</span></p>', 'hello')
// → '…<mark>hello</mark>…'   (control: the guard is otherwise fine)

Anchoring both branches fixes it, e.g. /^<(pre|code)(\s[^>]*)?>$/.

Credit where due: I tried to break this function on attribute values, <br/>, nested <pre><code class=…>, uppercase <CODE>, and an unbalanced </code>, and it handled all of them correctly. This is the only case that got through.

// Instead, preserve global settings as they are
this.exitEditMode();
// Search is a per-session view; don't carry it into a new chat.
this.isSearchMode = false;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

createNewSession is the one session-switch path without this reset.

This reset and the matching one in setActiveSession cover their paths, but createNewSession (line ~621) writes activeSessionId without clearing search state. It's reachable one tap from the new search icon: header ⋮ → Duplicate → duplicateSessioncreateNewSession. The new session opens still filtered by the previous query.

Calling the existing exitSearchMode() from all three writers would cover it and remove the duplicated inline reset.

@a-ghorbani

Copy link
Copy Markdown
Owner

Remaining review notes (non-visual)

Follow-up to the inline review. Nothing here is reproducible in a screenshot, so it's grouped rather than anchored to lines.

Performance — worth handling together with the assistant_turn fix

SearchQueryContext.Provider sits above ChatView, so its value changes on every keystroke. A useContext read isn't gated by React.memo, which means MarkdownView's memo — the barrier that currently keeps HTML re-parsing out of the chat's hot path, as its own docstring says — is bypassed. Each keystroke invalidates htmlContent, produces a new source object, and react-native-render-html rebuilds the transient render tree for every mounted markdown view.

Measured against this branch's own node_modules (Node/V8, 40 mounted views × ~2.6 KB HTML): buildTTree 10.6 ms and the highlight regex 1.2 ms per keystroke, excluding React reconciliation and native view creation. Hermes on a mid-range Android device will be meaningfully worse — I haven't profiled on-device, so treat the multiplier as unverified.

ChatView.tsx:987-1030 sets no windowSize and no removeClippedSubviews (RN's default is 21 viewport heights), and Message.tsx:257-300 renders one MarkdownView per step of an assistant_turn, so mounted instances exceed visible rows.

The reason typing feels fine today is the type === 'text' bug: assistant bodies never enter the filtered list, so the large HTML never gets re-parsed. Fixing that removes the accidental mitigation.

Two changes, either of which helps a lot:

  • Debounce the commit to the store (~200 ms) and keep the raw text in local component state so the input echoes immediately. The repo already does this in four places — ExpandableSearch.tsx:60, HFModelSearch.tsx:35, RemoteModelSheet.tsx:177, SettingsScreen.tsx:118; lodash is already a dependency. Right now the TextInput is fully controlled by the observable, so render latency sits on the character-echo path.
  • Pass the query as a prop down renderTextMessage → TextMessage → MarkdownView instead of through context, which keeps the memo barrier intact and also removes the MarkdownView → utils/index.ts → store import cycle that SearchQueryContext's placement introduces.

Tests

The suite is green (263 suites, 3975 tests) but doesn't pin this feature:

  • No test uses an assistant_turn fixture, which is why the filter bug passes.
  • No MarkdownView test renders with a non-empty query — the entire MarkdownProvider change (mark element model + tagsStyles) could be deleted and the suite would stay green.
  • __mocks__/stores/chatSessionStore.ts:170-173 mocks filteredSessionMessages as a frozen [] rather than deriving from state, unlike the neighbouring selectedCount / allSelected. ChatScreen.test.tsx:158-159 supplies messages by spying that getter, so search tests added there later would pass vacuously.
  • Nothing asserts search state clears on session switch or delete.

Interaction / a11y

  • Android back doesn't exit search. grep -rn "BackHandler" src/ returns nothing (positive control: Keyboard and hitSlop both match). Back backgrounds the app with isSearchMode still set.
  • Match count is a message count rendered as a bare digit, which reads as an occurrence count. The repo already has resultsCount / resultsCountOne with t() in WebSearchResultBubble.tsx:70-71. No live region either — BannerRow.tsx:148-149 sets the precedent.
  • No clear-query affordance. Both existing search bars have one: ExpandableSearch.tsx:99-110, EnhancedSearchBar.tsx:142-153.
  • Results are a dead end — no jump-to-match, no surrounding context, and exiting returns to the bottom of the chat. Worth deciding whether filter-the-list is the intended model at all.
  • Close button is ~26×26 pt with no hitSlop (used in 16 places in this repo). The header toggle has no accessibilityState and reuses the placeholder string as its label.
  • Search inside edit mode silently searches a truncated conversation — currentSessionMessages slices to messages after editingMessageId, and enterSearchMode() doesn't call exitEditMode() the way the other view-reset paths do.

Smaller things

  • chat.noResults duplicates chat.webSearch.noResults's English value three lines apart in en.json. Translators see two identical strings under different keys; a scoped sub-object (as in models.search.noResults) avoids it. Also over .... The en-only workflow is correct — no locale stubs needed.
  • setTimeout(…, 100) for autofocus where the repo uses autoFocus (ExpandableSearch.tsx:95, RenameModal.tsx:59). It races a composer tap and makes the keyboard settle in two visible steps. Neither opening nor closing search manages the keyboard, while openMenu in the same file dismisses it first.
  • returnKeyType="search" with no onSubmitEditing; add spellCheck={false} next to autoCorrect={false} as the other sensitive inputs do (HFTokenSheet.tsx:149-150, SearchProviderKeySheet.tsx:99-100).
  • Component-local StyleSheet.create instead of the styles.ts + createStyles(theme) convention; one-off fontSize: 15; missing RTL textAlign (the commit this branch is based on added exactly that ternary in SearchableSelectSheet/styles.ts:28-30).
  • Comments: the ones at ChatSearchBar.tsx:24 and ChatScreen.tsx:264 restate the code and can go; the two regexes in highlightSearchMatches would read better as named constants than as prose. The highlightSearchMatches JSDoc and the module-scope note in MarkdownProvider.tsx:53-55 are genuinely useful — keep those.

Things I checked that turned out fine

Recording these so they don't get re-raised: the query can't inject markup (the replacement is a literal and $1 is a slice the splitters guarantee contains no < or &); the regex escape is complete and there's no ReDoS (80k < in raw HTML under 1 ms, 24 KB prose 0.2 ms); registering mark doesn't widen the HTML surface, since it's already a default textual model in the render engine; MarkdownProvider doesn't rebuild the render engine per render; filteredSessionMessages doesn't recompute at streaming frequency; setSearchQuery without runInAction is correct under makeAutoObservable; the testIDs are globally unique; and no search state reaches disk.

Also: the en.json prettier failure is pre-existing on main (the byteSizes array at line 993), not something this branch introduced — please don't run prettier --write on that file to "fix" it.


Verification on this branch: tsc --noEmit clean · eslint 0 errors, 7 pre-existing warnings · jest 263 suites / 3975 passed · l10n:validate passed. No native changes, so no pod install or platform builds required. Screenshots came from an iOS 26 simulator with a seeded session, so no model inference was involved.

Generated by PocketPal Dev Team

Search finds text in the active chat session. Matches are highlighted in
place (find-in-page): the full conversation stays visible and a match
count is shown, rather than filtering the message list.

- Toolbar icon toggles a search bar with an auto-focus input.
- Matches are highlighted in message content via <mark> tags, using
  dedicated searchHighlight / onSearchHighlight theme tokens that clear
  ~3:1 against the message background in both light and dark themes.
- Match count (or "No results") is shown as the user types.
- SearchQueryContext passes the query to MarkdownView without prop
  drilling; the `mark` element model + style are registered once on
  MarkdownProvider.

Why find-in-page rather than filtering the message list: ChatView's
`messages` prop is conversation truth — it drives the empty state,
context banners, and the suggested-prompts row. Swapping it for a
filtered list made a zero-result search render the new-chat state, as if
the chat were deleted. Highlighting in place never touches that prop.

Correctness:
- Match count and highlight both derive from `derivedText`, so assistant
  replies (`assistant_turn` rows, whose `text` column is empty) are
  searched via their step content instead of being skipped.
- The highlighter decodes basic HTML entities before matching and
  re-encodes after, so a query containing ' & < or > highlights and
  agrees with the count.
- A self-closing <code/> no longer leaks the code-region guard and
  suppresses highlighting for the rest of a message.
- Search state resets on session create, switch, and reset; opening
  search leaves edit mode so the whole conversation is searched.

Closes a-ghorbani#603
@sakebomb
sakebomb force-pushed the feat/message-search branch from 3838f07 to 35428d5 Compare August 13, 2026 01:34
@sakebomb

Copy link
Copy Markdown
Contributor Author

Thanks — the two blocking bugs and the highlight issues were all reproducible as described. Rebased onto current main (was 37 behind, resolved the ChatScreen.tsx + test-file conflicts) and force-pushed a single commit.

The one design change worth calling out: I switched from filtering the message list to find-in-page — the full conversation stays visible, matches highlight in place, and a count is shown. This is the root fix for blocking #2: ChatView.messages is never swapped, so the empty-state / banners / suggested-prompts derivations stay anchored to the real conversation. It also answers your "worth deciding whether filter-the-list is the intended model at all" — this is the browser Ctrl+F model. (No jump-to-match yet; happy to add prev/next as a follow-up.)

Blocking

  1. Assistant replies never matched — count and highlight now both derive from derivedText, so assistant_turn step content is searched. Guarded by a test seeded with an assistant_turn fixture; reverting to the msg.type === 'text' filter turns it red (mutation-verified).
  2. Filtering broke the chat — fixed at the root by find-in-page (above).
  3. Invisible in light theme — added dedicated searchHighlight / onSearchHighlight tokens (deep amber #C77700 light, bright amber #FFB300 dark, both black text) that clear ~3:1 against the message background. Dropped the inert borderRadius. A render test asserts the token is applied; reverting to tertiaryContainer turns it red (mutation-verified). Exact hues are easy to tune if you want specific ratios.
  4. '&<> counted but not highlighted — the highlighter now decodes the basic entities before matching and re-encodes after, so highlight and count come from one definition of match. Test covers the apostrophe case.
  5. Self-closing <code/> leaked codeDepth — anchored the open/close regexes; a <code/> no longer suppresses the rest of the message. Test covers it.

Should-fix
5. createNewSession didn't reset search — now clears search state (reachable via Duplicate). Also enterSearchMode now calls exitEditMode so search scans the full conversation, not the edit-truncated slice. Tests cover create + session-switch resets.

Also: rewrote the store's Search-mode tests (the old ones used an image fixture and passed vacuously), and made the searchMatchCount store mock derive from state rather than return a frozen [].

Verification: tsc clean · eslint clean · l10n:validate valid · full suite 269 suites / 4186 passed. No native changes.

Deferred (non-blocking, tracked for a follow-up): the perf debounce / query-as-prop change (find-in-page highlights all visible rows, so it's still worth doing), the a11y set (BackHandler, live region, hitSlop, match-count wording), and the minor polish (l10n dedup, autoFocus prop, spellCheck, StyleSheet→createStyles). Called out so they're not lost.

@a-ghorbani

Copy link
Copy Markdown
Owner

Visual evidence — search projection + match navigation (iPhone 17 Pro sim, iOS 26, seeded DB, no inference)

01-entity-regression.png
02-count-highlight-agree.png
03-occurrence-count.png
04-navigation.png
05-menu.png
06-dark-and-within-message.png

Generated by PocketPal Dev Team

@a-ghorbani

Copy link
Copy Markdown
Owner

Re-reviewed the rebased branch on an iPhone 17 Pro simulator (iOS 26) with the conversation seeded straight into SQLite — no model, no inference. Verification below is from those runs, not from reading the diff.

The six fixes hold

I re-ran the mutations that originally caught each one, against the new code. Every guard still bites: reverting the derivedText fix turns 3 tests red, the highlight token 1, the createNewSession reset 1, the <code/> anchor 2. Highlight coverage is byte-identical to the previous captures across both themes (12,265 px / 7,355 px / 4,915 px / 10,324 px). The find-in-page decision was the right call.

Blocking — the entity fix introduced a rendering regression

decodeBasicEntities handles five entities; encodeBasicEntities then re-encodes every & in the text run, including ones it never decoded. marked deliberately passes valid entities through, so &nbsp;, &eacute;, &#8212;, &copy; all get double-escaped.

Same session, same scroll, search bar open in both — only the query differs:

entities

It fires on every text run, not just matching ones — a message containing &mdash; garbles the moment any character is typed, and repairs itself when search closes. It also fires for queries that don't match that message at all.

The cause is the decode-all/encode-all round trip. Matching needs the decoded string; emitting needs the original.

An implementation you can take, if useful

Rather than describe it, I built it: search-projection-and-navigation (one commit on top of this branch, ebb2e181).

It builds a small projection per message — the visible text plus, per visible character, the byte range it occupies in the rendered HTML. Matching runs on the visible text; emitting splices <mark> into the untouched HTML, so bytes outside a match are copied verbatim. An entity it cannot decode costs a missed match instead of a mangled message.

That deletes the tag walking, the codeDepth counter and the encode/decode round trip, and because count and highlight now come from one function they can't disagree:

count and highlight agree

Inline code is highlightable (it renders through the default renderer); fenced code isn't, because CodeRenderer re-reads rawHTML and would print the tag literally.

Behaviour changes worth an explicit decision: the count reports occurrences rather than messages, and a match may span inline markup (hello **world** now matches "hello world", emitted as two runs). The existing "does not match across tag boundaries" test asserted the opposite, so it flips.

occurrence count

Match navigation and menu placement

Since match positions fall out of the projection, prev/next was cheap to add — chevrons plus an n/N indicator, the current match styled distinctly, and the list scrolling to it:

navigation

Search also moves from the chat header into the overflow menu. That unloads a header carrying up to four controls, and it resolves two accessibility problems for free: the icon reused the placeholder string as its label and reported selected: false in both states, so it never announced that tapping again closes search.

menu

dark and within-message

Still open

  • Typing latency. Measured against the composer as a baseline in the same run: at 60 messages of ~3.5 KB, typing in the search field costs +59 ms per keystroke on this branch and +32 ms with the projection. SearchQueryContext sits above ChatView, so useContext bypasses MarkdownView's memo — the debounce or query-as-prop change is still the real fix.
  • Jump lands on the message, not always the occurrence. For a message taller than the viewport the match can sit below the fold behind the composer.
  • testID="menu-button" is used twiceHeaderLeft.tsx:17 and HeaderRight.tsx:179 — so a plain selector picks the drawer. Pre-existing, but it now sits on the path to search.
  • A legacy Text message containing a URL renders through LinkPreview/ParsedText, which never sees a <mark>: counted, never highlighted.
  • Deferred a11y/polish from last round still applies: Android BackHandler, and chat.noResults duplicating chat.webSearch.noResults.

Verification

tsc clean · eslint 0 errors (9 pre-existing warnings) · l10n:validate valid · 270 suites / 4243 passed, 2 skipped · iOS Release build succeeded. No native changes.

Two notes on my own branch, so you can weigh it fairly: an independent review of it found four issues I'd missed (a memory regression, a <pre/> leak, a trim divergence, a spurious entity match) which are fixed and mutation-verified; and a bug that made the field silently drop every keystroke passed the capture spec because screenshots alone can't fail — the spec now asserts the query committed.

Generated by PocketPal Dev Team

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat]: Message search within a conversation

2 participants