diff --git a/apps/ai-gateway/AGENTS.md b/apps/ai-gateway/AGENTS.md index ff449a43d01..9429dbb7a00 100644 --- a/apps/ai-gateway/AGENTS.md +++ b/apps/ai-gateway/AGENTS.md @@ -40,3 +40,13 @@ through those paths and that no `apps/web` route is shadowed by a rewrite. `packages/web-shared/src/lib/observability`, registered in `src/instrumentation.ts` with the `kilocode-ai-gateway` service name. The app has no client or Edge runtime code, so it has no client or Edge Sentry config. + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/mobile/.oxlintrc.json b/apps/mobile/.oxlintrc.json index 4d7a8288d43..408f2b6517e 100644 --- a/apps/mobile/.oxlintrc.json +++ b/apps/mobile/.oxlintrc.json @@ -338,6 +338,18 @@ "@typescript-eslint/strict-boolean-expressions": "off", "@typescript-eslint/no-import-type-side-effects": "off", "@typescript-eslint/consistent-type-definitions": ["error", "type"], + "@typescript-eslint/no-deprecated": [ + "error", + { + "allow": [ + { + "from": "package", + "package": "react-native-gesture-handler", + "name": ["Gesture", "Pan"] + } + ] + } + ], "@typescript-eslint/no-namespace": "off", "import/no-namespace": "off", "oxc/no-rest-spread-properties": "off", diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index ba019a7f6cf..eee896833f2 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -9,7 +9,7 @@ Expo Router app for iOS and Android only. Use dev builds, never Expo Go. No web- ## Stack -- Expo SDK 57, React Native 0.86, React 19, strict TypeScript (`tsgo`) +- Expo SDK 58, React Native 0.88, React 19.3, strict TypeScript (`tsgo`) - NativeWind v5 / Tailwind CSS v4; React Native Reusables in `src/components/ui/` - Expo Router routes in `src/app/` - oxlint and oxfmt @@ -52,10 +52,10 @@ git diff --check Before you build a UI element, find the library that already does it. 1. Check the element map below. If the concern has an entry, use that element. -2. Check `@expo/ui` (`node_modules/@expo/ui/build/`), which ships native modules for `57.x`: universal +2. Check `@expo/ui` (`node_modules/@expo/ui/build/`), which ships native modules for `58.x`: universal `BottomSheet`, `Picker`, `Switch`, `TextInput`, `List`, `Host`, plus `community/{segmented-control, picker, datetime-picker, masked-view, menu, pager-view, slider}`. -3. Check the Expo SDK 57 docs for the concern. +3. Check the Expo SDK 58 docs for the concern. 4. Check npm for a maintained package. A release in the last six months is the bar. A hand-built element is correct only when steps 1–4 fail. Record the decision in the PR body: the library diff --git a/apps/mobile/README.md b/apps/mobile/README.md index de5e978cfff..2cf65633125 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -29,6 +29,33 @@ Cached settings remain visible during offline pauses and refresh failures. Generally speaking, you only need a new dev build if making dependency/native changes. +The app uses Expo SDK 58, React Native 0.88 and React 19.3. Align SDK-managed +dependencies with the selected release's `bundledNativeModules.json`, and retain +the workspace release-age policy when resolving upgrades. Existing development-tool +exemptions remain separate from the SDK's release eligibility. + +Native compatibility requirements: + +- Keep versioned dependency patches and their lockfile hashes together. Regenerate + them against pristine package sources, and rebuild both development clients. +- Android builds the patched background-task, notifications and task-manager modules + from source, together with task-manager's app loader. +- `android.uniquePackageNames=false` retains compatibility with AppsFlyer's SDK and + purchase connector, which share a namespace. AGP 9 otherwise rejects that vendor pair. +- The supported Gesture Handler builder API remains explicitly allowed by the + deprecation lint rule; other deprecated APIs remain checked. +- The `react-native-css` patch forwards React Native's native component registry + through its CommonJS facade for Router's native screen host. +- The `llama.rn` iOS patch installs JSI bindings through React Native's TurboModule + runtime hook and conforms to the generated native spec. +- The iOS App Actions bridge uses `@JS` for Expo's noncopyable JavaScript values. + Its runtime-owned reference releases the dispatcher on the JavaScript thread. +- Root React types stay aligned with the root/web runtime; mobile declares its own + SDK-matched types. Keep both explicit so cached dependency hoisting cannot mix + the web and native JSX type identities. +- Deferred cache maintenance keeps a 500 ms timer fallback alongside idle work. + The first delivery cancels the other so invalidation runs once during sustained JS work. + Native permission translations live in `plugins/permission-prompt-copy.json` for all 87 supported languages. The camera and photo library prompts explain AI agent attachments and include specific examples. Background location descriptions remain disabled because the app does not request background location. @@ -57,21 +84,20 @@ Changes require a new iOS build; an over-the-air update cannot change the native #### iPhone Duo and iOS 27 - Use Xcode 27.1 or later to test native iPhone Duo layouts. -- Keep `expo-build-properties.ios.enableSceneSupport` enabled in `app.config.ts` while the app uses Expo SDK 57. +- Expo SDK 58 includes scene lifecycle support in its template; no scene-support opt-in is needed. - Expo's scene delegate creates the window and forwards lifecycle events and links to the app delegate. - After a native config change, regenerate the iOS project and rebuild the app. - Do not edit the generated `ios/` files. - Sign local simulator builds with an Apple Development identity and its correct team ID. - Unsigned builds cannot restore the local account from the keychain on this simulator. - Check both displays, all three fold poses, and each orientation after the rebuild. -- When the app moves to Expo SDK 58, remove the scene support option; its template includes scene support. #### Widget layout checks - Home widgets keep a 16pt band on all four sides, including the updating placeholder. - Check the medium placeholder on a 164pt-tall card: its header, bars and footer must fit within 132pt or SwiftUI centres the overflow into the padding. - Arabic Live Activity direction comes from `homeWidgetLocale` in the shared app group, not the simulator language. -- The Live Activity direction override is native code in `patches/expo-widgets@57.0.22.patch`. Rebuild the iOS extension after changing the patch; restarting Metro alone leaves the installed extension unchanged. +- The Live Activity direction override is native code in `patches/expo-widgets@58.0.17.patch`. Rebuild the iOS extension after changing the patch; restarting Metro alone leaves the installed extension unchanged. - After switching the app language, reopen the development client on Metro before applying a fixture and capturing widgets. #### Responsive layout checks diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index e6d2ca1b558..ee5eadb2324 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -301,18 +301,11 @@ const config: ExpoConfig = { }, ios: { ccacheEnabled: true, - // SDK 27 requires the scene lifecycle, including scene-based window and link handling. - enableSceneSupport: true, // iOS consumes React Native Core prebuilt by default, so the pnpm patch // over RCTComponentViewFactory.mm would never compile into the app. // The Expo Podfile maps this to ENV['RCT_USE_PREBUILT_RNCORE'] = '0' // (KILO-APP-6H; react/react-native#58299). - // Compile React Native from the patched source instead of linking the - // prebuilt core. The App Store core ships with assertions enabled - // (react/react-native#57454), so the Fabric unmount assert must carry - // the bounds guard from react/react-native#57865 or a stale child - // index SIGABRTs release builds (Kilo-Org/kilocode#14065; see - // patches/react-native@0.86.3.patch). + // Compile the registration and mounting guards in patches/react-native@0.88.0.patch. buildReactNativeFromSource: true, // GoogleSignIn is a Swift static lib that imports GoogleUtilities/RecaptchaInterop // (pulled transitively alongside expo-iap's AppCheckCore); those pods don't define diff --git a/apps/mobile/modules/kilo-app-actions/android/src/main/java/expo/modules/kiloappactions/KiloAppActionsModule.kt b/apps/mobile/modules/kilo-app-actions/android/src/main/java/expo/modules/kiloappactions/KiloAppActionsModule.kt index 4d01b9254a6..8f6b5a16085 100644 --- a/apps/mobile/modules/kilo-app-actions/android/src/main/java/expo/modules/kiloappactions/KiloAppActionsModule.kt +++ b/apps/mobile/modules/kilo-app-actions/android/src/main/java/expo/modules/kiloappactions/KiloAppActionsModule.kt @@ -6,6 +6,7 @@ import expo.modules.kotlin.jni.JavaScriptFunction import expo.modules.kotlin.jni.JavaScriptValue import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.types.ConverterContext /** * The Android half of the shared app-action contract. @@ -26,7 +27,7 @@ class KiloAppActionsModule : Module() { * not lost. JS runs each returned payload through the same dispatcher. */ Function("registerAppActionDispatcher") { handler: JavaScriptFunction -> - AppActionDispatcher.register(handler) { block -> + AppActionDispatcher.register(handler, appContext) { block -> // A JSI function may only be invoked on the JS thread, and an entry point // calls in from the main thread. `appContext.reactContext` is typed as a // plain Android `Context`, so the hop goes through the runtime's @@ -67,6 +68,7 @@ internal object AppActionDispatcher { /** The registered JS dispatcher and the hop that puts a call on its thread. */ private class Registration( val handler: JavaScriptFunction, + val converterContext: ConverterContext, val queue: (() -> Unit) -> Unit ) @@ -79,9 +81,10 @@ internal object AppActionDispatcher { /** Stores the JS dispatcher and returns the payloads that arrived first. */ fun register( next: JavaScriptFunction, + converterContext: ConverterContext, queue: (() -> Unit) -> Unit ): List = synchronized(lock) { - registration = Registration(next, queue) + registration = Registration(next, converterContext, queue) val pending = buffered.toList() buffered.clear() pending @@ -107,7 +110,7 @@ internal object AppActionDispatcher { // captures to Sentry), so a throw here only means this payload produced no // result for the caller. runCatching { - val returned = active.handler(payload) + val returned = active.handler(payload, converterContext = active.converterContext) if (returned.isString()) { complete(payload, returned.getString()) } diff --git a/apps/mobile/modules/kilo-app-actions/ios-app-intents.test.ts b/apps/mobile/modules/kilo-app-actions/ios-app-intents.test.ts index ec7ab4683bd..1b56bd9a818 100644 --- a/apps/mobile/modules/kilo-app-actions/ios-app-intents.test.ts +++ b/apps/mobile/modules/kilo-app-actions/ios-app-intents.test.ts @@ -35,9 +35,6 @@ function readSource(name: string): string { } const intents = readSource('KiloAppIntents.swift'); -const bridge = readSource('KiloAppActionBridge.swift'); -const actionsModule = readSource('KiloAppActionsModule.swift'); -const podspec = readSource('KiloAppActions.podspec'); const configSource = readFileSync( fileURLToPath(new URL('../../app.config.ts', import.meta.url)), 'utf8' @@ -240,100 +237,6 @@ describe('the Swift action URLs', () => { }); }); -describe('the native handshake', () => { - it('registers the JS dispatcher under the module name the JS side requires', () => { - // A synchronous `Function` runs on the JS thread. An `AsyncFunction` runs on - // a background queue, where replacing the dispatcher crashed (KILO-APP-3JG). - expect(actionsModule).toContain('Function("registerAppActionDispatcher")'); - expect(actionsModule).not.toContain('AsyncFunction("registerAppActionDispatcher")'); - expect(actionsModule).toContain('JavaScriptActor.assumeIsolated'); - expect(actionsModule).toContain('Name("KiloAppActions")'); - expect(actionsModule).toContain('KiloAppActionBridge.shared'); - expect(bridge).toContain('static let shared = KiloAppActionBridge()'); - }); - - it('bounds the wait for registration and reports a failure', () => { - expect(bridge).toContain('waitUntilRegistered'); - expect(bridge).toMatch(/registrationTimeout: TimeInterval = 25/); - expect(bridge).toContain('KiloAppActionError.dispatcherUnavailable'); - expect(bridge).toContain('func perform(payload: [String: String]) async throws -> String'); - }); - - it('resumes a parked wait from register instead of polling for one', () => { - // The old wait re-checked `registered()` and slept 50 ms in a loop for up to - // 25 s — up to 500 wakeups for a value `register(...)` can hand over. The - // bridge now parks each waiting run's continuation and resumes it from the - // registration; one deadline task is the only sleep left. - expect(bridge, 'the poll interval is gone').not.toContain('registrationPollInterval'); - expect(bridge, 'no continuation store').toContain( - 'waiters: [UUID: CheckedContinuation]' - ); - - const wait = swiftBlock(bridge, 'func waitUntilRegistered'); - expect(wait).toContain('withCheckedThrowingContinuation'); - expect(wait, 'the wait must not sleep in a loop').not.toContain('Task.sleep'); - - // `register(...)` stores the registration and hands it to every parked waiter. - const register = swiftBlock(bridge, 'func register('); - expect(register).toMatch(/waiters = \[:\]/); - expect(register).toContain('.resume(returning: next)'); - - // The one deadline task fails the waiter that outlived registration. - expect([...bridge.matchAll(/Task\.sleep/g)], 'one sleep, the deadline').toHaveLength(1); - const deadline = swiftBlock(bridge, 'func startDeadline'); - expect(deadline).toContain('Task.sleep'); - expect(deadline).toContain('failWaiter(id, seconds: Int(timeout))'); - expect(swiftBlock(bridge, 'func failWaiter')).toContain( - 'KiloAppActionError.dispatcherUnavailable(seconds: seconds)' - ); - }); - - it('marks the throwing runtime lookup with try', () => { - // `AppContext.runtime` is a throwing property: it raises `RuntimeLost` until - // the runtime exists, so an unmarked read stops the pod compiling. The ios - // job failed on exactly that — `KiloAppActionsModule.swift:19:27: error: - // property access can throw but is not marked with 'try'` — and this guard - // is where the module reads it. - const reads = actionsModule - .split('\n') - .filter(line => /appContext\?\.runtime|appContext\.runtime/.test(line)); - expect(reads.length, 'the module no longer reads the runtime').toBeGreaterThan(0); - for (const line of reads) { - expect(line, `${line.trim()} reads a throwing property`).toMatch(/\btry\b/); - } - }); - - it('lets the runtime teardown release the dispatcher on the JS thread', () => { - // The singleton outlives the runtime. On a reload (the RTL language switch), - // the module's `OnDestroy` runs off the JS thread, maybe after the runtime is - // freed, so it must not release a JSI value (KILO-APP-3JG). The runtime's - // long-lived objects own the dispatcher, and their teardown sweep releases it - // on the JS thread while the runtime is still valid. - expect(actionsModule).not.toContain('OnDestroy'); - expect(bridge).toContain('private final class DispatcherRegistration: LongLivedObject'); - expect(swiftBlock(bridge, 'func register(')).toContain('runtime.longLivedObjects.add(next)'); - const release = swiftBlock(bridge, 'func allowRelease()'); - expect(release).toContain('dispatcher = nil'); - expect(release).toContain('KiloAppActionBridge.shared.unregister(self)'); - // A later runtime's registration survives the earlier runtime's sweep. - expect(swiftBlock(bridge, 'func unregister(')).toContain('guard registration === released'); - }); - - it('reads the result fields the JS contract answers with', () => { - // The `AppActionResult` fields of src/lib/app-actions/app-action-contract.ts: - // a renamed field here would silently turn every run into a malformed result. - for (const field of ['ok', 'sessionId', 'message', 'retryable']) { - expect(bridge, `${field} is not read from the answer`).toContain(`getProperty("${field}")`); - } - }); - - it('ships the pod the module config autolinks', () => { - expect(podspec).toContain("s.name = 'KiloAppActions'"); - expect(podspec).toContain("s.platforms = { :ios => '16.4' }"); - expect(podspec).toContain("s.source_files = '**/*.swift'"); - }); -}); - describe('the App Intent copy', () => { it('holds exactly the keys the plugin renders', () => { expect(Object.keys(ENGLISH_COPY).toSorted()).toEqual([...INTENT_COPY_KEYS].toSorted()); diff --git a/apps/mobile/modules/kilo-app-actions/ios/KiloAppActionBridge.swift b/apps/mobile/modules/kilo-app-actions/ios/KiloAppActionBridge.swift index 8ff14ab98a9..b0ba5aa16ba 100644 --- a/apps/mobile/modules/kilo-app-actions/ios/KiloAppActionBridge.swift +++ b/apps/mobile/modules/kilo-app-actions/ios/KiloAppActionBridge.swift @@ -67,13 +67,13 @@ final class KiloAppActionBridge: @unchecked Sendable { /// waiting. A late registration resumes its callers directly instead of them /// re-checking on a timer. /// - /// Runs on the JS thread of `runtime` (the module's synchronous `Function`), + /// Runs on the JS thread of `runtime` (the module's synchronous `@JS` method), /// because it adds the registration to `runtime.longLivedObjects` and can /// release a replaced dispatcher of the same runtime. A replaced registration /// of an earlier runtime is not released here: that runtime's teardown sweep /// owns it, and on reload it already ran, before this runtime started. @JavaScriptActor - func register(dispatcher: JavaScriptValue, runtime: JavaScriptRuntime) { + func register(dispatcher: consuming JavaScriptValue, runtime: JavaScriptRuntime) { let next = DispatcherRegistration(dispatcher: dispatcher, runtime: runtime) runtime.longLivedObjects.add(next) lock.lock() @@ -209,7 +209,7 @@ final class KiloAppActionBridge: @unchecked Sendable { /// The dispatcher may answer with the result or with a promise of it; both /// are accepted, and a rejected promise propagates its JS error. @JavaScriptActor - private static func resolved(_ answer: JavaScriptValue) async throws -> JavaScriptValue { + private static func resolved(_ answer: consuming JavaScriptValue) async throws -> JavaScriptValue { do { return try await answer.asPromise().await() } catch is JavaScriptValue.TypeError { @@ -220,7 +220,8 @@ final class KiloAppActionBridge: @unchecked Sendable { /// The caller-facing result: the session id a `StartAgent` run created. /// A refusal throws with the pipeline's message. - private static func result(from answer: JavaScriptValue) throws -> String { + @JavaScriptActor + private static func result(from answer: borrowing JavaScriptValue) throws -> String { let decoded = try decode(answer) guard let ok = decoded.ok else { throw KiloAppActionError.malformedResult @@ -242,7 +243,8 @@ final class KiloAppActionBridge: @unchecked Sendable { /// Reads the answer as the `AppActionResult` object, or as its JSON text — /// the two forms the contract accepts. - private static func decode(_ answer: JavaScriptValue) throws -> Answer { + @JavaScriptActor + private static func decode(_ answer: borrowing JavaScriptValue) throws -> Answer { if answer.isString() { guard let data = answer.getString().data(using: .utf8) else { throw KiloAppActionError.malformedResult @@ -291,10 +293,10 @@ final class KiloAppActionBridge: @unchecked Sendable { @JavaScriptActor private final class DispatcherRegistration: LongLivedObject { nonisolated let runtime: JavaScriptRuntime - private var dispatcher: JavaScriptValue? + private let dispatcher: JavaScriptValue.Ref - init(dispatcher: JavaScriptValue, runtime: JavaScriptRuntime) { - self.dispatcher = dispatcher + init(dispatcher: consuming JavaScriptValue, runtime: JavaScriptRuntime) { + self.dispatcher = dispatcher.ref() self.runtime = runtime } @@ -302,18 +304,18 @@ private final class DispatcherRegistration: LongLivedObject { /// kept past this call, so a teardown while the answer is pending still /// releases the dispatcher on time. func call(payload: [String: String]) throws -> JavaScriptValue { - guard let dispatcher else { + guard let answer = try dispatcher.withUnwrappedValue({ (dispatcher: borrowing JavaScriptValue) in + try dispatcher.getFunction().call(arguments: payload) + }) else { throw KiloAppActionError.dispatcherUnavailable( seconds: Int(KiloAppActionBridge.registrationTimeout) ) } - // `JavaScriptValue` only holds the reference; the callable form is its - // function view. Same idiom as ExpoModulesJSI's own promise resolvers. - return try dispatcher.getFunction().call(arguments: payload) + return answer } func allowRelease() { - dispatcher = nil + dispatcher.release() KiloAppActionBridge.shared.unregister(self) } } diff --git a/apps/mobile/modules/kilo-app-actions/ios/KiloAppActionsModule.swift b/apps/mobile/modules/kilo-app-actions/ios/KiloAppActionsModule.swift index 26b8dd03c5b..194cda5c9ec 100644 --- a/apps/mobile/modules/kilo-app-actions/ios/KiloAppActionsModule.swift +++ b/apps/mobile/modules/kilo-app-actions/ios/KiloAppActionsModule.swift @@ -1,38 +1,15 @@ import ExpoModulesCore -/// The Expo module the JS side names `KiloAppActions`. -/// -/// It exists for one handshake: the JS action pipeline registers the dispatcher -/// that performs the four actions, and the native entry points (the App Intents -/// on this platform) reach it through `KiloAppActionBridge`. No action logic -/// lives here, and the bridge is a singleton rather than module state because -/// an App Intent can run before, or without, a module instance. +@ExpoModule("KiloAppActions") public final class KiloAppActionsModule: Module { - public func definition() -> ModuleDefinition { - Name("KiloAppActions") - - /// Registers the JS handler that runs an action payload and answers with - /// the `AppActionResult`. The return value is the payloads that arrived - /// before this call, so the pipeline can run them; on iOS it is always - /// empty, because a native run waits for registration instead of parking. - /// - /// A synchronous `Function`, like Android's, so it runs on the JS thread: - /// the bridge hands the dispatcher to the runtime's long-lived objects, - /// and the runtime's teardown releases it there. An `AsyncFunction` runs - /// on a background queue, where replacing the previous dispatcher - /// destroyed a JSI value of a runtime that a reload had already freed. - Function("registerAppActionDispatcher") { (dispatcher: JavaScriptValue) throws -> [[String: String]] in - // `AppContext.runtime` is a throwing property: it raises `RuntimeLost` - // until the runtime exists, which is the same condition as the module - // having no app context at all. - guard let appContext = self.appContext, let runtime = try? appContext.runtime else { - throw ActionDispatcherNotReadyException() - } - JavaScriptActor.assumeIsolated { - KiloAppActionBridge.shared.register(dispatcher: dispatcher, runtime: runtime) - } - return KiloAppActionBridge.shared.drainParkedPayloads() + @JS + @JavaScriptActor + func registerAppActionDispatcher(dispatcher: consuming JavaScriptValue) throws -> [[String: String]] { + guard let appContext, let runtime = try? appContext.runtime else { + throw ActionDispatcherNotReadyException() } + KiloAppActionBridge.shared.register(dispatcher: dispatcher, runtime: runtime) + return KiloAppActionBridge.shared.drainParkedPayloads() } } diff --git a/apps/mobile/modules/kilo-app-actions/platform-parity.test.ts b/apps/mobile/modules/kilo-app-actions/platform-parity.test.ts index 6e7f0491716..969cc476f51 100644 --- a/apps/mobile/modules/kilo-app-actions/platform-parity.test.ts +++ b/apps/mobile/modules/kilo-app-actions/platform-parity.test.ts @@ -250,30 +250,6 @@ describe('the same native identifiers on both platforms', () => { }); describe('one code path on both platforms', () => { - it('registers the one shared JS dispatcher under the same module name', () => { - // `registerAppActionDispatcher` (s3) is the only dispatcher: each - // platform's Expo module hands the same JS handler to its native entry - // points, and the JS side requires the same module name on both. Both take - // it in a synchronous `Function`, on the JS thread. - for (const source of [iosModuleSource, androidModuleSource]) { - expect(source).toContain('Function("registerAppActionDispatcher")'); - expect(source).not.toContain('AsyncFunction("registerAppActionDispatcher")'); - } - expect(iosModuleSource).toContain('Name("KiloAppActions")'); - expect(androidModuleSource).toContain('Name("KiloAppActions")'); - }); - - it('drops the registered dispatcher when the runtime goes away, on both platforms', () => { - // The bridge/dispatcher outlives the module on both platforms. Android's - // `OnDestroy` drops it; Expo's JNI deallocator frees the JSI function at - // runtime teardown. On iOS the runtime's long-lived objects own it, and - // their teardown sweep releases it on the JS thread. - expect(androidModuleSource).toContain('OnDestroy'); - expect(androidModuleSource).toContain('AppActionDispatcher.clear()'); - expect(bridgeSource).toContain('runtime.longLivedObjects.add(next)'); - expect(bridgeSource).toContain('func allowRelease()'); - }); - it('carries an action URL the contract parses in every literal of either tree', () => { for (const { file, literal, slug } of [...iosLiterals, ...androidLiterals]) { expect( diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 29f71b22851..f0431a182d3 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -28,9 +28,9 @@ "dependencies": { "@bsky.app/react-native-uitextview": "2.7.1", "@expo-google-fonts/jetbrains-mono": "0.4.1", - "@expo/app-integrity": "57.0.2", + "@expo/app-integrity": "~58.0.2", "@expo/react-native-action-sheet": "4.1.1", - "@expo/ui": "57.0.21", + "@expo/ui": "~58.0.17", "@formatjs/intl-durationformat": "^0.11.1", "@formatjs/intl-listformat": "^8.3.15", "@formatjs/intl-locale": "^5.3.12", @@ -65,51 +65,51 @@ "clsx": "2.1.1", "drizzle-orm": "catalog:", "effect": "3.22.1", - "expo": "~57.0.26", - "expo-apple-authentication": "~57.0.2", - "expo-application": "~57.0.3", - "expo-asset": "~57.0.18", - "expo-audio": "~57.0.5", - "expo-background-task": "57.0.21", - "expo-battery": "~57.0.3", - "expo-blur": "~57.0.3", - "expo-build-properties": "~57.0.22", - "expo-clipboard": "~57.0.2", - "expo-constants": "~57.0.20", - "expo-crypto": "~57.0.3", - "expo-dev-client": "~57.0.19", - "expo-device": "~57.0.2", - "expo-document-picker": "~57.0.3", - "expo-file-system": "~57.0.7", - "expo-font": "57.0.4", - "expo-haptics": "~57.0.3", + "expo": "~58.0.10", + "expo-apple-authentication": "~58.0.2", + "expo-application": "~58.1.1", + "expo-asset": "~58.0.15", + "expo-audio": "~58.0.9", + "expo-background-task": "~58.0.10", + "expo-battery": "~58.0.3", + "expo-blur": "~58.0.3", + "expo-build-properties": "~58.0.10", + "expo-clipboard": "~58.0.3", + "expo-constants": "~58.0.12", + "expo-crypto": "~58.0.6", + "expo-dev-client": "~58.0.13", + "expo-device": "~58.0.5", + "expo-document-picker": "~58.0.4", + "expo-file-system": "~58.0.9", + "expo-font": "~58.0.8", + "expo-haptics": "~58.0.6", "expo-iap": "5.8.2", - "expo-image": "57.0.5", - "expo-image-manipulator": "57.0.20", - "expo-image-picker": "~57.0.20", - "expo-keep-awake": "~57.0.2", - "expo-linear-gradient": "~57.0.2", - "expo-linking": "57.0.11", - "expo-local-authentication": "~57.0.3", - "expo-localization": "~57.0.2", - "expo-location": "~57.0.20", - "expo-notifications": "~57.0.21", - "expo-router": "~57.0.24", + "expo-image": "~58.0.14", + "expo-image-manipulator": "~58.0.14", + "expo-image-picker": "~58.0.10", + "expo-keep-awake": "~58.0.3", + "expo-linear-gradient": "~58.0.3", + "expo-linking": "~58.0.13", + "expo-local-authentication": "~58.0.2", + "expo-localization": "~58.0.4", + "expo-location": "~58.0.16", + "expo-notifications": "~58.1.4", + "expo-router": "~58.0.19", "expo-screen-corner-radius": "1.1.0", - "expo-secure-store": "~57.0.4", + "expo-secure-store": "~58.0.2", "expo-share-intent": "8.0.1", - "expo-sharing": "~57.0.22", + "expo-sharing": "~58.0.18", "expo-speech-recognition": "57.1.0", - "expo-splash-screen": "~57.0.9", - "expo-sqlite": "~57.0.3", - "expo-status-bar": "57.0.1", - "expo-store-review": "~57.0.3", - "expo-system-ui": "~57.0.4", - "expo-task-manager": "57.0.21", - "expo-tracking-transparency": "~57.0.2", - "expo-video": "~57.0.5", - "expo-web-browser": "~57.0.3", - "expo-widgets": "57.0.22", + "expo-splash-screen": "~58.0.9", + "expo-sqlite": "~58.0.13", + "expo-status-bar": "~58.0.3", + "expo-store-review": "~58.0.2", + "expo-system-ui": "~58.0.5", + "expo-task-manager": "~58.0.11", + "expo-tracking-transparency": "~58.0.2", + "expo-video": "~58.0.10", + "expo-web-browser": "~58.0.6", + "expo-widgets": "~58.0.17", "i18next": "^26.4.2", "jotai": "2.20.3", "llama.rn": "0.12.9", @@ -118,22 +118,22 @@ "marked": "18.0.14", "nativewind": "5.0.0-preview.4", "posthog-react-native": "4.78.2", - "react": "19.2.3", + "react": "19.3.0", "react-i18next": "^17.0.15", - "react-native": "0.86.3", + "react-native": "0.88.0", "react-native-android-widget": "^0.22.1", "react-native-appsflyer": "7.0.2-v2", "react-native-css": "3.0.7", "react-native-enriched-markdown": "1.1.0", - "react-native-gesture-handler": "2.32.0", - "react-native-keyboard-controller": "1.22.6", + "react-native-gesture-handler": "~3.3.0", + "react-native-keyboard-controller": "1.22.7", "react-native-passkeys": "^0.4.2", "react-native-reanimated": "4.7.1", - "react-native-safe-area-context": "5.7.0", - "react-native-screens": "4.26.2", + "react-native-safe-area-context": "~5.10.1", + "react-native-screens": "~4.30.0", "react-native-sortables": "^1.10.1", - "react-native-svg": "15.15.4", - "react-native-webview": "13.16.1", + "react-native-svg": "15.15.5", + "react-native-webview": "14.0.1", "react-native-worklets": "0.13.0", "react-native-zoom-toolkit": "5.1.1", "sonner-native": "0.27.0", @@ -145,10 +145,10 @@ "devDependencies": { "@expo/plist": "0.8.1", "@sentry/cli": "catalog:", - "@types/react": "19.2.14", + "@types/react": "~19.3.0", "@typescript/native-preview": "catalog:", "babel-plugin-inline-import": "^3.0.0", - "babel-preset-expo": "~57.0.13", + "babel-preset-expo": "~58.0.14", "drizzle-kit": "catalog:", "eslint-plugin-sonarjs": "4.2.2", "knip": "6.39.0", @@ -171,7 +171,9 @@ "android": { "buildFromSource": [ "expo-background-task", - "expo-notifications" + "expo-notifications", + "expo-task-manager", + "unimodules-app-loader" ] } }, diff --git a/apps/mobile/plugins/withActiveAgentsAndroidWidget.js b/apps/mobile/plugins/withActiveAgentsAndroidWidget.js index 26df2642ce1..48e2c27301c 100644 --- a/apps/mobile/plugins/withActiveAgentsAndroidWidget.js +++ b/apps/mobile/plugins/withActiveAgentsAndroidWidget.js @@ -1,7 +1,7 @@ const fs = require('fs'); const path = require('path'); -const { withAppBuildGradle, withDangerousMod } = require('expo/config-plugins'); +const { withDangerousMod } = require('expo/config-plugins'); const { rewriteWidgetProviderCategoryOrThrow } = require('./android-widget-category'); @@ -20,31 +20,6 @@ const GALLERY_COPY = require('./widget-gallery-copy.json'); // wraps it in a string resource of its own. const WIDGET_CONFIG_PATH = path.resolve(__dirname, '../src/glanceable-android/widget-config.json'); -const WORK_FORCE_MARKER = 'kilo-work-runtime-alignment'; - -// expo-widgets pulls androidx.glance, which depends on work-runtime-ktx 2.7.1, -// while react-native-android-widget depends on work-runtime 2.8.1. Version -// 2.8.0 folded the ktx classes into the main artifact, so the two together fail -// :app:checkDebugDuplicateClasses. Pin both to 2.8.1, where the ktx artifact is -// an empty shim. -function withWorkRuntimeAlignment(config) { - return withAppBuildGradle(config, cfg => { - if (cfg.modResults.contents.includes(WORK_FORCE_MARKER)) { - return cfg; - } - cfg.modResults.contents += ` -// ${WORK_FORCE_MARKER} -configurations.configureEach { - resolutionStrategy { - force 'androidx.work:work-runtime:2.8.1' - force 'androidx.work:work-runtime-ktx:2.8.1' - } -} -`; - return cfg; - }); -} - /** * Declare the lock screen / always-on-display host on the provider XML. * @@ -120,5 +95,5 @@ module.exports = function withActiveAgentsAndroidWidget(config) { })); // The category mod is registered before the library plugin: see its comment. const withCategory = withKeyguardWidgetCategory(config, described); - return withWorkRuntimeAlignment(loadAndroidWidgetsPlugin()(withCategory, { widgets: described })); + return loadAndroidWidgetsPlugin()(withCategory, { widgets: described }); }; diff --git a/apps/mobile/plugins/withAndroidManifestFix.js b/apps/mobile/plugins/withAndroidManifestFix.js index 61ba31e5d76..f3fe4159e15 100644 --- a/apps/mobile/plugins/withAndroidManifestFix.js +++ b/apps/mobile/plugins/withAndroidManifestFix.js @@ -1,7 +1,11 @@ const fs = require('fs'); const path = require('path'); -const { withAndroidManifest, withDangerousMod } = require('expo/config-plugins'); +const { + withAndroidManifest, + withDangerousMod, + withGradleProperties, +} = require('expo/config-plugins'); const BACKUP_RULES_RES = '@xml/kilo_backup_rules'; const DATA_EXTRACTION_RULES_RES = '@xml/kilo_data_extraction_rules'; @@ -47,6 +51,14 @@ const OPTIONAL_FEATURES = [ */ function withAndroidManifestFix(config) { config = withAndroidResources(config); + config = withGradleProperties(config, cfg => { + // AppsFlyer's SDK and purchase connector share a namespace. AGP 9 enables + // uniqueness validation by default; retain AGP 8's policy for this vendor pair. + const key = 'android.uniquePackageNames'; + cfg.modResults = cfg.modResults.filter(entry => entry.type !== 'property' || entry.key !== key); + cfg.modResults.push({ type: 'property', key, value: 'false' }); + return cfg; + }); return withAndroidManifest(config, config => { const manifest = config.modResults.manifest; const application = manifest.application?.[0]; diff --git a/apps/mobile/plugins/withArtifactFileProvider.js b/apps/mobile/plugins/withArtifactFileProvider.js index 4c8e04441d0..4f762ccf0a2 100644 --- a/apps/mobile/plugins/withArtifactFileProvider.js +++ b/apps/mobile/plugins/withArtifactFileProvider.js @@ -115,6 +115,7 @@ function withFileProviderTarget(config) { groupName, productFile, widgetFiles: relativePaths.filter(file => file.endsWith('.swift')), + resourceFileRefs: [], }); addPbxGroup(xcodeProject, { targetName: TARGET_NAME, widgetFiles: relativePaths }); return projectConfig; diff --git a/apps/mobile/plugins/withReactMountLogBreadcrumbs.js b/apps/mobile/plugins/withReactMountLogBreadcrumbs.js index e78811889ed..076329e9cdd 100644 --- a/apps/mobile/plugins/withReactMountLogBreadcrumbs.js +++ b/apps/mobile/plugins/withReactMountLogBreadcrumbs.js @@ -1,7 +1,7 @@ const { withAppDelegate } = require('expo/config-plugins'); /** - * Copies the mount-skip errors from `patches/react-native@0.86.3.patch` into + * Copies the mount-skip errors from `patches/react-native@0.88.0.patch` into * Sentry breadcrumbs on iOS. * * The patch reports a Fabric mutation for an unregistered component view diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/index.language-remount.mounted.test.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/index.language-remount.mounted.test.tsx index e360c5923d1..a476205eb03 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/index.language-remount.mounted.test.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/index.language-remount.mounted.test.tsx @@ -7,8 +7,8 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { authState, findConfigureRows, - flushInteractions, - interactionState, + flushIdle, + idleState, keys, nodeCountWithChildren, organizationsQueryFn, @@ -34,8 +34,8 @@ beforeEach(async () => { providersQueryFn.mockReset(); organizationsQueryFn.mockReset(); authState.token = 'token-1'; - interactionState.storedCallback = undefined; - interactionState.cancel.mockReset(); + idleState.storedCallback = undefined; + idleState.cancel.mockReset(); providersQueryFn.mockResolvedValue({ providers: [{ provider: 'github', email: 'dev@kilo.ai' }], }); @@ -53,7 +53,7 @@ describe('ProfileRoute language remount', () => { it('renders the profile under the active language', async () => { const { renderer, unmount } = await renderWithProviders(createElement(ProfileRoute)); - flushInteractions(); + flushIdle(); await waitFor(() => findConfigureRows(renderer.root, 'GitHub').length === 1); expect(findConfigureRows(renderer.root, 'GitHub')[0]?.props.subtitle).toBe('dev@kilo.ai'); @@ -63,29 +63,29 @@ describe('ProfileRoute language remount', () => { it('remounts the screen when the applied language changes', async () => { const { renderer, unmount } = await renderWithProviders(createElement(ProfileRoute)); - flushInteractions(); + flushIdle(); await waitFor(() => findConfigureRows(renderer.root, 'GitHub').length === 1); expect(findConfigureRows(renderer.root, 'GitHub')[0]?.props.subtitle).toBe('dev@kilo.ai'); // Applying a new language re-renders the route through `useTranslation` // and the key swap unmounts the old screen and mounts a fresh one. The - // remount is made observable through the interaction deferral: the fresh - // mount re-runs `useAfterInteractions`, so a cleared captured callback is + // remount is made observable through the idle deferral: the fresh + // mount re-runs the deferred work, so a cleared captured callback is // registered again — `ProfileScreen`'s own `useTranslation` alone would // re-render copy without any of that. organizationsQueryFn.mockReset(); organizationsQueryFn.mockResolvedValue([]); - interactionState.storedCallback = undefined; - interactionState.cancel.mockClear(); + idleState.storedCallback = undefined; + idleState.cancel.mockClear(); await act(async () => { await i18n.changeLanguage('ar'); }); - // The fresh mount captured a new interaction callback and the old + // The fresh mount captured a new idle callback and the old // screen's handle was cancelled on unmount. - expect(interactionState.storedCallback).toBeTypeOf('function'); - expect(interactionState.cancel).toHaveBeenCalled(); + expect(idleState.storedCallback).toBeTypeOf('function'); + expect(idleState.cancel).toHaveBeenCalled(); - flushInteractions(); + flushIdle(); await waitFor(() => nodeCountWithChildren(renderer.root, 'Text', 'الوكلاء') === 1); // Section headers read from the Arabic resource, proving the fresh mount @@ -111,13 +111,13 @@ describe('ProfileRoute language remount', () => { expect(findConfigureRows(renderer.root, 'GitHub')[0]?.props.subtitle).toBe('dev@kilo.ai'); - // The remount re-runs the interaction deferral on the fresh scene. - interactionState.storedCallback = undefined; - interactionState.cancel.mockClear(); + // The remount re-runs the idle deferral on the fresh scene. + idleState.storedCallback = undefined; + idleState.cancel.mockClear(); await act(async () => { await i18n.changeLanguage('de'); }); - expect(interactionState.storedCallback).toBeTypeOf('function'); + expect(idleState.storedCallback).toBeTypeOf('function'); // Cached rows survive the remount (the query client is shared), so the // fresh scene paints data immediately instead of a skeleton. diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/_layout.tsx index 077ade23fb4..0ce0fb9a1b7 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/_layout.tsx @@ -6,6 +6,10 @@ import { SecurityAgentCommandObserver } from '@/components/security-agent/securi import { useFormSheetScreenOptions } from '@/lib/form-sheet'; import { parseParam } from '@/lib/route-params'; +export const unstable_settings = { + anchor: 'index', +}; + // Mounts exactly one command observer per scope alongside a headerless Stack, // so it stays mounted across Dashboard/Findings/Settings navigation without // ever running twice for the same scope. Also the single validation point @@ -24,19 +28,8 @@ export default function SecurityAgentScopeLayout() { return ( <> - {/* Anchored entry needs a valid scope root, not a parameterless dismissal route. */} - - {/* - * An anchored navigation (a notification or system-search link to a - * finding) inserts `index` below the target with only its - * `initialParams`, not the URL params. Without `scope` here the - * dashboard under the finding queries with no `organizationId`. - */} - + + diff --git a/apps/mobile/src/app/(app)/agent-chat/__tests__/use-new-session-discard-guard-test-helpers.tsx b/apps/mobile/src/app/(app)/agent-chat/__tests__/use-new-session-discard-guard-test-helpers.tsx index d0bfaaff55d..de7cdbb63e5 100644 --- a/apps/mobile/src/app/(app)/agent-chat/__tests__/use-new-session-discard-guard-test-helpers.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/__tests__/use-new-session-discard-guard-test-helpers.tsx @@ -28,14 +28,21 @@ type Action = { type: string }; const usePreventRemoveHolder = vi.hoisted(() => ({ preventRemove: undefined as boolean | undefined, - callback: undefined as ((options: { data: { action: Action } }) => void) | undefined, + callback: undefined as + | ((options: { data: { action: Action }; repeat: () => void }) => void) + | undefined, })); const usePreventRemoveMock = vi.hoisted(() => - vi.fn((preventRemove: boolean, handler: (options: { data: { action: Action } }) => void) => { - usePreventRemoveHolder.preventRemove = preventRemove; - usePreventRemoveHolder.callback = handler; - }) + vi.fn( + ( + preventRemove: boolean, + handler: (options: { data: { action: Action }; repeat: () => void }) => void + ) => { + usePreventRemoveHolder.preventRemove = preventRemove; + usePreventRemoveHolder.callback = handler; + } + ) ); // The dialog the hook returns is rendered for real, so its primitives and the @@ -76,10 +83,6 @@ vi.mock('sonner-native', () => ({ })); vi.mock('expo-router', () => ({ - useNavigation: () => ({ dispatch: dispatchMock }), -})); - -vi.mock('@/lib/navigation/prevent-remove', () => ({ usePreventRemove: usePreventRemoveMock, })); @@ -145,7 +148,12 @@ export function mountGuardWithAttachments() { /** Fires the captured leave outside `act`; the hook's handler runs synchronously. */ export function triggerPreventRemove(): Action { const action = { type: 'GO_BACK' }; - usePreventRemoveHolder.callback?.({ data: { action } }); + usePreventRemoveHolder.callback?.({ + data: { action }, + repeat: () => { + dispatchMock(action); + }, + }); return action; } diff --git a/apps/mobile/src/app/(app)/agent-chat/__tests__/use-new-session-discard-guard.mounted.test.tsx b/apps/mobile/src/app/(app)/agent-chat/__tests__/use-new-session-discard-guard.mounted.test.tsx index 0e9d57af737..36cb36d5e8c 100644 --- a/apps/mobile/src/app/(app)/agent-chat/__tests__/use-new-session-discard-guard.mounted.test.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/__tests__/use-new-session-discard-guard.mounted.test.tsx @@ -17,7 +17,6 @@ import { renderedLabels, resetGuardHarness, toastErrorMock, - triggerPreventRemove, triggerPreventRemoveInAct, usePreventRemoveHolder, usePreventRemoveMock, @@ -111,7 +110,7 @@ describe('useNewSessionDiscardGuard', () => { expect(usePreventRemoveMock).toHaveBeenCalledTimes(1); expect(usePreventRemoveMock.mock.calls[0]?.[0]).toBe(true); - const action = triggerPreventRemove(); + const action = triggerPreventRemoveInAct(); // The removal was already prevented, so the guard replays the action. expect(dispatchMock).toHaveBeenCalledTimes(1); expect(dispatchMock).toHaveBeenCalledWith(action); @@ -119,6 +118,7 @@ describe('useNewSessionDiscardGuard', () => { expect(renderedLabels(renderer)).not.toContain('Discard draft?'); // The bypass is one-shot: consumed on the removal it armed. expect(skipRef.current).toBe(false); + expect(usePreventRemoveHolder.preventRemove).toBe(false); act(() => { renderer.unmount(); @@ -135,12 +135,20 @@ describe('useNewSessionDiscardGuard', () => { // abandon, so the discard confirm must not hijack it (spot check // e12-tap-prefs: the dialog blocked the Preferences screen from opening). const action = { type: actionType }; - usePreventRemoveHolder.callback?.({ data: { action } }); + act(() => { + usePreventRemoveHolder.callback?.({ + data: { action }, + repeat: () => { + dispatchMock(action); + }, + }); + }); expect(alertMock).not.toHaveBeenCalled(); expect(renderedLabels(renderer)).not.toContain('Discard draft?'); expect(dispatchMock).toHaveBeenCalledTimes(1); expect(dispatchMock).toHaveBeenCalledWith(action); + expect(usePreventRemoveHolder.preventRemove).toBe(false); act(() => { renderer.unmount(); @@ -183,6 +191,7 @@ describe('useNewSessionDiscardGuard', () => { expect(dispatchMock).toHaveBeenCalledWith(action); // The draft clear must finish before the captured leave is replayed. expect(order).toEqual(['discard', 'dispatch']); + expect(usePreventRemoveHolder.preventRemove).toBe(false); act(() => { renderer.unmount(); @@ -212,6 +221,7 @@ describe('useNewSessionDiscardGuard', () => { expect(onDiscard).toHaveBeenCalledTimes(1); expect(dispatchMock).not.toHaveBeenCalled(); expect(toastErrorMock).toHaveBeenCalledWith('Could not discard the draft. Please try again.'); + expect(usePreventRemoveHolder.preventRemove).toBe(true); // Closed, so the user can retry by leaving again with the draft intact. expect(renderedLabels(renderer)).not.toContain('Discard draft?'); @@ -240,6 +250,7 @@ describe('useNewSessionDiscardGuard', () => { expect(onDiscard).not.toHaveBeenCalled(); expect(dispatchMock).not.toHaveBeenCalled(); expect(renderedLabels(renderer)).not.toContain('Discard draft?'); + expect(usePreventRemoveHolder.preventRemove).toBe(true); act(() => { renderer.unmount(); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index c84cbf113c0..1214e9d1489 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -44,7 +44,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { useRootNavigationReady } from '@/lib/navigation-ready'; import { useTranslation } from 'react-i18next'; -import { View } from 'react-native'; +import { View, type ViewInstance } from 'react-native'; import Animated, { useAnimatedStyle } from 'react-native-reanimated'; import { toast } from 'sonner-native'; @@ -1021,7 +1021,7 @@ function RootLayoutNav({ // cleanup cancels the pending frame, which React runs before any later // render's frame can fire, so an interrupted reveal never focuses a stale // wrapper. - const wrapperRef = useRef(null); + const wrapperRef = useRef(null); const wasHiddenRef = useRef(wrapperObscured); useEffect(() => { const wasHidden = wasHiddenRef.current; diff --git a/apps/mobile/src/components/agents/attachment-picker.test.ts b/apps/mobile/src/components/agents/attachment-picker.test.ts index 279c462363a..055c7c0ea5c 100644 --- a/apps/mobile/src/components/agents/attachment-picker.test.ts +++ b/apps/mobile/src/components/agents/attachment-picker.test.ts @@ -31,7 +31,7 @@ const expoFileSystemMock = vi.hoisted(() => { get exists() { return files.has(uri); }, - write(content: string) { + writeSync(content: string) { files.set(uri, content); }, textSync() { diff --git a/apps/mobile/src/components/agents/attachment-preview-strip.tsx b/apps/mobile/src/components/agents/attachment-preview-strip.tsx index 9bacd612dae..f5e1d001edd 100644 --- a/apps/mobile/src/components/agents/attachment-preview-strip.tsx +++ b/apps/mobile/src/components/agents/attachment-preview-strip.tsx @@ -8,6 +8,7 @@ import { Pressable, ScrollView, View, + type ViewInstance, } from 'react-native'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; import { @@ -167,7 +168,7 @@ function AttachmentChip({ // The chip body is the single accessible element; focus restoration targets // it after a move so VoiceOver/TalkBack stays on the chip that changed slot. - const bodyRef = useRef(null); + const bodyRef = useRef(null); const focusPendingRef = useRef(false); const [focusEpoch, setFocusEpoch] = useState(0); diff --git a/apps/mobile/src/components/agents/blocking-card-state-a11y.test.ts b/apps/mobile/src/components/agents/blocking-card-state-a11y.test.ts index c48175a9391..4f2b10abcbe 100644 --- a/apps/mobile/src/components/agents/blocking-card-state-a11y.test.ts +++ b/apps/mobile/src/components/agents/blocking-card-state-a11y.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { type Component, type RefObject } from 'react'; +import { type RefObject } from 'react'; +import { type HostInstance } from 'react-native'; import { applyBlockingCardAppearance, @@ -10,14 +11,14 @@ import { function makeDeps(): BlockingCardA11yDeps { return { announce: vi.fn<(message: string) => void>(), - focus: vi.fn<(ref: RefObject) => boolean>().mockReturnValue(true), + focus: vi.fn<(ref: RefObject) => boolean>().mockReturnValue(true), }; } describe('applyBlockingCardAppearance', () => { it('invokes the announce helper with the presentation announcement on appearance', () => { const deps = makeDeps(); - const ref: RefObject = { current: null }; + const ref: RefObject = { current: null }; const presentation = getBlockingCardPresentationForKind({ kind: 'question', submissionError: null, @@ -31,9 +32,8 @@ describe('applyBlockingCardAppearance', () => { it('moves a11y focus to the card ref on appearance', () => { const deps = makeDeps(); - const ref: RefObject = { - current: { node: 'card' } as unknown as Component, - }; + const node: Partial = {}; + const ref: RefObject = { current: node as HostInstance }; const presentation = getBlockingCardPresentationForKind({ kind: 'permission', submissionError: null, @@ -48,9 +48,9 @@ describe('applyBlockingCardAppearance', () => { it('still announces when the focus move cannot find a node handle', () => { const deps: BlockingCardA11yDeps = { announce: vi.fn<(message: string) => void>(), - focus: vi.fn<(ref: RefObject) => boolean>().mockReturnValue(false), + focus: vi.fn<(ref: RefObject) => boolean>().mockReturnValue(false), }; - const ref: RefObject = { current: null }; + const ref: RefObject = { current: null }; const presentation = getBlockingCardPresentationForKind({ kind: 'question', submissionError: { kind: 'retryable', message: 'Try again', action: 'answer' }, @@ -65,13 +65,13 @@ describe('applyBlockingCardAppearance', () => { it('retries focus once on the next tick when the first attempt misses the handle', () => { vi.useFakeTimers(); - const focusMock = vi.fn<(ref: RefObject) => boolean>(); + const focusMock = vi.fn<(ref: RefObject) => boolean>(); focusMock.mockReturnValueOnce(false).mockReturnValueOnce(true); const deps: BlockingCardA11yDeps = { announce: vi.fn<(message: string) => void>(), focus: focusMock, }; - const ref: RefObject = { current: null }; + const ref: RefObject = { current: null }; const presentation = getBlockingCardPresentationForKind({ kind: 'question', submissionError: null, @@ -89,12 +89,14 @@ describe('applyBlockingCardAppearance', () => { it('clears the pending retry when the cleanup function runs', () => { vi.useFakeTimers(); - const focusMock = vi.fn<(ref: RefObject) => boolean>().mockReturnValue(false); + const focusMock = vi + .fn<(ref: RefObject) => boolean>() + .mockReturnValue(false); const deps: BlockingCardA11yDeps = { announce: vi.fn<(message: string) => void>(), focus: focusMock, }; - const ref: RefObject = { current: null }; + const ref: RefObject = { current: null }; const presentation = getBlockingCardPresentationForKind({ kind: 'permission', submissionError: null, diff --git a/apps/mobile/src/components/agents/blocking-card-state.ts b/apps/mobile/src/components/agents/blocking-card-state.ts index 8b62955983b..e8a171ebb5e 100644 --- a/apps/mobile/src/components/agents/blocking-card-state.ts +++ b/apps/mobile/src/components/agents/blocking-card-state.ts @@ -5,7 +5,8 @@ // lets the repo's `node` vitest env cover the selection + CTA rules without // rendering React Native. -import { type Component, type RefObject } from 'react'; +import { type RefObject } from 'react'; +import { type HostInstance } from 'react-native'; import { i18n } from '@/i18n'; import { formatNumber } from '@/lib/format'; @@ -53,7 +54,7 @@ type BlockingCardPresentation = { export type BlockingCardA11yDeps = { announce: (message: string) => void; - focus: (ref: RefObject) => boolean; + focus: (ref: RefObject) => boolean; }; /** @@ -137,7 +138,7 @@ export function getBlockingCardPresentationForKind(input: { */ export function applyBlockingCardAppearance( presentation: BlockingCardPresentation, - ref: RefObject, + ref: RefObject, deps: BlockingCardA11yDeps ): (() => void) | undefined { deps.announce(presentation.announcement); diff --git a/apps/mobile/src/components/agents/chat-composer-attachment-send.test.ts b/apps/mobile/src/components/agents/chat-composer-attachment-send.test.ts index aa747e439bf..11667d62e7c 100644 --- a/apps/mobile/src/components/agents/chat-composer-attachment-send.test.ts +++ b/apps/mobile/src/components/agents/chat-composer-attachment-send.test.ts @@ -4,7 +4,7 @@ import * as React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { usePreventRemove } from '@/lib/navigation/prevent-remove'; +import { usePreventRemove } from 'expo-router'; import { type ChatComposer } from './chat-composer'; // The attachment-only send contract: a ready (`uploaded`) attachment with an @@ -101,10 +101,6 @@ vi.mock('react-native-gesture-handler', () => ({ })); vi.mock('expo-router', () => ({ - useNavigation: () => ({ dispatch: leaveMock.dispatch }), -})); - -vi.mock('@/lib/navigation/prevent-remove', () => ({ usePreventRemove: vi.fn(), })); @@ -493,13 +489,16 @@ describe('ChatComposer unclaimed-attachment leave', () => { const guard = vi.mocked(usePreventRemove).mock.calls.at(-1); expect(guard?.[0]).toBe(true); const action = { type: 'GO_BACK' }; - const runGuard = guard?.[1] as - | ((options: { data: { action: { type: string } } }) => void) - | undefined; + const runGuard = guard?.[1]; if (!runGuard) { throw new Error('composer did not register its leave guard'); } - runGuard({ data: { action } }); + runGuard({ + data: { action }, + repeat: () => { + leaveMock.dispatch(action); + }, + }); expect(leaveMock.confirm).toHaveBeenCalledWith({ title: 'Discard attachments?', diff --git a/apps/mobile/src/components/agents/chat-composer-send-once.mounted.test.tsx b/apps/mobile/src/components/agents/chat-composer-send-once.mounted.test.tsx index 204532fc07f..6581dfc11c6 100644 --- a/apps/mobile/src/components/agents/chat-composer-send-once.mounted.test.tsx +++ b/apps/mobile/src/components/agents/chat-composer-send-once.mounted.test.tsx @@ -93,10 +93,6 @@ vi.mock('sonner-native', () => ({ })); vi.mock('expo-router', () => ({ - useNavigation: () => ({ dispatch: vi.fn() }), -})); - -vi.mock('@/lib/navigation/prevent-remove', () => ({ usePreventRemove: vi.fn(), })); diff --git a/apps/mobile/src/components/agents/chat-composer-typing-render.mounted.test.tsx b/apps/mobile/src/components/agents/chat-composer-typing-render.mounted.test.tsx index ab65f26f9c3..f06f5295514 100644 --- a/apps/mobile/src/components/agents/chat-composer-typing-render.mounted.test.tsx +++ b/apps/mobile/src/components/agents/chat-composer-typing-render.mounted.test.tsx @@ -98,10 +98,6 @@ vi.mock('sonner-native', () => ({ })); vi.mock('expo-router', () => ({ - useNavigation: () => ({ dispatch: vi.fn() }), -})); - -vi.mock('@/lib/navigation/prevent-remove', () => ({ usePreventRemove: vi.fn(), })); diff --git a/apps/mobile/src/components/agents/chat-composer.test.ts b/apps/mobile/src/components/agents/chat-composer.test.ts index c62181c5e83..2e3f346de09 100644 --- a/apps/mobile/src/components/agents/chat-composer.test.ts +++ b/apps/mobile/src/components/agents/chat-composer.test.ts @@ -140,10 +140,6 @@ vi.mock('react-native-gesture-handler', () => ({ })); vi.mock('expo-router', () => ({ - useNavigation: () => ({ dispatch: vi.fn() }), -})); - -vi.mock('@/lib/navigation/prevent-remove', () => ({ usePreventRemove: vi.fn(), })); diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index 1de8ed841ca..107248728eb 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -42,7 +42,7 @@ import Animated, { useAnimatedStyle, useSharedValue, } from 'react-native-reanimated'; -import { useNavigation } from 'expo-router'; +import { usePreventRemove } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; @@ -53,7 +53,6 @@ import { pickAgentAttachments, pickAgentImages } from '@/components/agents/attac import { AccessibleStatus } from '@/components/ui/accessible-status'; import { useConfirmDialog } from '@/components/ui/dialog'; import { Text } from '@/components/ui/text'; -import { usePreventRemove } from '@/lib/navigation/prevent-remove'; import { createMobileSlashCommandList, getSlashCommandCandidate, @@ -144,7 +143,7 @@ const COMPOSER_COUNTER_VISIBLE_REMAINING = 1000; const TOOLBAR_ROW_UNCLAMPED_HEIGHT = 10_000; type AndroidDismissKeyboardGesture = { - identifier: string; + identifier: GestureResponderEvent['nativeEvent']['identifier']; startPageX: number; startPageY: number; dismissed: boolean; @@ -400,18 +399,12 @@ export function ChatComposer({ } satisfies { current: boolean }; const upload = useAgentAttachmentUpload({ organizationId, delivery: attachmentDelivery }); - // Leave confirm for unsent uploads. The composer registers its own - // `beforeRemove` listener so header back, the iOS swipe-back gesture, and - // Android back all confirm the same way. Slash-command navigation (/new, - // /restart, /exit) is already rejected while attachments are present, so no - // bypass flag is needed; a successful send clears the chips and disarms. - const navigation = useNavigation(); + const [leaveAllowed, setLeaveAllowed] = useState(false); const { confirm, dialog } = useConfirmDialog(); const { confirmExit, exitDialog } = useRemoteSessionExitConfirmation(); const releaseUnclaimedRef = useRef(upload.releaseUnclaimedUploads); releaseUnclaimedRef.current = upload.releaseUnclaimedUploads; - usePreventRemove(upload.hasUnclaimedAttachments, ({ data }) => { - const action = data.action; + usePreventRemove(upload.hasUnclaimedAttachments && !leaveAllowed, ({ repeat }) => { confirm({ title: i18n.t('agentChat.composer.discardAttachmentsTitle'), message: i18n.t('agentChat.composer.discardAttachmentsMessage'), @@ -419,7 +412,8 @@ export function ChatComposer({ cancelLabel: i18n.t('common.keepEditing'), onConfirm: () => { releaseUnclaimedRef.current(); - navigation.dispatch(action); + setLeaveAllowed(true); + repeat(); }, }); }); diff --git a/apps/mobile/src/components/agents/chat-link-actions.test.ts b/apps/mobile/src/components/agents/chat-link-actions.test.ts index b77b2bd7472..b8c65f0755f 100644 --- a/apps/mobile/src/components/agents/chat-link-actions.test.ts +++ b/apps/mobile/src/components/agents/chat-link-actions.test.ts @@ -143,7 +143,7 @@ describe('chat link actions', () => { }); it('shares the exact URL without treating dismissal as failure', async () => { - mockedShare.mockResolvedValue({ action: Share.dismissedAction }); + mockedShare.mockResolvedValue({ action: Share.dismissedAction, activityType: undefined }); await performChatLinkAction('share', 'https://kilo.ai/docs'); @@ -154,7 +154,7 @@ describe('chat link actions', () => { it('retries only sharing after a share failure', async () => { mockedShare .mockRejectedValueOnce(new Error('share unavailable')) - .mockResolvedValueOnce({ action: Share.sharedAction }); + .mockResolvedValueOnce({ action: Share.sharedAction, activityType: undefined }); await performChatLinkAction('share', 'https://kilo.ai/docs'); diff --git a/apps/mobile/src/components/agents/file-part-cache.test.ts b/apps/mobile/src/components/agents/file-part-cache.test.ts index bef18ee7cb5..d0940fbef96 100644 --- a/apps/mobile/src/components/agents/file-part-cache.test.ts +++ b/apps/mobile/src/components/agents/file-part-cache.test.ts @@ -20,7 +20,8 @@ import { type FileInstance = { uri: string; - write: ReturnType; + written?: { content: string; encoding?: string }; + writeSync: (content: string, options?: { encoding?: string }) => void; filename: string; }; @@ -38,9 +39,11 @@ const expoFileSystemMock = vi.hoisted(() => { }; }); const File = vi.fn(function FileMock(_directory: { name?: string }, filename: string) { - const instance = { + const instance: FileInstance = { uri: `file:///cache/session-file-parts/${filename}`, - write: vi.fn(), + writeSync(content, options) { + this.written = { content, encoding: options?.encoding }; + }, filename, }; fileInstances.push(instance); @@ -123,7 +126,7 @@ describe('cacheFilePart', () => { }); const file = fileInstances[0]; expect(file?.filename).toBe('part-1-report.pdf'); - expect(file?.write).toHaveBeenCalledWith('QUJD', { encoding: 'base64' }); + expect(file?.written).toEqual({ content: 'QUJD', encoding: 'base64' }); expect(getFilePartCacheEntry('part-1')?.url).toBe( 'file:///cache/session-file-parts/part-1-report.pdf' ); @@ -158,7 +161,7 @@ describe('cacheFilePart', () => { ) { const instance = { uri: `file:///cache/session-file-parts/${filename}`, - write: vi.fn(() => { + writeSync: vi.fn(() => { throw new Error('disk full'); }), filename, diff --git a/apps/mobile/src/components/agents/file-part-cache.ts b/apps/mobile/src/components/agents/file-part-cache.ts index 4e4d795cc90..c85a5924733 100644 --- a/apps/mobile/src/components/agents/file-part-cache.ts +++ b/apps/mobile/src/components/agents/file-part-cache.ts @@ -72,7 +72,7 @@ function resolveCacheUrl( directory, getSafeCacheFilename({ id: partId, filename: payload.filename ?? 'file' }) ); - file.write(stripped, { encoding: 'base64' }); + file.writeSync(stripped, { encoding: 'base64' }); return file.uri; } catch { return payload.url; diff --git a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx index f48a5aef688..640f19bde44 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx @@ -17,7 +17,8 @@ import { __resetFilePartUrlResolverForTests } from './file-part-url-resolver'; type FileInstance = { uri: string; - write: ReturnType; + write: (content: string, options?: { encoding?: string }) => Promise; + writeSync: (content: string, options?: { encoding?: string }) => void; text: ReturnType; filename?: string; }; @@ -39,7 +40,8 @@ const expoFileSystemMock = vi.hoisted(() => { typeof directoryOrUri === 'string' ? directoryOrUri : `file:///cache/session-file-parts/${filename}`, - write: vi.fn(), + write: vi.fn().mockResolvedValue(undefined), + writeSync: vi.fn(), text: fileText, filename, }; diff --git a/apps/mobile/src/components/agents/file-part-renderer.tsx b/apps/mobile/src/components/agents/file-part-renderer.tsx index 3c87982a498..8a27ea4192e 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.tsx @@ -44,7 +44,10 @@ function cacheFilenameForPart(part: Pick): } /** Write a base64 `data:` payload to the cache and return the file. */ -function writeDataUrlToCache(url: string, part: Pick): File { +async function writeDataUrlToCache( + url: string, + part: Pick +): Promise { const payload = stripDataUrlBase64Prefix(url, part.mime); if (payload === undefined) { throw new Error('decode-failed'); @@ -52,7 +55,7 @@ function writeDataUrlToCache(url: string, part: Pick { return; } if (url.startsWith('data:')) { - const file = writeDataUrlToCache(url, part); + const file = await writeDataUrlToCache(url, part); await shareLocalFile(file.uri, { mimeType: part.mime }); return; } diff --git a/apps/mobile/src/components/agents/markdown-mermaid.tsx b/apps/mobile/src/components/agents/markdown-mermaid.tsx index c3727480e76..d7461b64fea 100644 --- a/apps/mobile/src/components/agents/markdown-mermaid.tsx +++ b/apps/mobile/src/components/agents/markdown-mermaid.tsx @@ -70,7 +70,7 @@ async function prepareMermaidHost(): Promise { await new File(asset.localUri ?? asset.uri).copy(script); } const page = new File(directory, PAGE_FILE_NAME); - page.write(HOST_PAGE); + await page.write(HOST_PAGE); return { pageUri: page.uri, directoryUri: directory.uri }; } @@ -183,7 +183,7 @@ export function MarkdownMermaid({ source, palette }: Readonly request.url.startsWith('file://') && request.url.endsWith(`/${PAGE_FILE_NAME}`) } - onMessage={(event: WebViewMessageEvent) => { + onMessage={(event: WebViewMessageEvent): void => { const message = parseDiagramMessage(event.nativeEvent.data); if (message?.kind === 'height') { setHeight(Math.max(MIN_HEIGHT, message.height)); diff --git a/apps/mobile/src/components/agents/new-session-screen-body.tsx b/apps/mobile/src/components/agents/new-session-screen-body.tsx index fd0325c9459..bea9165b98c 100644 --- a/apps/mobile/src/components/agents/new-session-screen-body.tsx +++ b/apps/mobile/src/components/agents/new-session-screen-body.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { View } from 'react-native'; import { useTranslation } from 'react-i18next'; -import { type Href, useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; +import { type Href, useLocalSearchParams, usePreventRemove, useRouter } from 'expo-router'; import { useActionSheet } from '@expo/react-native-action-sheet'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner-native'; @@ -76,7 +76,6 @@ import { resolveNewSessionStartBlockedReason, resolveNewSessionStartDisabled, } from '@/lib/new-session-submit'; -import { usePreventRemove } from '@/lib/navigation/prevent-remove'; import { getRepoBindingsPath } from '@/lib/profile-agent-navigation'; import { clearDraft, @@ -797,12 +796,13 @@ export function NewSessionScreenBody() { // swipe are blocked with no discard alert. The one-shot bypass, armed by the // success callbacks right before the replace, lets the success navigation // through so back from the new session returns to the source session. - const navigation = useNavigation(); + const [cloneLeaveAllowed, setCloneLeaveAllowed] = useState(false); const isStarting = runOnInstance !== null ? remoteSpawn.isSpawningRemote : isCreating; - usePreventRemove(isCloneEntry && isStarting, ({ data }) => { + usePreventRemove(isCloneEntry && isStarting && !cloneLeaveAllowed, ({ repeat }) => { if (cloneNavigateBypassRef.current) { cloneNavigateBypassRef.current = false; - navigation.dispatch(data.action); + setCloneLeaveAllowed(true); + repeat(); } }); diff --git a/apps/mobile/src/components/agents/permission-card.tsx b/apps/mobile/src/components/agents/permission-card.tsx index 4a154cffbb0..743b6750deb 100644 --- a/apps/mobile/src/components/agents/permission-card.tsx +++ b/apps/mobile/src/components/agents/permission-card.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { type Text as RNText, ScrollView, View } from 'react-native'; +import { ScrollView, type TextInstance, View } from 'react-native'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; import * as Haptics from 'expo-haptics'; import { useTranslation } from 'react-i18next'; @@ -73,7 +73,7 @@ export function PermissionCard({ // accessibility element. The focus target is a non-interactive leaf title // inside the header; this keeps every Deny/Allow option reachable by // VoiceOver while still landing focus on the card when it appears. - const titleRef = useRef(null); + const titleRef = useRef(null); const presentationRef = useRef(presentation); presentationRef.current = presentation; useEffect( diff --git a/apps/mobile/src/components/agents/question-card.tsx b/apps/mobile/src/components/agents/question-card.tsx index 0dd8aee3f8d..fc2588cc7b5 100644 --- a/apps/mobile/src/components/agents/question-card.tsx +++ b/apps/mobile/src/components/agents/question-card.tsx @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- The blocking question card keeps its selection, custom-answer, and CTA presentation together. */ import { useEffect, useRef, useState } from 'react'; -import { Pressable, type Text as RNText, ScrollView, type TextInput, View } from 'react-native'; +import { Pressable, ScrollView, type TextInput, type TextInstance, View } from 'react-native'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; import * as Haptics from 'expo-haptics'; import { useTranslation } from 'react-i18next'; @@ -89,7 +89,7 @@ export function QuestionCard({ // reachable by VoiceOver while still landing focus on the card when it // appears. A missing node handle on the first paint is recovered by a // follow-up focus inside the shared appearance helper. - const titleRef = useRef(null); + const titleRef = useRef(null); const presentationRef = useRef(presentation); presentationRef.current = presentation; useEffect( diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index f91b568dc0e..53f7479c806 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -10,7 +10,7 @@ import { useAtomValue, useSetAtom, useStore } from 'jotai'; import { MessageSquare } from '@/components/ui/icons'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import * as Haptics from 'expo-haptics'; -import { Platform, type Text as RNText, View } from 'react-native'; +import { Platform, type TextInstance, View } from 'react-native'; import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -1888,7 +1888,7 @@ export function SessionDetailContent({ // node that no longer exists. We track the previous blocking kind and // only fire on the non-none → none transition (i.e. the user satisfied // the card), not on the very first mount. - const transcriptRef = useRef(null); + const transcriptRef = useRef(null); const previousBlockingInteractionRef = useRef('none'); useEffect(() => { const wasBlocking = previousBlockingInteractionRef.current !== 'none'; diff --git a/apps/mobile/src/components/agents/session-history-screen.pull-failure.mounted.test.tsx b/apps/mobile/src/components/agents/session-history-screen.pull-failure.mounted.test.tsx index 841c0df1670..d64810e1fde 100644 --- a/apps/mobile/src/components/agents/session-history-screen.pull-failure.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-history-screen.pull-failure.mounted.test.tsx @@ -59,12 +59,6 @@ const appState = vi.hoisted(() => ({ vi.mock('react-native', () => ({ I18nManager: { isRTL: false }, - InteractionManager: { - runAfterInteractions: (run: () => void) => { - run(); - return { cancel: () => undefined }; - }, - }, Platform: { OS: 'ios' }, KeyboardAvoidingView: 'KeyboardAvoidingView', Modal: 'Modal', @@ -77,6 +71,11 @@ vi.mock('react-native', () => ({ useWindowDimensions: () => ({ fontScale: 1 }), AppState: appState, })); +vi.mock('@/lib/query/schedule-cache-maintenance', () => ({ + scheduleCacheMaintenance: (run: () => void) => { + run(); + }, +})); vi.mock('@shopify/flash-list', async () => { const React = await import('react'); type Row = { key: string }; diff --git a/apps/mobile/src/components/agents/session-list-screen.pull-failure.mounted.test.tsx b/apps/mobile/src/components/agents/session-list-screen.pull-failure.mounted.test.tsx index ba30f3fa088..e8d8e21d4cd 100644 --- a/apps/mobile/src/components/agents/session-list-screen.pull-failure.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.pull-failure.mounted.test.tsx @@ -90,12 +90,6 @@ vi.mock('@shopify/flash-list', () => ({ })); vi.mock('react-native', () => ({ I18nManager: { isRTL: false }, - InteractionManager: { - runAfterInteractions: (run: () => void) => { - run(); - return { cancel: () => undefined }; - }, - }, Platform: { OS: 'ios' }, Modal: 'Modal', Pressable: 'Pressable', @@ -120,6 +114,11 @@ vi.mock('react-native', () => ({ ) ), })); +vi.mock('@/lib/query/schedule-cache-maintenance', () => ({ + scheduleCacheMaintenance: (run: () => void) => { + run(); + }, +})); vi.mock('react-native-reanimated', () => ({ __esModule: true, default: { View: 'AnimatedView' }, diff --git a/apps/mobile/src/components/agents/session-preview-action-panel.tsx b/apps/mobile/src/components/agents/session-preview-action-panel.tsx index 6cb01b340e8..c6a424e4989 100644 --- a/apps/mobile/src/components/agents/session-preview-action-panel.tsx +++ b/apps/mobile/src/components/agents/session-preview-action-panel.tsx @@ -1,5 +1,5 @@ import { type Ref } from 'react'; -import { Pressable, View } from 'react-native'; +import { Pressable, View, type ViewInstance } from 'react-native'; import { Text } from '@/components/ui/text'; import { cn } from '@/lib/utils'; @@ -16,7 +16,7 @@ type SessionPreviewActionPanelProps = { * VoiceOver focus moves when the portal actually mounts the row — an effect * on open runs before the portal has rendered it. */ - firstItemRef?: Ref; + firstItemRef?: Ref; }; /** diff --git a/apps/mobile/src/components/agents/session-preview-overlay.tsx b/apps/mobile/src/components/agents/session-preview-overlay.tsx index 555a92b339a..987f9ac0aad 100644 --- a/apps/mobile/src/components/agents/session-preview-overlay.tsx +++ b/apps/mobile/src/components/agents/session-preview-overlay.tsx @@ -11,6 +11,7 @@ import { useColorScheme, useWindowDimensions, View, + type ViewInstance, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; @@ -120,7 +121,7 @@ function SessionPreviewContent({ const progress = useSharedValue(0); const dragY = useSharedValue(0); - const menuFirstItemRef = useRef(null); + const menuFirstItemRef = useRef(null); // Read by the first row's ref callback so the callback can keep a stable // identity across renders (a new identity would re-fire it on every commit). const visibleRef = useRef(visible); @@ -185,7 +186,7 @@ function SessionPreviewContent({ // still null. The row's own ref callback fires exactly when the portal has // attached a node; the `visibleRef` guard keeps a later re-render from // stealing focus back after the user has moved on. - const setMenuFirstItem = useCallback((node: View | null) => { + const setMenuFirstItem = useCallback((node: ViewInstance | null) => { menuFirstItemRef.current = node; if (node && visibleRef.current) { moveA11yFocus(menuFirstItemRef); diff --git a/apps/mobile/src/components/agents/session-row-actions.test.ts b/apps/mobile/src/components/agents/session-row-actions.test.ts index 30d1a0ea647..0ac4063f446 100644 --- a/apps/mobile/src/components/agents/session-row-actions.test.ts +++ b/apps/mobile/src/components/agents/session-row-actions.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { sessionResumeUrl } from '@kilocode/app-shared/universal-links'; import * as Clipboard from 'expo-clipboard'; import * as Haptics from 'expo-haptics'; -import { Alert } from 'react-native'; import { buildSessionActionMenuItems, @@ -84,7 +83,7 @@ type PromptButton = { /** Invoke the confirm button of the last `Alert.prompt` with `value`. */ function confirmWith(value: string) { - const call = vi.mocked(Alert.prompt).mock.calls.at(-1); + const call = reactNativeMock.prompt.mock.calls.at(-1); if (!call) { throw new Error('Alert.prompt was not called'); } @@ -95,7 +94,7 @@ function confirmWith(value: string) { describe('showRenamePrompt', () => { afterEach(() => { - vi.mocked(Alert.prompt).mockClear(); + reactNativeMock.prompt.mockClear(); }); it('does not rename when the prefilled title is confirmed unchanged', () => { diff --git a/apps/mobile/src/components/agents/tool-card-file-attachments.mounted.test.tsx b/apps/mobile/src/components/agents/tool-card-file-attachments.mounted.test.tsx index 440e8ba06f9..4c8034ad52e 100644 --- a/apps/mobile/src/components/agents/tool-card-file-attachments.mounted.test.tsx +++ b/apps/mobile/src/components/agents/tool-card-file-attachments.mounted.test.tsx @@ -17,7 +17,7 @@ import { __resetToolCardImageCacheForTests, cacheToolAttachment } from './tool-c type FileInstance = { exists: boolean; uri: string; - write: ReturnType; + writeSync: (content: string) => void; filename: string; }; @@ -35,7 +35,7 @@ const expoFileSystemMock = vi.hoisted(() => { const instance = { exists: false, uri: `file:///cache/tool-card-images/${filename}`, - write: vi.fn(), + writeSync: vi.fn(), filename, directory, }; diff --git a/apps/mobile/src/components/agents/tool-card-image-attachments.mounted.test.tsx b/apps/mobile/src/components/agents/tool-card-image-attachments.mounted.test.tsx index a4525961d6e..bf8bd3387cf 100644 --- a/apps/mobile/src/components/agents/tool-card-image-attachments.mounted.test.tsx +++ b/apps/mobile/src/components/agents/tool-card-image-attachments.mounted.test.tsx @@ -10,7 +10,7 @@ import { __resetToolCardImageCacheForTests, cacheToolAttachment } from './tool-c type FileInstance = { exists: boolean; uri: string; - write: ReturnType; + writeSync: (content: string) => void; filename: string; }; @@ -28,7 +28,7 @@ const expoFileSystemMock = vi.hoisted(() => { const instance = { exists: false, uri: `file:///cache/tool-card-images/${filename}`, - write: vi.fn(), + writeSync: vi.fn(), filename, directory, }; diff --git a/apps/mobile/src/components/agents/tool-card-image-cache.test.ts b/apps/mobile/src/components/agents/tool-card-image-cache.test.ts index f7dcf7c390b..3fa592958d8 100644 --- a/apps/mobile/src/components/agents/tool-card-image-cache.test.ts +++ b/apps/mobile/src/components/agents/tool-card-image-cache.test.ts @@ -9,13 +9,14 @@ import { extensionForMime, getToolCardImageUri, stripDataUrlBase64Prefix, - useToolCardImageUri, } from '@/components/agents/tool-card-image-cache'; type FileInstance = { exists: boolean; uri: string; - write: ReturnType; + written?: { content: string; encoding?: string }; + writeSync: (content: string, options?: { encoding?: string }) => void; + directory?: { name?: string }; filename: string; }; @@ -33,10 +34,12 @@ const expoFileSystemMock = vi.hoisted(() => { }; }); const File = vi.fn(function FileMock(directory: { name?: string }, filename: string) { - const instance = { + const instance: FileInstance = { exists: false, uri: `file:///cache/tool-card-images/${filename}`, - write: vi.fn(), + writeSync(content, options) { + this.written = { content, encoding: options?.encoding }; + }, filename, directory, }; @@ -122,7 +125,7 @@ describe('cacheToolCardImage', () => { expect(expoFileSystemMock.File).toHaveBeenCalled(); const file = fileInstances[0]; expect(file?.filename).toBe('part-1.png'); - expect(file?.write).toHaveBeenCalledWith('QUJD', { encoding: 'base64' }); + expect(file?.written).toEqual({ content: 'QUJD', encoding: 'base64' }); expect(getToolCardImageUri('part-1')).toBe('file:///cache/tool-card-images/part-1.png'); }); @@ -136,8 +139,7 @@ describe('cacheToolCardImage', () => { cacheToolCardImage('part-1', 'image/png', 'data:image/png;base64,BBB'); expect(fileInstances).toHaveLength(1); - expect(fileInstances[0]?.write).toHaveBeenCalledTimes(1); - expect(fileInstances[0]?.write).toHaveBeenCalledWith('AAA', { encoding: 'base64' }); + expect(fileInstances[0]?.written).toEqual({ content: 'AAA', encoding: 'base64' }); }); it('skips the write when the file already exists and still records the URI', () => { @@ -150,7 +152,7 @@ describe('cacheToolCardImage', () => { const instance = { exists: true, uri: `file:///cache/tool-card-images/${filename}`, - write: vi.fn(), + writeSync: vi.fn(), filename, directory, }; @@ -160,7 +162,7 @@ describe('cacheToolCardImage', () => { cacheToolCardImage('part-exists', 'image/png', 'data:image/png;base64,AAA'); - expect(fileInstances[0]?.write).not.toHaveBeenCalled(); + expect(fileInstances[0]?.written).toBeUndefined(); expect(getToolCardImageUri('part-exists')).toBe( 'file:///cache/tool-card-images/part-exists.png' ); @@ -177,7 +179,7 @@ describe('cacheToolCardImage', () => { const instance = { exists: false, uri: `file:///cache/tool-card-images/${filename}`, - write: vi.fn(() => { + writeSync: vi.fn(() => { throw new Error('disk full'); }), filename, @@ -191,13 +193,12 @@ describe('cacheToolCardImage', () => { cacheToolCardImage('part-fail', 'image/png', 'data:image/png;base64,AAA'); }).not.toThrow(); - expect(fileInstances[0]?.write).toHaveBeenCalled(); expect(getToolCardImageUri('part-fail')).toBeUndefined(); // Failure cleared the mark — a retry is allowed and should attempt again. cacheToolCardImage('part-fail', 'image/png', 'data:image/png;base64,AAA'); expect(fileInstances).toHaveLength(2); - expect(fileInstances[1]?.write).toHaveBeenCalledWith('AAA', { encoding: 'base64' }); + expect(fileInstances[1]?.written).toEqual({ content: 'AAA', encoding: 'base64' }); expect(getToolCardImageUri('part-fail')).toBe('file:///cache/tool-card-images/part-fail.png'); }); @@ -227,7 +228,7 @@ describe('cacheToolAttachment', () => { // getSafeCacheFilename({ id: 'part-send', filename: 'report.pdf' }) // → 'part-send-report.pdf' (after sanitization) expect(file?.filename).toBe('part-send-report.pdf'); - expect(file?.write).toHaveBeenCalledWith('QUJD', { encoding: 'base64' }); + expect(file?.written).toEqual({ content: 'QUJD', encoding: 'base64' }); expect(getToolCardImageUri('part-send')).toBe( 'file:///cache/tool-card-images/part-send-report.pdf' ); @@ -238,7 +239,7 @@ describe('cacheToolAttachment', () => { const file = fileInstances[0]; expect(file?.filename).toBe('part-img.png'); - expect(file?.write).toHaveBeenCalledWith('AAA', { encoding: 'base64' }); + expect(file?.written).toEqual({ content: 'AAA', encoding: 'base64' }); }); it('preserves _DOT_ in the partId, replacing only the appended final -_DOT_ suffix', () => { @@ -253,7 +254,7 @@ describe('cacheToolAttachment', () => { // _DOT_ in partId must survive; only final -_DOT_ before ext is replaced → // "part-_DOT_42.png" expect(file?.filename).toBe('part-_DOT_42.png'); - expect(file?.write).toHaveBeenCalledWith('AAA', { encoding: 'base64' }); + expect(file?.written).toEqual({ content: 'AAA', encoding: 'base64' }); }); it('deduplicates same part id across cacheToolAttachment calls', () => { @@ -269,7 +270,7 @@ describe('cacheToolAttachment', () => { }); expect(fileInstances).toHaveLength(1); - expect(fileInstances[0]?.write).toHaveBeenCalledWith('AAA', { encoding: 'base64' }); + expect(fileInstances[0]?.written).toEqual({ content: 'AAA', encoding: 'base64' }); expect(getToolCardImageUri('part-dup')).toBe('file:///cache/tool-card-images/part-dup-img.png'); }); @@ -285,7 +286,7 @@ describe('cacheToolAttachment', () => { // getSafeCacheFilename({ id: 'part-xss', filename: 'vnd.