Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions mobile/src/session/use-mobile-native-chat-file-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<RpcClient['sendRequest']>>) => void = () => {}
const oldReply = new Promise<Awaited<ReturnType<RpcClient['sendRequest']>>>((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<ReturnType<RpcClient['sendRequest']>>) => void = () => {}
const listResponse = new Promise<Awaited<ReturnType<RpcClient['sendRequest']>>>((resolve) => {
Expand Down
23 changes: 21 additions & 2 deletions mobile/src/session/use-mobile-native-chat-file-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function useMobileNativeChatFileSearch(args: {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sequenceRef = useRef(0)
const queryCacheRef = useRef(new Map<string, string[]>())
const queryCacheGenerationRef = useRef<number | null>(null)
const searchSupportedRef = useRef<boolean | null>(null)
const inventory = useRef(
new GenerationScopedRequestOwner<WorkspaceInventoryParameters, string[]>()
Expand All @@ -45,6 +46,7 @@ export function useMobileNativeChatFileSearch(args: {
useEffect(() => {
sequenceRef.current++
queryCacheRef.current.clear()
queryCacheGenerationRef.current = null
searchSupportedRef.current = null
setNativeChatFilePaths([])
return () => {
Expand All @@ -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()
}
Comment on lines +69 to +74

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
# Inspect search callers and migration handling without running repository code.
rg -n -C 4 'loadNativeChatFiles\s*\(|nativeChatFilePaths\b|migrateTo\s*\(' mobile/src

Repository: stablyai/orca

Length of output: 41797


🏁 Script executed:

set -u
printf '%s\n' '--- hook ---'
nl -ba mobile/src/session/use-mobile-native-chat-file-search.ts | sed -n '1,230p'
printf '%s\n' '--- overlay ---'
nl -ba mobile/src/session/MobileNativeChatOverlay.tsx | sed -n '130,230p'
printf '%s\n' '--- file UI consumers ---'
rg -n -C 8 -F -- 'onNeedFiles' mobile/src/session mobile/src/components || test "$?" -eq 1
printf '%s\n' '--- client generation declarations and uses ---'
rg -n -C 6 -e 'getGeneration' -e 'generation' -e 'authority' mobile/src/transport mobile/src/session | head -n 260

Repository: stablyai/orca

Length of output: 42214


Clear displayed paths when authority changes.

The hook resets displayed paths only when client or worktreeId changes, or when loadNativeChatFiles runs. A migration that keeps the same client object and worktree changes only client.getGeneration(), so existing nativeChatFilePaths can remain displayed if no new file query runs. Clear the paths on logical-generation changes or trigger a fresh search.

const normalizedQuery = query.trim().toLowerCase().slice(0, 256)
const cached = queryCacheRef.current.get(normalizedQuery)
if (cached) {
Expand All @@ -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)
Expand All @@ -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))
Expand All @@ -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
Expand All @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions mobile/src/test-support/rpc-recording/derived-goldens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
)
Expand All @@ -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' } },
Expand All @@ -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([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 0 additions & 2 deletions src/renderer/src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading