Skip to content

perf(mobile): defer picker search filtering with useDeferredValue - #6095

Open
iscekic wants to merge 1 commit into
mainfrom
kwf/janitor-2026-09-12-freestyle-1b41
Open

perf(mobile): defer picker search filtering with useDeferredValue#6095
iscekic wants to merge 1 commit into
mainfrom
kwf/janitor-2026-09-12-freestyle-1b41

Conversation

@iscekic

@iscekic iscekic commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

kwf trial run. This PR came from a workflow trial request, not from a tracked work item. Review it as you would any other PR; the label kwf-trial marks where it came from.

Changelog for users

  • Typing in the model picker no longer blocks the text input on large catalogs.
  • The model list filters to the settled query instead of on every keystroke.
  • Typing in the repository picker no longer blocks the text input on large repository lists.
  • The repository list filters to the settled query, and clearing it restores the grouped sections.
  • A non-matching query still shows the existing "No matches" empty state in both pickers.

Changelog for maintainers

  • Both pickers keep search as the urgent input state and add deferredSearch = useDeferredValue(search) for the list.
  • buildModelPickerRows and filterRepoPickerOptions, plus the derived listItems, now read deferredSearch.
  • The grouped-vs-flat and empty-state decisions also read deferredSearch, so the rendered rows and the empty/grouped state agree.
  • Matching semantics, the modelPickerSlot/repoPickerSlot bridge, the sheet/route structure, icons, and copy are unchanged; no new dependency.
  • The urgent keystroke render relies on React Compiler to reuse the memoized FlatList while deferredSearch is unchanged; check data, renderItem, and keyExtractor stability if list rendering regresses.
  • The new mounted test covers a 300-option catalog: the rows settle on the final query, and no superseded query reaches the list derivation.
  • Review first the deferred wiring in model-picker-content.tsx and repo-picker.tsx; the risk is a decision still reading the urgent search, which would show rows that disagree with the grouped/empty state.

E2E proof

e1-recording.mp4
e2-recording.mp4

[p2] New session → repo field: type a multi-character query into the full repository list; rows filter to the final query, clearing the query restores the grouped sections, the list scrolls, and tapping a… — e2e-mobile-app/p2.png

E2E proof — log excerpts

[p1] New session → model field: type a multi-character query into the session mo -> pass :: android emulator-5554. Scripted p1 passed ("SCENE p1 OK"); device re-run proved filtering ("unfiltered visible model rows: 5" -> "after typing 'laguna' model rows: 2"), scroll ("thinkingmachines/inkling-small:free [37,668][900,705]" appears after swipe up in p1-scroll-after.xml, vs "poolside/laguna-s-2.1:free [37,682][825,719]" before), and tap-select ("after tapping Laguna XS row + Done: ['Laguna XS 2.1 (free)']"); Metro check "mobile.log:0" Unable-to-resolve. No UX-DEFECT observed.
[p3] In each picker type a query that matches nothing; the existing "No matches" -> pass :: android emulator-5554. Scripted p3 MISSed on "SCENE p3 MISS tap 'Home'" (p1's scene left the app on New session); hand-driven from that screen: "SCENE p3 OK", repository picker dump shows 'text="No matches": True' with 'typed query text="zzqqxx": True', and "SCENE p3-model-nomatch OK" with the same No-matches/zzqqxx state for the model picker. Asserted state driven on the populated default e2e account; no UX-DEFECT observed.
[p2] New session → repo field -> pass :: android emulator-5554: scripted scene reached SCENE p2 OK in p2-run.log, whose final digest shows 'Repository: GitHub · iscekic/tax-report-generator' after typing 'generator' and tapping the repo row, proving rows filter to the final query, the list scrolls (two swipes in-scene), and tapping a repo selects it; p2-states.log adds 'SCENE p2ev-empty OK' (No matches empty state for 'zzz'), 'SCENE p2ev-grouped OK' (clearing the query restores the grouped GITHUB sections) and 'SCENE p2ev-filter OK' (digest row 'GitHub iscekic/tax-report-generator'); the earlier scripted MISS was only a start-screen mismatch (p2-scenes.json asserted 'Repository' while the app sat on Home), so the saved p2.replay.js
/home/igor_kilocode_ai/.local/share/kwf/sections/janitor-2026-09-12-freestyle-1b41/e2e-mobile-app/p1-verify.log
== scripted p1 scene ==
SCENE p1 OK
== model catalog filtering (model-id rows in picker) ==
unfiltered visible model rows: 5
after typing 'laguna' model rows: 2
  row: poolside/laguna-s-2.1:free
  row: poolside/laguna-xs-2.1:free
== filtered list scrolls (row positions before -> after swipe up) ==
before swipe up:
  poolside/laguna-s-2.1:free [37,682][825,719]
  nvidia/nemotron-3-ultra-550b-a55b:free [37,1046][900,1083]
after swipe up:
  thinkingmachines/inkling-small:free [37,668][900,705]
  poolside/laguna-xs-2.1:free [37,876][900,913]
== tapping a row selects that model (field label changes) ==
before selection: ['Laguna S 2.1 (free)']
after tapping Laguna XS row + Done: ['Laguna XS 2.1 (free)']
== Metro/Unable-to-resolve regression check (mobile.log, device.log) ==
/home/igor_kilocode_ai/.local/share/kwf/sections/janitor-2026-09-12-freestyle-1b41/e2e-mobile-app/mobile.log:0
/home/igor_kilocode_ai/.local/share/kwf/sections/janitor-2026-09-12-freestyle-1b41/e2e-mobile-app/device.log:0
/home/igor_kilocode_ai/.local/share/kwf/sections/janitor-2026-09-12-freestyle-1b41/e2e-mobile-app/mobile.log
react-test-renderer is deprecated. See https://react.dev/warnings/react-test-renderer
stderr | src/components/agents/model-picker-content.mounted.test.tsx > ModelPickerContent deferred search > shows the existing no-matches empty state for a non-matching query
react-test-renderer is deprecated. See https://react.dev/warnings/react-test-renderer
 ✓  mobile-mounted  src/components/agents/model-picker-content.mounted.test.tsx (2 tests) 72ms
stderr | src/components/agents/picker-search.mounted.test.tsx > 'model' picker centering > centers an empty catalog without nesting a list
react-test-renderer is deprecated. See https://react.dev/warnings/react-test-renderer
stderr | src/components/agents/picker-search.mounted.test.tsx > 'repository' picker centering > keeps the search input and native header mounted when replacing the list
react-test-renderer is deprecated. See https://react.dev/warnings/react-test-renderer
stderr | src/components/agents/picker-search.mounted.test.tsx > 'repository' picker centering > centers an empty catalog without nesting a list
react-test-renderer is deprecated. See https://react.dev/warnings/react-test-renderer
 ✓  mobile-mounted  src/components/agents/picker-search.mounted.test.tsx (4 tests) 40ms
 Test Files  2 passed (2)
      Tests  6 passed (6)
   Start at  02:30:39
   Duration  294ms (transform 28ms, setup 0ms, import 210ms, tests 112ms, environment 0ms)
$ pnpm -w exec oxlint --config apps/mobile/.oxlintrc.i18n.json apps/mobile/src packages/app-shared/src && node ../../tools/i18n/check-catalogs.mjs
(node:35572) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///home/igor_kilocode_ai/.local/share/kwf/wt/janitor-2026-09-12-freestyle-1b41/tools/oxlint/no-literal-copy/index.ts is not spe
/home/igor_kilocode_ai/.local/share/kwf/sections/janitor-2026-09-12-freestyle-1b41/e2e-mobile-app/p3-verify.log
== scripted p3 scene ==
SCENE p3 MISS tap 'Home'
== p3 driven by hand from the New session screen (scripted run left the app there) ==
SCENE p3 OK
== repository picker, query 'zzqqxx' matches nothing ==
renderer state from p3-repo-nomatch.xml:
  text="No matches": True
  typed query text="zzqqxx": True
== model picker, query 'zzqqxx' matches nothing ==
SCENE p3-model-nomatch OK
renderer state from p3-model-nomatch.xml:
  text="No matches": True
  typed query text="zzqqxx": True
/home/igor_kilocode_ai/.local/share/kwf/sections/janitor-2026-09-12-freestyle-1b41/e2e-mobile-app/p2-run.log
android.widget.TextView Auto Efficient tappable [347,721][546,767]
android.widget.TextView Run on tappable [37,857][1045,903]
android.widget.Button Run on: Cloud Agent tappable [37,921][909,1037]
android.widget.TextView Cloud Agent tappable [67,951][844,1007]
android.widget.Button Refresh tappable [928,921][1043,1037]
android.widget.TextView Run `kilo remote` on your computer, or `/remote` in a running CLI session, to control a local kilo process. tappable [36,1056][1044,1130]
android.widget.TextView Repository tappable [37,1176][1045,1222]
android.widget.Button Repository: GitHub · iscekic/tax-report-generator tappable [37,1240][1043,1356]
android.widget.TextView GitHub · iscekic/tax-report-generator tappable [67,1270][978,1326]
android.widget.TextView Connect GitLab tappable [76,1423][1004,1469]
android.widget.TextView Connect GitLab in your browser, then return here to pick a repository. tappable [76,1478][1004,1570]
android.widget.Button Open GitLab tappable [76,1598][870,1714]
android.widget.TextView Open GitLab tappable [412,1632][594,1678]
android.widget.Button Refresh repositories tappable [888,1598][1004,1714]
android.widget.TextView Changes tappable [37,1799][1045,1845]
android.view.View Changes tappable [37,1863][1043,1983]
android.widget.RadioButton Leave changes tappable [46,1872][540,1973]
android.widget.TextView Leave changes tappable [182,1899][403,1945]
android.widget.RadioButton Commit and push tappable [540,1872][1034,1973]
android.widget.TextView Commit and push tappable [656,1899][917,1945]
android.widget.TextView Environment tappable [37,2029][1045,2075]
android.widget.TextView Default environment tappable [37,2093][1045,2139]
android.widget.Button Start session [37,2194][1043,2310]
android.widget.TextView Start session tappable [442,2228][638,2274]
/home/igor_kilocode_ai/.local/share/kwf/sections/janitor-2026-09-12-freestyle-1b41/e2e-mobile-app/p2-states.log
android.widget.TextView Run `kilo remote` on your computer, or `/remote` in a running CLI session, to control a local kilo process. tappable [36,1056][1044,1130]
android.widget.TextView Repository tappable [37,1176][1045,1222]
android.widget.Button Repository: GitHub · iscekic/tax-report-generator tappable [37,1240][1043,1356]
android.widget.TextView GitHub · iscekic/tax-report-generator tappable [67,1270][978,1326]
android.widget.TextView Connect GitLab tappable [76,1423][1004,1469]
android.widget.TextView Connect GitLab in your browser, then return here to pick a repository. tappable [76,1478][1004,1570]
android.widget.Button Open GitLab tappable [76,1598][870,1714]
android.widget.TextView Open GitLab tappable [412,1632][594,1678]
android.widget.Button Refresh repositories tappable [888,1598][1004,1714]
android.widget.TextView Changes tappable [37,1799][1045,1845]
android.view.View Changes tappable [37,1863][1043,1983]
android.widget.RadioButton Leave changes tappable [46,1872][540,1973]
android.widget.TextView Leave changes tappable [182,1899][403,1945]
android.widget.RadioButton Commit and push tappable [540,1872][1034,1973]
android.widget.TextView Commit and push tappable [656,1899][917,1945]
android.widget.TextView Environment tappable [37,2029][1045,2075]
android.widget.TextView Default environment tappable [37,2093][1045,2139]
android.widget.Button Start session [37,2194][1043,2310]
android.widget.TextView Start session tappable [442,2228][638,2274]
android.view.View Select repository tappable [37,246][858,311]
android.widget.Button Done tappable [884,228][1043,329]
android.widget.TextView Done tappable [920,250][1006,306]
android.widget.EditText Search repositories tappable [130,405][1017,480]
android.widget.Button GitHub iscekic/tax-report-generator tappable [0,524][1080,638]
Owner request

Surface: the mobile app (apps/mobile).

Surface: the mobile app (apps/mobile).

Problem

Both picker search surfaces keep the query in React state and use it directly for the expensive local list derivation and a FlatList render, so every keystroke does O(n) filtering and a full list reconciliation synchronously on the JS thread, blocking the text input.

Evidence at current head:

  • apps/mobile/src/components/agents/model-picker-content.tsx:35 const [search, setSearch] = useState('');; :84-87 const rows = useMemo<ModelPickerRow[]>(() => buildModelPickerRows({ models: bridge?.options ?? [], search, favoriteIds }), [bridge, search, favoriteIds]);; :174 onChangeText={setSearch}; :199 <FlatList ... data={rows}.
  • apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx:26 const [search, setSearch] = useState('');; :50-53 const filtered = useMemo(() => filterRepoPickerOptions({ repositories: bridge?.repositories ?? [], search }), [bridge, search]);; :58-77 listItems rebuilt from search; :119 onChangeText={setSearch}; :139 <FlatList ... data={listItems}.

The model picker is populated from the full session model catalog (bridge.options), so its size grows with the CLI/provider catalog; the repository picker is populated from a user's full repository list. On a large catalog/repo set, typing in either picker drops frames.

Primary source for the requested technique: React useDeferredValuehttps://react.dev/reference/react/useDeferredValue (defer updating a slow part of the UI while the input stays urgent). React version here is 19.2.3 (apps/mobile/package.json:108), which supports it.

Requested behavior

Use useDeferredValue to split urgent input state from the value that drives the list, in both pickers:

  • Keep const [search, setSearch] = useState('') as the immediate input state and keep onChangeText={setSearch}.
  • Add const deferredSearch = useDeferredValue(search); and use deferredSearch (not search) for buildModelPickerRows / filterRepoPickerOptions, for the derived listItems, and for the search.trim() grouped-vs-flat and empty-state decisions, so the rendered rows and the empty/grouped state always agree.
  • Make sure the urgent keystroke render does not re-render the list rows: React Compiler is enabled (apps/mobile/app.config.ts:329 reactCompiler: true) so the memoized FlatList element should be reused while deferredSearch is unchanged; if it is not, isolate the list (memoized component or stable data/renderItem/keyExtractor).
  • Do not change the matching semantics, the modelPickerSlot/repoPickerSlot bridge, the sheet/route structure, icons, or any copy.

Measurable benefit

During a rapid multi-character query the filtered-list derivation and the FlatList commit become non-urgent, so React can skip intermediate list renders and keep the text input's render free of the O(n) filter/list work; the list settles on the final query. Metric: filtered-list commits per typing burst (one for the settled query instead of one per keystroke) and flat keystroke-to-echo latency as the option list grows.

Exclusions

  • No changes to Kilo Claw (apps/mobile/src/app//kiloclaw/, apps/mobile/src/components/kiloclaw/, apps/mobile/src/lib/kiloclaw/) or Kilo Chat (apps/mobile/src/components/kilo-chat/, apps/mobile/src/app//(4_chat)/**).
  • No backend/web change, no server-side search, no change to agent-session-filters/use-agent-session-search.
  • No new dependency; JS-only change, both platforms.

Acceptance checks

  1. Static, from apps/mobile/: pnpm format && pnpm typecheck && pnpm lint && pnpm check:unused && pnpm test all pass. The new test must sit at a path matching the mounted glob src/**/*.mounted.test.tsx (apps/mobile/vitest.mounted.config.ts).
  2. Test: add apps/mobile/src/components/agents/model-picker-content.mounted.test.tsx that renders the model picker with a large option list, drives several onChangeText updates in one act, and asserts (a) the rows shown correspond to the final query once the deferred value flushes, and (b) the immediate input state and the deferred list state are wired separately (no superseded query's rows are committed).
  3. Local E2E through the normal workflow (Android emulator/device via the repo's dev runner; do not run pnpm start, the runner owns Metro): record opening the model picker (new-session model field) and the repository picker (new-session repo field), typing a multi-character query in each, and confirming rows filter to the final query and the list still scrolls and taps normally. Provide the recording; screenshots are the fallback only if recording fails. iOS shares the same JS change and must pass the static/build gates.
  4. Regression: no new Metro/Unable to resolve errors; a non-matching query still shows the existing "no matches" empty state in both pickers.

Prerequisites in scope

  • Dev stack and Metro started by the repo runner; Android emulator/device from pnpm dev:mobile:android.
  • A model catalog and/or repository list large enough to exercise filtering (the e2e account or a seeded account); if the default account's lists are too small to show an effect, seed or switch to an account with a larger catalog as part of the scenario.

Surface: the mobile app (apps/mobile).

Surface: the mobile app (apps/mobile).

# Problem
Both picker search surfaces keep the query in React state and use it directly for the expensive local list derivation and a FlatList render, so every keystroke does O(n) filtering and a full list reconciliation synchronously on the JS thread, blocking the text input.

Evidence at current head:
- apps/mobile/src/components/agents/model-picker-content.tsx:35 `const [search, setSearch] = useState('');`; :84-87 `const rows = useMemo<ModelPickerRow[]>(() => buildModelPickerRows({ models: bridge?.options ?? [], search, favoriteIds }), [bridge, search, favoriteIds]);`; :174 `onChangeText={setSearch}`; :199 `<FlatList ... data={rows}`.
- apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx:26 `const [search, setSearch] = useState('');`; :50-53 `const filtered = useMemo(() => filterRepoPickerOptions({ repositories: bridge?.repositories ?? [], search }), [bridge, search]);`; :58-77 `listItems` rebuilt from `search`; :119 `onChangeText={setSearch}`; :139 `<FlatList ... data={listItems}`.

The model picker is populated from the full session model catalog (`bridge.options`), so its size grows with the CLI/provider catalog; the repository picker is populated from a user's full repository list. On a large catalog/repo set, typing in either picker drops frames.

Primary source for the requested technique: React `useDeferredValue` — https://react.dev/reference/react/useDeferredValue (defer updating a slo
@iscekic iscekic added the kwf-janitor-trial Nightly mobile janitor request label Sep 12, 2026
@kilo-code-bot

kilo-code-bot Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Both pickers now split urgent input state from the deferred list value, and every decision that drives the rendered rows, the grouped-vs-flat branch, and the empty state reads deferredSearch, so the rows and empty/grouped state stay consistent. No remaining reader of the urgent search was found in either changed file.

Files Reviewed (3 files)
  • apps/mobile/src/components/agents/model-picker-content.tsx
  • apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx
  • apps/mobile/src/components/agents/model-picker-content.mounted.test.tsx

Reviewed by deepseek-v4.1-flash · Input: 0 · Output: 0 · Cached: 0

Review guidance: REVIEW.md from base branch main

@iscekic iscekic added the human-ready The PR is ready for human review. label Sep 12, 2026
@iscekic
iscekic requested a review from eshurakov September 12, 2026 04:09
@iscekic iscekic self-assigned this Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

human-ready The PR is ready for human review. kwf-janitor-trial Nightly mobile janitor request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant