From c37567a9d4014d3ccb7f4f19c8d8a7890113abbf Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 21 Oct 2026 16:02:17 -0700 Subject: [PATCH 1/5] fix(mobile): invalidate chat file search on authority migration Modern search caches and late replies survive authority migration on the same client object. Own cache, capability decision and inventory by logical generation. --- ...use-mobile-native-chat-file-search.test.ts | 56 +++++++++++++++++++ .../use-mobile-native-chat-file-search.ts | 23 +++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/mobile/src/session/use-mobile-native-chat-file-search.test.ts b/mobile/src/session/use-mobile-native-chat-file-search.test.ts index 725bd5916535..e42a59898403 100644 --- a/mobile/src/session/use-mobile-native-chat-file-search.test.ts +++ b/mobile/src/session/use-mobile-native-chat-file-search.test.ts @@ -178,6 +178,62 @@ describe('useMobileNativeChatFileSearch', () => { expect(state?.nativeChatFilePaths).toEqual(['docs/guide.md']) }) + it.each([false, true])( + 'retires cached queries after authority migration; legacy=%s', + async (legacy) => { + let generation = 1 + const sendRequest = vi.fn(async (method: string) => { + if (legacy && method === 'files.searchPaths') { + return { + id: 'missing', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' } + } + } + return rpcSuccess([generation === 1 ? 'old/apple.ts' : 'new/apple.ts']) + }) + await mount(fakeClient({ sendRequest, getGeneration: () => generation })) + act(() => state?.loadNativeChatFiles('apple')) + await act(async () => { + await vi.advanceTimersByTimeAsync(120) + }) + expect(state?.nativeChatFilePaths).toEqual(['old/apple.ts']) + generation = 2 + act(() => state?.loadNativeChatFiles('apple')) + await act(async () => { + await vi.advanceTimersByTimeAsync(120) + }) + expect(state?.nativeChatFilePaths).toEqual(['new/apple.ts']) + } + ) + + it('rejects an old authority response before it can populate the cache', async () => { + let generation = 1 + let resolveOld: (reply: Awaited>) => void = () => {} + const oldReply = new Promise>>((resolve) => { + resolveOld = resolve + }) + const sendRequest = vi.fn(() => + generation === 1 ? oldReply : Promise.resolve(rpcSuccess(['new/apple.ts'])) + ) + await mount(fakeClient({ sendRequest, getGeneration: () => generation })) + act(() => state?.loadNativeChatFiles('apple')) + await act(async () => { + await vi.advanceTimersByTimeAsync(120) + }) + generation = 2 + await act(async () => { + resolveOld(rpcSuccess(['old/apple.ts'])) + await oldReply + }) + expect(state?.nativeChatFilePaths).toEqual([]) + act(() => state?.loadNativeChatFiles('apple')) + await act(async () => { + await vi.advanceTimersByTimeAsync(120) + }) + expect(state?.nativeChatFilePaths).toEqual(['new/apple.ts']) + }) + it('coalesces overlapping legacy inventory requests on a slow host', async () => { let resolveList: (value: Awaited>) => void = () => {} const listResponse = new Promise>>((resolve) => { diff --git a/mobile/src/session/use-mobile-native-chat-file-search.ts b/mobile/src/session/use-mobile-native-chat-file-search.ts index 8fe58a8ccd60..1173be2f9da2 100644 --- a/mobile/src/session/use-mobile-native-chat-file-search.ts +++ b/mobile/src/session/use-mobile-native-chat-file-search.ts @@ -29,6 +29,7 @@ export function useMobileNativeChatFileSearch(args: { const timerRef = useRef | null>(null) const sequenceRef = useRef(0) const queryCacheRef = useRef(new Map()) + const queryCacheGenerationRef = useRef(null) const searchSupportedRef = useRef(null) const inventory = useRef( new GenerationScopedRequestOwner() @@ -45,6 +46,7 @@ export function useMobileNativeChatFileSearch(args: { useEffect(() => { sequenceRef.current++ queryCacheRef.current.clear() + queryCacheGenerationRef.current = null searchSupportedRef.current = null setNativeChatFilePaths([]) return () => { @@ -63,6 +65,13 @@ export function useMobileNativeChatFileSearch(args: { if (!client) { return } + const generation = client.getGeneration?.() ?? 0 + if (queryCacheGenerationRef.current !== generation) { + queryCacheGenerationRef.current = generation + queryCacheRef.current.clear() + searchSupportedRef.current = null + inventory.reset() + } const normalizedQuery = query.trim().toLowerCase().slice(0, 256) const cached = queryCacheRef.current.get(normalizedQuery) if (cached) { @@ -83,8 +92,12 @@ export function useMobileNativeChatFileSearch(args: { setNativeChatFilePaths([]) timerRef.current = setTimeout(() => { timerRef.current = null + const isCurrentRequest = (): boolean => + mountedRef.current && + sequenceRef.current === sequence && + (client.getGeneration?.() ?? 0) === generation const applyPaths = (paths: string[]): void => { - if (sequenceRef.current !== sequence) { + if (!isCurrentRequest()) { return } queryCacheRef.current.set(normalizedQuery, paths) @@ -105,7 +118,7 @@ export function useMobileNativeChatFileSearch(args: { // reconnect to the same host leaves the files on disk alone, so the physical session // epoch is deliberately not in it. Read once, so a cutover between the two calls below // cannot put one attempt in two scopes. - const inventoryScope: RequestScope = [client, worktreeId, client.getGeneration?.() ?? 0] + const inventoryScope: RequestScope = [client, worktreeId, generation] const held = inventory.read(inventoryScope, WHOLE_WORKSPACE) if (held) { applyPaths(rankSuggestions(held, normalizedQuery, FILE_SEARCH_RESULT_LIMIT)) @@ -126,6 +139,9 @@ export function useMobileNativeChatFileSearch(args: { applyPaths(rankSuggestions(loaded.value, normalizedQuery, FILE_SEARCH_RESULT_LIMIT)) } void (async () => { + if (!isCurrentRequest()) { + return + } if (searchSupportedRef.current === false) { await loadLegacyPaths() return @@ -136,6 +152,9 @@ export function useMobileNativeChatFileSearch(args: { limit: FILE_SEARCH_RESULT_LIMIT }) const accepted = nativeChatFileSearchRead.interpret(response) + if (!isCurrentRequest()) { + return + } if (accepted.accepted) { searchSupportedRef.current = true applyPaths(accepted.value) From 1b8461cb8a17ffb51bfe9776c43bf23e2785f62e Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 21 Oct 2026 16:47:03 -0700 Subject: [PATCH 2/5] test(mobile): record retired file-search requests after reset --- .../goldens/lifecycle-inventory-lifecycle.json | 4 ++-- mobile/src/test-support/rpc-recording/derived-goldens.ts | 7 +++++-- .../rpc-recording/inventory-lifecycle-derivation.test.ts | 9 +++++---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json index d4adb3e24fd3..fbe36b8c1646 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json @@ -186,8 +186,8 @@ { "id": "inventory-lifecycle.reset-before-1:settled", "observation": { - "sender": ["32c9018052ba", "2f1953cc0315"], - "payloads": ["8db0d1234c94", "c9b9d7a9b388"], + "sender": ["32c9018052ba"], + "payloads": ["8db0d1234c94"], "settlements": { "mount": "eb79a9b3682a", "old": "eb79a9b3682a", diff --git a/mobile/src/test-support/rpc-recording/derived-goldens.ts b/mobile/src/test-support/rpc-recording/derived-goldens.ts index 940b04e06456..362c216cb3e8 100644 --- a/mobile/src/test-support/rpc-recording/derived-goldens.ts +++ b/mobile/src/test-support/rpc-recording/derived-goldens.ts @@ -54,10 +54,13 @@ function baseScenario(manifest: readonly RecordingScenario[], id: string): Recor } function omitRetiredInventoryAdmission(scenario: RecordingScenario): RecordingScenario { - if (scenario.id !== 'inventory-lifecycle.unmount-before-1') { + if ( + scenario.id !== 'inventory-lifecycle.unmount-before-1' && + scenario.id !== 'inventory-lifecycle.reset-before-1' + ) { return scenario } - // A late search refusal cannot start an inventory after this owner's unmount. + // A late search refusal cannot start an inventory after its owner retires. return { ...scenario, steps: scenario.steps.filter( diff --git a/mobile/src/test-support/rpc-recording/inventory-lifecycle-derivation.test.ts b/mobile/src/test-support/rpc-recording/inventory-lifecycle-derivation.test.ts index 9cf6c5947bce..dc0a653a8e26 100644 --- a/mobile/src/test-support/rpc-recording/inventory-lifecycle-derivation.test.ts +++ b/mobile/src/test-support/rpc-recording/inventory-lifecycle-derivation.test.ts @@ -5,7 +5,7 @@ import { hoistPreludeCheckpoints } from './prelude-checkpoints' import { readScenarios } from './scenario-input' import { bindCompletions, lifecycleSchedules } from './schedule-driver' -it('omits only the inventory admission following a refusal to a fully unmounted owner', () => { +it('omits only inventory admission following a refusal to a retired owner', () => { const manifest = readScenarios( resolve(import.meta.dirname, '../../../rpc-foundation/pilot-scenarios.json') ) @@ -27,12 +27,13 @@ it('omits only the inventory admission following a refusal to a fully unmounted expect(derived.map((scenario) => scenario.id)).toEqual(original.map((scenario) => scenario.id)) expect(derived).toHaveLength(12) for (const [index, scenario] of original.entries()) { - if (scenario.id !== 'inventory-lifecycle.unmount-before-1') { + const unmounted = scenario.id === 'inventory-lifecycle.unmount-before-1' + if (!unmounted && scenario.id !== 'inventory-lifecycle.reset-before-1') { expect(derived[index]).toEqual(scenario) expect(JSON.stringify(derived[index])).toBe(JSON.stringify(scenario)) continue } - expect(scenario.steps).toHaveLength(13) + expect(scenario.steps).toHaveLength(unmounted ? 13 : 11) expect(scenario.steps.slice(7, 10)).toEqual([ { bind: 'old-inventory', request: 'files.list#1', params: { worktree: 'id:A' } }, { bind: 'lifecycle-old-inventory', request: 'old-inventory', params: { worktree: 'id:A' } }, @@ -51,7 +52,7 @@ it('omits only the inventory admission following a refusal to a fully unmounted expect(expected.steps.filter((step) => 'checkpoint' in step)).toEqual([ { checkpoint: 'lifecycle-boundary' }, { checkpoint: 'settled' }, - { checkpoint: 'remounted' } + ...(unmounted ? [{ checkpoint: 'remounted' }] : []) ]) expect(expected.steps.filter((step) => 'bind' in step)).toEqual([ { From 3f6d4c15de8c937e6e06327963ee77ce179125f7 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 21 Oct 2026 16:54:05 -0700 Subject: [PATCH 3/5] refactor(browser): simplify address bar callbacks within lint limit --- .../assemble-chrome/BrowserAddressBar.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx index 35fdd3b27bf7..f84fdebac6b4 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx @@ -233,8 +233,7 @@ export default function BrowserAddressBar({ // cycle can cause a transient blur on the input. Without this guard the // popover opens on focus, immediately gets a blur, and closes ~150ms later // — producing the "flash then disappear" on first click. - const elapsed = Date.now() - openedAtRef.current - const grace = elapsed < 400 + const grace = Date.now() - openedAtRef.current < 400 if (blurCloseTimerRef.current !== null) { window.clearTimeout(blurCloseTimerRef.current) } @@ -460,12 +459,8 @@ export default function BrowserAddressBar({ align="start" sideOffset={4} className="w-[var(--radix-popover-trigger-width)] p-0" - onOpenAutoFocus={(e) => { - // Why: prevent the popover from stealing focus away from the - // address bar input. The user is still typing; the popover is - // an overlay of suggestions, not a focus target. - e.preventDefault() - }} + // Keep focus in the address bar while suggestions open. + onOpenAutoFocus={(e) => e.preventDefault()} > Date: Wed, 21 Oct 2026 16:58:14 -0700 Subject: [PATCH 4/5] style(browser): keep client-hosted pane within line limit --- .../browser-pane/ClientHostedBrowserPagePane.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx index a12e4fe05949..decd819bcb99 100644 --- a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx +++ b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx @@ -146,10 +146,7 @@ export function ClientHostedBrowserPagePane({ // Why: the tour points at controls that cannot work yet, and recording the interaction is a // one-way write that would burn the tour on a pane the user has not really seen. useClientHostedBrowserIntroTour(isActive && !attachmentError && placement !== null) - useBrowserPageFindShortcuts({ - ...shortcutOwner, - setFindOpen - }) + useBrowserPageFindShortcuts({ ...shortcutOwner, setFindOpen }) useBrowserPageWebviewShortcuts({ ...shortcutOwner, isActiveRef, @@ -241,9 +238,7 @@ export function ClientHostedBrowserPagePane({ const activeLoadFailure = activeLoadFailureRef.current // URL writes clear certificate challenges, so preserve them while a failure stands. if (!activeLoadFailure) { - setUrlFromGuest(browserTab.id, metadata.url, { - preserveLoadError: true - }) + setUrlFromGuest(browserTab.id, metadata.url, { preserveLoadError: true }) } updatePageStateFromGuest(browserTab.id, { title: metadata.title, From c32fe39b9a1562303031d2e603874b54cd5b111e Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 21 Oct 2026 17:03:21 -0700 Subject: [PATCH 5/5] fix(i18n): retire obsolete Portuguese default zoom entries --- src/renderer/src/i18n/locales/pt.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 0c8d3a52d1d9..6af340c87fe6 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -8163,8 +8163,6 @@ "5f5142a62a": "Ícone anterior" }, "BrowserDefaultZoomSetting": { - "2622126877": "Nível de zoom aplicado às abas de navegador recém-abertas.", - "bbeec087d3": "Aplicado às abas de navegador recém-abertas.", "265597101f": "Zoom Padrão" }, "BrowserUseComputerUseNotice": {