From 5870ee49a0676752a1f46fbd9b9303ea71177cc3 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 8 Jul 2026 16:40:58 -0400 Subject: [PATCH 01/24] =?UTF-8?q?build(mobile):=20iOS=2027=20SDK=20enablem?= =?UTF-8?q?ent=20=E2=80=94=20scene=20lifecycle,=20entitlement=20+=20pod=20?= =?UTF-8?q?targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt what the iOS 27 SDK requires and set up the config plugins that make it survive expo prebuild (ios/ is gitignored). withSceneLifecycle: iOS 27 makes the UIKit scene lifecycle MANDATORY — the legacy AppDelegate window path is killed at launch ("UIScene life cycle is required for apps built with this SDK"). Expo SDK 57 / RN 0.86 don't ship a SceneDelegate, so the plugin adds a UIApplicationSceneManifest, writes a SceneDelegate that builds the window from the scene and starts RN via the AppDelegate's existing factory, strips the legacy window bootstrap from the AppDelegate (fails loudly if the template drifts), registers the file in the Xcode project, and forwards deep/universal links (which now route to the scene, not the AppDelegate) to RCTLinkingManager so Linking.getInitialURL() keeps working. withPodDeploymentTarget: bump every pod target to the project floor (16.4) in post_install — Xcode 27's minimum is iOS 15, and a few transitive pods still declare 11.0/12.x, which it flags/errors on. withPrivateCloudCompute: add the managed com.apple.developer.private-cloud-compute entitlement, gated on EXPO_PUBLIC_PCC so unprovisioned builds (EAS, shipped) never carry it and keep signing. expo-build-properties: pin ios.deploymentTarget 16.4 so prebuild is reproducible (deployment target stays 16.4 — PCC is weak/availability-gated, not a bump). Verified: app builds + launches on an iOS 27 device; EAS iOS-26 builds stay green (PCC entitlement + Swift both auto-bypass on the current image). --- mobile/app.json | 11 ++ mobile/package-lock.json | 15 ++ mobile/package.json | 1 + mobile/plugins/withPodDeploymentTarget.js | 68 ++++++++ mobile/plugins/withPrivateCloudCompute.js | 38 +++++ mobile/plugins/withSceneLifecycle.js | 199 ++++++++++++++++++++++ 6 files changed, 332 insertions(+) create mode 100644 mobile/plugins/withPodDeploymentTarget.js create mode 100644 mobile/plugins/withPrivateCloudCompute.js create mode 100644 mobile/plugins/withSceneLifecycle.js diff --git a/mobile/app.json b/mobile/app.json index 35ad8807f..17e4f0ba5 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -37,6 +37,9 @@ ], "./plugins/withAppleSigning", "./plugins/withICloudContainer", + "./plugins/withPrivateCloudCompute", + "./plugins/withSceneLifecycle", + "./plugins/withPodDeploymentTarget", "./plugins/withStatusBarStyle", "@react-native-community/datetimepicker", "expo-secure-store", @@ -52,6 +55,14 @@ "backgroundColor": "#0d0d0d" } } + ], + [ + "expo-build-properties", + { + "ios": { + "deploymentTarget": "16.4" + } + } ] ], "experiments": { diff --git a/mobile/package-lock.json b/mobile/package-lock.json index b4d77d1fe..d6b3375b4 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -13,6 +13,7 @@ "@react-native-community/datetimepicker": "9.1.0", "ai": "^7.0.15", "expo": "~57.0.2", + "expo-build-properties": "~57.0.3", "expo-clipboard": "~57.0.0", "expo-constants": "~57.0.2", "expo-document-picker": "~57.0.0", @@ -7412,6 +7413,20 @@ "react-native": "*" } }, + "node_modules/expo-build-properties": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.3.tgz", + "integrity": "sha512-oiqyD583acVmFVdF5nPSYEI7B/1ulOfIJhmfhr3bT51/64jtwaY0FzgVL8C2o23Z+CvCnEL8gOnhtH0sqcRWiA==", + "license": "MIT", + "dependencies": { + "@expo/schema-utils": "^57.0.1", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-clipboard": { "version": "57.0.0", "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index ad09b449b..4dd8d6aba 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -21,6 +21,7 @@ "@react-native-community/datetimepicker": "9.1.0", "ai": "^7.0.15", "expo": "~57.0.2", + "expo-build-properties": "~57.0.3", "expo-clipboard": "~57.0.0", "expo-constants": "~57.0.2", "expo-document-picker": "~57.0.0", diff --git a/mobile/plugins/withPodDeploymentTarget.js b/mobile/plugins/withPodDeploymentTarget.js new file mode 100644 index 000000000..2ff130aab --- /dev/null +++ b/mobile/plugins/withPodDeploymentTarget.js @@ -0,0 +1,68 @@ +/** + * Expo config plugin: force every CocoaPods target's iOS deployment target up to + * the project floor during `pod install`. + * + * Xcode 27's minimum supported deployment target is iOS 15.0. A few transitive + * pods (and CocoaPods' own aggregate/default) still declare 11.0 / 12.0 / 12.4, + * which Xcode 27 flags as warnings (and can escalate to build errors). Expo's + * `expo-build-properties` sets the app + most pods to our floor, but doesn't + * rewrite every straggler config, so we do it in `post_install`. + * + * `ios/` is gitignored and regenerated by `expo prebuild`, so this MUST live in a + * config plugin (Podfile edits by hand don't survive). We append a snippet to the + * generated Podfile's existing `post_install` block via a dangerous mod. + * + * Floor: read from ios.deploymentTarget (expo-build-properties) via + * Podfile.properties.json, defaulting to 16.4 to match app.json. + */ + +const { withDangerousMod } = require("@expo/config-plugins"); +const fs = require("fs"); +const path = require("path"); + +const MARKER = "# cairn: bump pod deployment targets"; + +module.exports = (config) => { + return withDangerousMod(config, [ + "ios", + (cfg) => { + const podfile = path.join(cfg.modRequest.platformProjectRoot, "Podfile"); + let contents = fs.readFileSync(podfile, "utf8"); + + // Idempotent: skip if already injected (prebuild without --clean re-runs mods). + if (contents.includes(MARKER)) return cfg; + + // The snippet raises any target below the floor. Uses the Podfile's own + // `podfile_properties` (already parsed at the top of the generated Podfile). + const snippet = ` + ${MARKER} + min_ios = (podfile_properties['ios.deploymentTarget'] || '16.4') + installer.pods_project.targets.each do |target| + target.build_configurations.each do |bc| + current = bc.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + if current.nil? || current.to_f < min_ios.to_f + bc.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = min_ios + end + end + end +`; + + // Inject just before the closing `end` of the `post_install do |installer|` + // block. That block ends the file's target block; we find the LAST + // `react_native_post_install(...)` call's enclosing block and append after it. + const anchor = /(react_native_post_install\([\s\S]*?\)\s*\n)/; + if (anchor.test(contents)) { + contents = contents.replace(anchor, `$1${snippet}`); + } else { + // Fallback: append a fresh post_install if the RN one isn't present. + contents += ` +post_install do |installer| +${snippet}end +`; + } + + fs.writeFileSync(podfile, contents); + return cfg; + }, + ]); +}; diff --git a/mobile/plugins/withPrivateCloudCompute.js b/mobile/plugins/withPrivateCloudCompute.js new file mode 100644 index 000000000..a24aeb5f4 --- /dev/null +++ b/mobile/plugins/withPrivateCloudCompute.js @@ -0,0 +1,38 @@ +/** + * Expo config plugin: add the Private Cloud Compute entitlement so the app can + * route Apple Foundation Models sessions through PCC (server model, iOS 27+). + * + * Adds entitlement: com.apple.developer.private-cloud-compute = true. + * + * GATED behind the EXPO_PUBLIC_PCC env flag (opt-in), because this is a MANAGED + * entitlement: it must be granted for the App ID (applied for separately) and + * present in the provisioning profile, or code-signing FAILS. Standard EAS / + * shipped builds must NOT carry it until it's provisioned — otherwise every + * build breaks. When the entitlement is granted and you build the iOS 27 SDK + * variant locally (or on a future EAS image), set EXPO_PUBLIC_PCC=1 to inject it. + * + * The native code is already SDK-gated (CAIRN_PCC_SDK in AppleLlm.podspec) and + * runtime-gated (#available(iOS 27) + availability check), so the app runs fine + * without this entitlement — PCC just reports unavailable. This plugin only + * flips on the capability once you're entitled. + * + * See: https://developer.apple.com/documentation/BundleResources/Entitlements/com.apple.developer.private-cloud-compute + */ + +const { withEntitlementsPlist } = require("@expo/config-plugins"); + +const ENTITLEMENT = "com.apple.developer.private-cloud-compute"; + +module.exports = (config) => { + const enabled = process.env.EXPO_PUBLIC_PCC === "1" || process.env.EXPO_PUBLIC_PCC === "true"; + if (!enabled) { + // Opt-in only: without the flag, don't touch entitlements so unprovisioned + // builds (EAS, shipped) keep signing successfully. + return config; + } + + return withEntitlementsPlist(config, (cfg) => { + cfg.modResults[ENTITLEMENT] = true; + return cfg; + }); +}; diff --git a/mobile/plugins/withSceneLifecycle.js b/mobile/plugins/withSceneLifecycle.js new file mode 100644 index 000000000..2a15493c2 --- /dev/null +++ b/mobile/plugins/withSceneLifecycle.js @@ -0,0 +1,199 @@ +/** + * Expo config plugin: adopt the UIKit UIScene lifecycle. + * + * iOS 27's SDK makes scene adoption MANDATORY — an app built against it that + * still uses the legacy `application(_:didFinishLaunchingWithOptions:)` + window + * path is killed at launch with: + * "UIScene life cycle is required for apps built with this SDK." + * + * Expo SDK 57 / React Native 0.86 don't ship a SceneDelegate yet (their + * AppDelegate creates the UIWindow directly), so we add one. Because `ios/` is + * regenerated by `expo prebuild`, this must be a plugin, not a hand edit. + * + * What it does: + * 1. Info.plist — add a UIApplicationSceneManifest whose single configuration + * names `$(PRODUCT_MODULE_NAME).SceneDelegate` as the scene delegate class. + * 2. Writes ios//SceneDelegate.swift — creates the window bound to the + * UIWindowScene and starts React Native using the AppDelegate's existing + * RCTReactNativeFactory (RN's `startReactNative(...inWindow:)` sets the + * rootViewController + makeKeyAndVisible on the window we pass). + * 3. AppDelegate.swift — remove the legacy window creation / startReactNative + * (the scene now owns the window) while keeping the factory setup, and make + * the factory reachable from the scene. + * 4. Registers SceneDelegate.swift in the Xcode project (sources build phase). + * + * The RN root view + factory are unchanged; we only move WHO creates the window. + */ + +const { + withInfoPlist, + withAppDelegate, + withXcodeProject, + withDangerousMod, + IOSConfig, +} = require("@expo/config-plugins"); +const fs = require("fs"); +const path = require("path"); + +const SCENE_DELEGATE_CLASS = "SceneDelegate"; + +// ── 1. Info.plist: scene manifest pointing at our delegate ─────────────────── +function withSceneManifest(config) { + return withInfoPlist(config, (cfg) => { + cfg.modResults.UIApplicationSceneManifest = { + UIApplicationSupportsMultipleScenes: false, + UISceneConfigurations: { + UIWindowSceneSessionRoleApplication: [ + { + UISceneConfigurationName: "Default Configuration", + UISceneDelegateClassName: `$(PRODUCT_MODULE_NAME).${SCENE_DELEGATE_CLASS}`, + }, + ], + }, + }; + return cfg; + }); +} + +// ── 2. Write SceneDelegate.swift ───────────────────────────────────────────── +function sceneDelegateSource() { + return `import UIKit +import React + +// Added by plugins/withSceneLifecycle.js to adopt the UIScene lifecycle required +// by the iOS 27 SDK. Creates the window for the connecting scene and starts +// React Native via the AppDelegate's existing RCTReactNativeFactory. +// +// IMPORTANT: once a SceneDelegate exists, iOS routes deep links / universal +// links to the SCENE, not the AppDelegate — so the AppDelegate's +// open-url / continue-userActivity methods stop firing and +// Linking.getInitialURL() would return nil. We forward all link entry points to +// RCTLinkingManager here (cold launch via connectionOptions, warm via the +// scene open/continue callbacks). +class ${SCENE_DELEGATE_CLASS}: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate, + let factory = appDelegate.reactNativeFactory else { return } + + let window = UIWindow(windowScene: windowScene) + self.window = window + // RN sets window.rootViewController + makeKeyAndVisible internally. + factory.startReactNative(withModuleName: "main", in: window, launchOptions: nil) + + // Cold-launch deep link (custom scheme) → RN Linking.getInitialURL(). + if let urlContext = connectionOptions.urlContexts.first { + RCTLinkingManager.application( + UIApplication.shared, + open: urlContext.url, + options: [:] + ) + } + // Cold-launch universal link. + for userActivity in connectionOptions.userActivities + where userActivity.activityType == NSUserActivityTypeBrowsingWeb { + RCTLinkingManager.application( + UIApplication.shared, + continue: userActivity, + restorationHandler: { _ in } + ) + } + } + + // Warm deep link (custom scheme) while the app is running. + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let url = URLContexts.first?.url else { return } + RCTLinkingManager.application(UIApplication.shared, open: url, options: [:]) + } + + // Warm universal link while the app is running. + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + RCTLinkingManager.application( + UIApplication.shared, + continue: userActivity, + restorationHandler: { _ in } + ) + } +} +`; +} + +function withSceneDelegateFile(config) { + return withDangerousMod(config, [ + "ios", + (cfg) => { + const name = cfg.modRequest.projectName; + const dir = path.join(cfg.modRequest.platformProjectRoot, name); + fs.writeFileSync(path.join(dir, `${SCENE_DELEGATE_CLASS}.swift`), sceneDelegateSource()); + return cfg; + }, + ]); +} + +// ── 3. Strip legacy window bootstrap from AppDelegate ──────────────────────── +function withPatchedAppDelegate(config) { + return withAppDelegate(config, (cfg) => { + const src = cfg.modResults.contents; + + // Already patched (idempotent across prebuilds) — nothing to do. + if (src.includes("handled by SceneDelegate")) { + return cfg; + } + + // Remove the `#if os(iOS)... startReactNative ... #endif` window bootstrap. + // The scene creates the window now; the factory is still built above. + const anchor = /#if os\(iOS\) \|\| os\(tvOS\)[\s\S]*?factory\.startReactNative\([\s\S]*?\)\s*#endif\s*/; + + if (!anchor.test(src)) { + // FAIL LOUDLY: if the Expo AppDelegate template changed and we can't strip + // the legacy window bootstrap, leaving both the AppDelegate window AND the + // SceneDelegate window active causes a black screen / crash. Better to fail + // the build with a clear message than ship a broken app (esp. on EAS). + throw new Error( + "[withSceneLifecycle] Could not find the legacy window bootstrap in " + + "AppDelegate.swift to remove. The Expo AppDelegate template likely " + + "changed. Update the anchor regex in plugins/withSceneLifecycle.js so " + + "the AppDelegate and SceneDelegate don't both create a window.", + ); + } + + cfg.modResults.contents = src.replace( + anchor, + "// Window + React Native start now handled by SceneDelegate (iOS 27 scene lifecycle).\n", + ); + return cfg; + }); +} + +// ── 4. Register SceneDelegate.swift in the Xcode project ───────────────────── +function withSceneDelegateInXcode(config) { + return withXcodeProject(config, (cfg) => { + const project = cfg.modResults; + const name = cfg.modRequest.projectName; + const filePath = `${name}/${SCENE_DELEGATE_CLASS}.swift`; + + // Skip if already added (idempotent across prebuilds). + if (project.hasFile(filePath)) return cfg; + + IOSConfig.XcodeUtils.addBuildSourceFileToGroup({ + filepath: filePath, + groupName: name, + project, + }); + return cfg; + }); +} + +module.exports = (config) => { + config = withSceneManifest(config); + config = withSceneDelegateFile(config); + config = withPatchedAppDelegate(config); + config = withSceneDelegateInXcode(config); + return config; +}; From fb818f6ad04c0231978ac9974f07dd0865dc4aef Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 8 Jul 2026 16:41:49 -0400 Subject: [PATCH 02/24] feat(mobile): Apple Intelligence via Private Cloud Compute (iOS 27) Make Private Cloud Compute the user-facing "Apple Intelligence" chat provider: full 11-tool set, the agent's full system prompt, 32K context, "moderate" reasoning, and quota UI. The small on-device model stays DEV-only behind EXPO_PUBLIC_APPLE_LLM_DEV (too weak for agentic tool use). Native (AppleLlmModule.swift): add useServer + reasoningLevel options; a CAIRN_PCC_SDK-gated iOS-27 path builds LanguageModelSession with PrivateCloudComputeLanguageModel (the unified "some LanguageModel" init is iOS 27+; on-device keeps the concrete SystemLanguageModel init), applies ContextOptions(reasoningLevel:), and maps quota/network errors. Session cache is namespaced by model kind. New availability + quota surface: isServerAvailable, serverUnavailableReason, quotaStatus, showQuotaUpgradeOptions. EAS-safe compile gate (AppleLlm.podspec): define CAIRN_PCC_SDK only when the active iOS SDK is >= 27 (read via xcrun). ALL PCC symbols live under it, so the current EAS image (iOS 26 SDK) compiles them out and builds green; local Xcode 27 compiles them in. Verified: parses clean under both SDKs; full pod build on the iOS 27 SDK succeeds. JS: AppleGenerateOptions gains useServer/reasoningLevel; new AppleReasoningLevel, AppleQuotaStatus, QUOTA_EXCEEDED/NETWORK_UNAVAILABLE codes. apple.ts splits into appleProvider (PCC, full schemas/prompt/32K/reasoning) and appleOnDeviceProvider (dev, lean). providers/index prefers PCC then dev on-device. agent.ts passes AppleLLMError messages through verbatim (quota/network are user-friendly). AiSettingsForm relabels the provider "Apple Intelligence" and adds a quota status line + iCloud+ "Show options". Docs: rewrite the plan with the shipped design, the CAIRN_PCC_SDK gate, and the iOS-27 init/availability API notes. .env.example documents EXPO_PUBLIC_PCC. type-check + lint clean. --- .../plans/apple-foundation-models-provider.md | 60 ++- mobile/.env.example | 34 +- mobile/modules/apple-llm/AppleLlm.types.ts | 30 ++ mobile/modules/apple-llm/index.ts | 71 +++- mobile/modules/apple-llm/ios/AppleLlm.podspec | 15 +- .../apple-llm/ios/AppleLlmModule.swift | 263 +++++++++++- mobile/src/chat/agent.ts | 9 +- mobile/src/chat/ai-config.ts | 3 +- mobile/src/chat/providers/apple.ts | 381 ++++++++++-------- mobile/src/chat/providers/index.ts | 38 +- mobile/src/components/AiSettingsForm.tsx | 73 +++- 11 files changed, 757 insertions(+), 220 deletions(-) diff --git a/docs/plans/apple-foundation-models-provider.md b/docs/plans/apple-foundation-models-provider.md index e46fbd05d..0484dd2c4 100644 --- a/docs/plans/apple-foundation-models-provider.md +++ b/docs/plans/apple-foundation-models-provider.md @@ -1,9 +1,61 @@ # Plan: On-device / Private Cloud Compute Apple Intelligence provider -**Status:** shelved. On-device (iOS 26) implementation exists in the repo but is -**not viable** for Cairn's agentic tool use. Re-implement against **Private Cloud -Compute (PCC)** once the managed entitlement is granted and an **iOS 27 SDK** is -available. +**Status:** PCC IMPLEMENTED (2026-07-08). Private Cloud Compute is now the +user-facing "Apple Intelligence" provider (full tool set, full system prompt, +32K window, `moderate` reasoning, quota UI). The on-device (iOS 26) model is +retained but DEV-ONLY behind `EXPO_PUBLIC_APPLE_LLM_DEV` (too weak for agentic +tool use). Verified: JS type-check + lint clean; Swift parses clean under BOTH +the stable iOS-26 SDK (PCC compiled out) and the beta iOS-27 SDK (PCC compiled +in). Pending device verification on iOS 27 with the granted entitlement. + +### EAS-safety: the CAIRN_PCC_SDK compile-time gate (critical) + +PCC symbols (`PrivateCloudComputeLanguageModel`, `ContextOptions.ReasoningLevel`, +`.quotaLimitReached`, `quotaUsage`) exist ONLY in the iOS 27 SDK. `#available(iOS +27)` is a RUNTIME check — it does NOT stop the compiler needing those symbols to +exist. `#if canImport(FoundationModels)` is TRUE on both SDK 26 and 27 (only the +PCC types within it are new), so it can't gate them either. The current EAS iOS +image is `macos-tahoe-26.4-xcode-26.4` (iOS 26 SDK) — referencing PCC symbols +unconditionally there FAILS the build. + +Fix (`modules/apple-llm/ios/AppleLlm.podspec`): read the active iOS SDK version +at pod-eval time (`xcrun --sdk iphoneos --show-sdk-version`) and define the +`CAIRN_PCC_SDK` compilation condition ONLY when the SDK major is ≥ 27. ALL PCC +symbol references live inside `#if CAIRN_PCC_SDK`. Result: +- EAS / stable Xcode 26 (iOS 26 SDK) → flag off → PCC compiled out → builds green, + PCC reports unavailable at runtime. +- Local Xcode 27 beta (`/Applications/Xcode-beta.app`, iOS 27 SDK) → flag on → + PCC compiled in + runs. Point the toolchain at it: + `sudo xcode-select -s /Applications/Xcode-beta.app/Contents/Developer` + (or prefix a build with `DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer`). +- Future EAS iOS 27 image → flips on automatically, zero code change. + +### SDK API gotcha: LanguageModelSession init + `LanguageModel` are iOS-27-only + +Verified against the iOS 27 SDK `.swiftinterface` (build error surfaced it): +- The **unified** `LanguageModelSession.init(model: some LanguageModel, tools:, instructions:)` and the `LanguageModel` protocol itself are **iOS 27.0+**. Passing a `PrivateCloudComputeLanguageModel` REQUIRES this init. +- iOS 26 only has the **concrete** `init(model: SystemLanguageModel = .default, tools:, instructions:)`. +- So there is NO single shared init/`resolveModel(): any LanguageModel` (that annotation compiled under `@available(iOS 26)` but references 27-only symbols → error). `ensureSession` must branch: server path (`#if CAIRN_PCC_SDK` + `#available(iOS 27)`) builds with `PrivateCloudComputeLanguageModel()` via the `some LanguageModel` init; on-device path uses the concrete-`SystemLanguageModel` init. +- Likewise `streamResponse(to:options:contextOptions:)` is iOS 27+ (base `streamResponse(to:options:)` is iOS 26); reasoning is applied only on the server path. +- Quota/availability shapes confirmed: `PrivateCloudComputeLanguageModel()`, `.availability → .available/.unavailable(.deviceNotEligible|.systemNotReady)`, `.quotaUsage.status → .belowLimit(BelowLimit{isApproachingLimit})`, `.isLimitReached`, `.limitIncreaseSuggestion?.show()`, `.resetDate`, `Error.quotaLimitReached(_)`. +- **Verified:** AppleLlm pod `xcodebuild BUILD SUCCEEDED` under iOS 27 SDK (flag on); parses clean under iOS 26 SDK (flag off). + +### Entitlement gating (critical) + +`com.apple.developer.private-cloud-compute` is a MANAGED entitlement +without it being provisioned for the App ID BREAKS code-signing on every build. +`plugins/withPrivateCloudCompute.js` therefore injects it ONLY when +`EXPO_PUBLIC_PCC=1`. Set that flag only once the entitlement is granted AND you +build the iOS 27 SDK variant. The app runs fine without it (PCC just reports +unavailable), so shipped/EAS builds stay signable. + +--- + +## Original shelving context (on-device) + +On-device (iOS 26) implementation exists but is **not viable** for Cairn's +agentic tool use. Re-implemented against **Private Cloud Compute (PCC)** — see +status above. --- diff --git a/mobile/.env.example b/mobile/.env.example index d8ef125c5..d9908c5b8 100644 --- a/mobile/.env.example +++ b/mobile/.env.example @@ -40,14 +40,28 @@ EXPO_PUBLIC_TOOLKIT_URL= # ───────────────────────────────────────────────────────────────────────────── # Apple on-device AI (experimental, dev-only) # ───────────────────────────────────────────────────────────────────────────── -# The Apple Foundation Models (on-device Apple Intelligence) provider is -# EXPERIMENTAL and not yet viable for Cairn's agentic tool use — the on-device -# model is too small / context too tight (see -# docs/plans/apple-foundation-models-provider.md; the plan is to redo it against -# Private Cloud Compute on iOS 27). It is therefore HIDDEN from end users. -# -# Set to 1 in your LOCAL .env.local to expose the "On-device" provider in -# Settings → AI so you can keep iterating on a device. Leave BLANK/unset for EAS -# and any shipped build — the provider then never appears or resolves. (The -# native module still compiles but stays dormant unless this flag is on.) +# The on-device Apple Foundation Models model is EXPERIMENTAL and not viable for +# Cairn's agentic tool use — the model is too small / context too tight (fixed +# 4096 tokens). The USER-FACING Apple provider is Private Cloud Compute (below), +# NOT this. The on-device model is HIDDEN from end users. +# +# Set to 1 in your LOCAL .env.local to expose the on-device model in Settings → +# AI so you can keep iterating. Leave BLANK/unset for EAS and shipped builds. +# (The native module still compiles but stays dormant unless this flag is on.) EXPO_PUBLIC_APPLE_LLM_DEV= + +# ───────────────────────────────────────────────────────────────────────────── +# Apple Intelligence via Private Cloud Compute (iOS 27+) +# ───────────────────────────────────────────────────────────────────────────── +# PCC is the USER-FACING Apple provider: 32K context, stronger reasoning, no API +# key, privacy-preserving. It only compiles into the app when built against the +# iOS 27+ SDK (the AppleLlm podspec auto-defines CAIRN_PCC_SDK from the active +# SDK), and only runs on an eligible iOS 27+ device — so it's a no-op on the +# current EAS image (iOS 26 SDK) and simply reports unavailable. +# +# Set to 1 ONLY once the managed entitlement +# `com.apple.developer.private-cloud-compute` is granted for the App ID AND you +# build with the iOS 27 SDK (Xcode 27). This makes withPrivateCloudCompute inject +# the entitlement. Leave BLANK otherwise — adding an unprovisioned managed +# entitlement breaks code-signing on every build. +EXPO_PUBLIC_PCC= diff --git a/mobile/modules/apple-llm/AppleLlm.types.ts b/mobile/modules/apple-llm/AppleLlm.types.ts index 27baff2a9..e673ee584 100644 --- a/mobile/modules/apple-llm/AppleLlm.types.ts +++ b/mobile/modules/apple-llm/AppleLlm.types.ts @@ -24,6 +24,32 @@ export interface AppleGenerateOptions { temperature?: number; maxTokens?: number; system?: string; + /** + * Route through Private Cloud Compute (server model, iOS 27+) instead of the + * on-device model. Falls back to on-device automatically below iOS 27 or when + * PCC is unavailable. + */ + useServer?: boolean; + /** + * PCC reasoning effort. Ignored on-device / when omitted (model default). + * Deeper reasoning trades latency + context for stronger multi-step analysis. + */ + reasoningLevel?: AppleReasoningLevel; +} + +/** PCC reasoning-effort levels (iOS 27+). */ +export type AppleReasoningLevel = "light" | "moderate" | "deep"; + +/** PCC daily-quota snapshot (from `quotaStatus()`). */ +export interface AppleQuotaStatus { + /** False when PCC/iOS 27 isn't present — callers should hide quota UI. */ + available: boolean; + status: "below" | "approaching" | "exceeded" | "unknown"; + isLimitReached: boolean; + /** Whether an iCloud+ upgrade suggestion can be presented. */ + canUpgrade: boolean; + /** ISO8601 quota-reset date, or "" when unknown / well below the limit. */ + resetDate: string; } /** Streaming events emitted by the native module, keyed by `requestId`. */ @@ -73,6 +99,10 @@ export const AppleLLMErrorCodes = { ToolCallError: "TOOL_CALL_ERROR", ContextWindowExceeded: "CONTEXT_WINDOW_EXCEEDED", Cancelled: "CANCELLED", + /** Private Cloud Compute daily request quota reached (iOS 27+). */ + QuotaExceeded: "QUOTA_EXCEEDED", + /** PCC request failed with no network (PCC is online-only). */ + NetworkUnavailable: "NETWORK_UNAVAILABLE", } as const; export type AppleLLMErrorCode = diff --git a/mobile/modules/apple-llm/index.ts b/mobile/modules/apple-llm/index.ts index bebda83d9..26f1dad56 100644 --- a/mobile/modules/apple-llm/index.ts +++ b/mobile/modules/apple-llm/index.ts @@ -3,6 +3,7 @@ import { NativeModule, requireOptionalNativeModule } from "expo"; import type { AppleGenerateOptions, AppleLlmEvents, + AppleQuotaStatus, AppleTool, } from "./AppleLlm.types"; @@ -19,6 +20,18 @@ declare class AppleLlmNativeModule extends NativeModule { isAvailable(): boolean; /** Human-readable reason when unavailable (empty when available). */ unavailableReason(): string; + /** + * True when Private Cloud Compute (the server model) can serve requests: + * iOS 27+, an eligible device, PCC ready. Always false when the app was built + * against a pre-iOS-27 SDK (PCC compiled out) — see the CAIRN_PCC_SDK gate. + */ + isServerAvailable(): boolean; + /** Human-readable reason PCC is unavailable (empty when available). */ + serverUnavailableReason(): string; + /** PCC daily-quota snapshot as a JSON string (parse to AppleQuotaStatus). */ + quotaStatus(): string; + /** Present the system iCloud+ upgrade sheet. Returns whether one was shown. */ + showQuotaUpgradeOptions(): boolean; /** Best-effort token count (-1 when unavailable). */ countTokens(text: string): Promise; /** @@ -28,6 +41,7 @@ declare class AppleLlmNativeModule extends NativeModule { * `onToolCall` / `onDone` / `onError` events (tokens/done/error keyed by * `requestId`, tool calls by `sessionId`). When the model calls a tool, an * `onToolCall` fires — execute it and answer with `resolveToolCall` to resume. + * Set `options.useServer` to route through PCC (iOS 27+, else on-device). */ generate( requestId: string, @@ -38,8 +52,11 @@ declare class AppleLlmNativeModule extends NativeModule { ): Promise; /** Drop a persistent session so the next generate() gets a fresh context window. */ resetSession(sessionId: string): void; - /** Warm the model for a session to cut first-token latency. Best-effort. */ - prewarm(sessionId: string, system: string | undefined, tools: AppleTool[]): void; + /** + * Warm the model for a session to cut first-token latency. Best-effort. + * `useServer` selects which model (PCC vs on-device) to warm. + */ + prewarm(sessionId: string, system: string | undefined, tools: AppleTool[], useServer: boolean): void; /** * Resume a suspended tool call. Pass the tool result as a JSON string, or a * non-empty `errorMessage` to fail the call. @@ -74,3 +91,53 @@ export function appleLlmUnavailableReason(): string { return "On-device AI isn't available on this device."; } } + +/** + * Whether Private Cloud Compute (the server model) can run right now: an iOS 27+ + * SDK build on an eligible iOS 27+ device with PCC ready. False on the current + * EAS image (built against the iOS 26 SDK — PCC compiled out), so shipped builds + * simply won't offer PCC until an iOS 27 SDK build is produced. + */ +export function isAppleServerAvailable(): boolean { + try { + return AppleLlm?.isServerAvailable() ?? false; + } catch { + return false; + } +} + +/** Reason PCC is unavailable, for surfacing in the UI. */ +export function appleServerUnavailableReason(): string { + try { + if (!AppleLlm) return "Private Cloud Compute isn't available in this build."; + return AppleLlm.serverUnavailableReason() || "Private Cloud Compute is unavailable."; + } catch { + return "Private Cloud Compute isn't available on this device."; + } +} + +/** Current PCC daily-quota snapshot. `available:false` when PCC isn't present. */ +export function appleQuotaStatus(): AppleQuotaStatus { + const fallback: AppleQuotaStatus = { + available: false, + status: "unknown", + isLimitReached: false, + canUpgrade: false, + resetDate: "", + }; + try { + if (!AppleLlm) return fallback; + return { ...fallback, ...(JSON.parse(AppleLlm.quotaStatus()) as Partial) }; + } catch { + return fallback; + } +} + +/** Present the system iCloud+ upgrade sheet (no-op when unavailable). */ +export function showAppleQuotaUpgrade(): boolean { + try { + return AppleLlm?.showQuotaUpgradeOptions() ?? false; + } catch { + return false; + } +} diff --git a/mobile/modules/apple-llm/ios/AppleLlm.podspec b/mobile/modules/apple-llm/ios/AppleLlm.podspec index c08ee7ba6..dfe43ef28 100644 --- a/mobile/modules/apple-llm/ios/AppleLlm.podspec +++ b/mobile/modules/apple-llm/ios/AppleLlm.podspec @@ -23,9 +23,22 @@ Pod::Spec.new do |s| # the binary must load on older OSes (all API use is `if #available` gated). s.weak_framework = 'FoundationModels' + # Private Cloud Compute (PrivateCloudComputeLanguageModel, ContextOptions + # reasoning, quota APIs) only exists in the iOS 27+ SDK. Those symbols are + # referenced ONLY inside `#if CAIRN_PCC_SDK`, and we define that flag only when + # building against an iOS 27+ SDK. This keeps builds green on the current EAS + # image (Xcode 26 / iOS 26 SDK, where the symbols don't exist) — the PCC code + # is compiled out — while lighting up automatically under Xcode 27 locally and + # on future EAS images. A runtime `#available(iOS 27)` check still gates + # execution on-device. + sdk_version = `xcrun --sdk iphoneos --show-sdk-version 2>/dev/null`.strip + sdk_major = sdk_version.split('.').first.to_i + pcc_flags = sdk_major >= 27 ? 'CAIRN_PCC_SDK' : '' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', - 'SWIFT_COMPILATION_MODE' => 'wholemodule' + 'SWIFT_COMPILATION_MODE' => 'wholemodule', + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS' => "$(inherited) #{pcc_flags}".strip } s.source_files = "**/*.{h,m,swift}" diff --git a/mobile/modules/apple-llm/ios/AppleLlmModule.swift b/mobile/modules/apple-llm/ios/AppleLlmModule.swift index 6400fa8b1..34642da28 100644 --- a/mobile/modules/apple-llm/ios/AppleLlmModule.swift +++ b/mobile/modules/apple-llm/ios/AppleLlmModule.swift @@ -23,6 +23,11 @@ private enum AppleLlmCode: String { case toolCallError = "TOOL_CALL_ERROR" case contextWindowExceeded = "CONTEXT_WINDOW_EXCEEDED" case cancelled = "CANCELLED" + // Private Cloud Compute: the user hit their daily request quota. Distinct from + // rate limiting — they wait for the reset date or upgrade iCloud+. + case quotaExceeded = "QUOTA_EXCEEDED" + // PCC request failed with no network (PCC is online-only). JS retries on-device. + case networkUnavailable = "NETWORK_UNAVAILABLE" } /// A tool the model may call, mirrored from Cairn's AiTool. `jsonSchema` is a @@ -39,6 +44,13 @@ struct AppleLlmOptions: Record { @Field var temperature: Double? @Field var maxTokens: Int? @Field var system: String? + /// Route this session through Private Cloud Compute (server model, iOS 27+) + /// instead of the on-device model. Falls back to on-device below iOS 27. + @Field var useServer: Bool = false + /// PCC reasoning effort: "light" | "moderate" | "deep". Ignored on-device and + /// when nil (uses the model default). Deeper reasoning trades latency + more + /// of the 32K window for stronger multi-step analysis. + @Field var reasoningLevel: String? } public class AppleLlmModule: Module { @@ -78,6 +90,33 @@ public class AppleLlmModule: Module { return Self.availabilityState().reason } + // Whether Private Cloud Compute (the server model) can serve requests right + // now: iOS 27+, an Apple-Intelligence-eligible device, and PCC ready. False + // everywhere else (older OS, ineligible device, simulator). + Function("isServerAvailable") { () -> Bool in + return Self.serverAvailabilityState().available + } + + // Human-readable reason PCC is unavailable (empty when available). + Function("serverUnavailableReason") { () -> String in + return Self.serverAvailabilityState().reason + } + + // Current PCC daily-quota snapshot as a JSON string, so the JS/UI can show a + // usage indicator and an iCloud+ upgrade path. Fields: + // { available: Bool, status: "below"|"approaching"|"exceeded"|"unknown", + // isLimitReached: Bool, canUpgrade: Bool, resetDate: String? (ISO8601) } + // `available:false` when PCC/iOS 27 isn't present — callers should hide the UI. + Function("quotaStatus") { () -> String in + return Self.quotaStatusJson() + } + + // Present Apple's system iCloud+ upgrade sheet when the user is at/near quota. + // No-op when PCC is unavailable or no upgrade suggestion exists. Best-effort. + Function("showQuotaUpgradeOptions") { () -> Bool in + return Self.showQuotaUpgrade() + } + // Best-effort token count. Returns -1 ("unknown"): the current // FoundationModels SDK exposes no public token-counting API. JS surface kept // so a future SDK can fill it in without a bridge change. @@ -100,16 +139,21 @@ public class AppleLlmModule: Module { } // Drop a persistent session (e.g. when the user clears the chat) so the next - // generate() starts a fresh 4096-token context window. + // generate() starts a fresh context window. Clears both the on-device and + // PCC variants for the thread (sessions are namespaced by model kind). Function("resetSession") { (sessionId: String) in - self.withState { _ = $0.sessionStore.removeValue(forKey: sessionId) } + self.withState { + $0.sessionStore.removeValue(forKey: "device:" + sessionId) + $0.sessionStore.removeValue(forKey: "server:" + sessionId) + } } // Warm the model for a session to reduce first-token latency. Best-effort. - Function("prewarm") { (sessionId: String, system: String?, tools: [AppleLlmTool]) in + Function("prewarm") { (sessionId: String, system: String?, tools: [AppleLlmTool], useServer: Bool) in #if canImport(FoundationModels) if #available(iOS 26.0, *) { - if let session = try? self.ensureSession(sessionId: sessionId, system: system, tools: tools) { + let server = useServer && Self.serverAvailabilityState().available + if let session = try? self.ensureSession(sessionId: sessionId, system: system, tools: tools, useServer: server) { session.prewarm() } } @@ -199,15 +243,100 @@ public class AppleLlmModule: Module { } #endif + // MARK: - Private Cloud Compute availability + quota + + /// Whether the server (PCC) model can serve requests. Requires iOS 27+, an + /// eligible device, and the framework reporting `.available`. Always false when + /// built against a pre-iOS-27 SDK (PCC symbols compiled out via CAIRN_PCC_SDK). + private static func serverAvailabilityState() -> (available: Bool, reason: String) { + #if CAIRN_PCC_SDK + if #available(iOS 27.0, *) { + switch PrivateCloudComputeLanguageModel().availability { + case .available: + return (true, "") + case .unavailable(.deviceNotEligible): + return (false, "This device doesn't support Apple Intelligence.") + case .unavailable(.systemNotReady): + return (false, "Private Cloud Compute isn't ready yet. Try again shortly.") + case .unavailable: + return (false, "Private Cloud Compute is unavailable.") + @unknown default: + return (false, "Private Cloud Compute is unavailable.") + } + } + return (false, "Requires iOS 27 or newer.") + #else + return (false, "Private Cloud Compute requires iOS 27 or newer.") + #endif + } + + /// PCC daily-quota snapshot as JSON (see the `quotaStatus` Function doc). + private static func quotaStatusJson() -> String { + #if CAIRN_PCC_SDK + if #available(iOS 27.0, *), case .available = PrivateCloudComputeLanguageModel().availability { + let model = PrivateCloudComputeLanguageModel() + let usage = model.quotaUsage + var status = "unknown" + var approaching = false + if usage.isLimitReached { + status = "exceeded" + } else if case .belowLimit(let info) = usage.status { + approaching = info.isApproachingLimit + status = approaching ? "approaching" : "below" + } + let iso: String + if let reset = usage.resetDate { + let fmt = ISO8601DateFormatter() + iso = fmt.string(from: reset) + } else { + iso = "" + } + let dict: [String: Any] = [ + "available": true, + "status": status, + "isLimitReached": usage.isLimitReached, + "canUpgrade": usage.limitIncreaseSuggestion != nil, + "resetDate": iso, + ] + if let data = try? JSONSerialization.data(withJSONObject: dict), + let json = String(data: data, encoding: .utf8) { + return json + } + } + #endif + return "{\"available\":false,\"status\":\"unknown\",\"isLimitReached\":false,\"canUpgrade\":false,\"resetDate\":\"\"}" + } + + /// Present the system iCloud+ upgrade sheet. Returns whether a suggestion was shown. + private static func showQuotaUpgrade() -> Bool { + #if CAIRN_PCC_SDK + if #available(iOS 27.0, *), case .available = PrivateCloudComputeLanguageModel().availability { + if let suggestion = PrivateCloudComputeLanguageModel().quotaUsage.limitIncreaseSuggestion { + suggestion.show() + return true + } + } + #endif + return false + } + // MARK: - Generation #if canImport(FoundationModels) @available(iOS 26.0, *) private func startGeneration(requestId: String, sessionId: String, prompt: String, tools: [AppleLlmTool], options: AppleLlmOptions) { - guard case .available = SystemLanguageModel.default.availability else { - let state = Self.availabilityState() - emitError(requestId, .modelUnavailable, state.reason.isEmpty ? "Apple Intelligence is unavailable." : state.reason) - return + // Resolve the effective model kind: PCC only when requested AND actually + // available (iOS 27 + eligible device). Otherwise fall back to on-device. + let useServer = options.useServer && Self.serverAvailabilityState().available + + if useServer { + // PCC availability was just checked; on-device check is skipped for server. + } else { + guard case .available = SystemLanguageModel.default.availability else { + let state = Self.availabilityState() + emitError(requestId, .modelUnavailable, state.reason.isEmpty ? "Apple Intelligence is unavailable." : state.reason) + return + } } let promptText = prompt.trimmingCharacters(in: .whitespacesAndNewlines) @@ -217,10 +346,12 @@ public class AppleLlmModule: Module { } // Reuse (or lazily create) the persistent session for this chat thread. The - // session carries the transcript + tools + instructions across turns. + // session carries the transcript + tools + instructions across turns. The + // cache key includes the model kind so an on-device and a PCC session for + // the same thread never collide. let session: LanguageModelSession do { - session = try ensureSession(sessionId: sessionId, system: options.system, tools: tools) + session = try ensureSession(sessionId: sessionId, system: options.system, tools: tools, useServer: useServer) } catch let e as AppleLlmSchemaError { if case .unsupported(let m) = e { emitError(requestId, .invalidSchema, "A tool schema couldn't be used by Apple Foundation Models: \(m)") @@ -242,7 +373,13 @@ public class AppleLlmModule: Module { guard let self else { return } var previous = "" do { - let stream = session.streamResponse(to: promptText, options: genOptions) + let stream = Self.makeStream( + session: session, + prompt: promptText, + genOptions: genOptions, + useServer: useServer, + reasoningLevel: options.reasoningLevel + ) for try await snapshot in stream { if Task.isCancelled { self.emitError(requestId, .cancelled, "Generation cancelled.") @@ -277,13 +414,43 @@ public class AppleLlmModule: Module { storeTask(task, for: requestId) } - /// Fetch the cached session for `sessionId`, or build one bound with the given - /// instructions + tools. Tools/instructions are fixed at creation time (Apple's - /// design), which is fine because Cairn's tool set and system prompt are stable - /// for the life of a chat thread. + /// Build the response stream, applying PCC `ContextOptions(reasoningLevel:)` + /// when running on the server model (iOS 27+). On-device ignores reasoning. + @available(iOS 26.0, *) + private static func makeStream( + session: LanguageModelSession, + prompt: String, + genOptions: GenerationOptions, + useServer: Bool, + reasoningLevel: String? + ) -> LanguageModelSession.ResponseStream { + #if CAIRN_PCC_SDK + if useServer, #available(iOS 27.0, *), let level = Self.reasoning(from: reasoningLevel) { + return session.streamResponse( + to: prompt, + options: genOptions, + contextOptions: ContextOptions(reasoningLevel: level) + ) + } + #endif + return session.streamResponse(to: prompt, options: genOptions) + } + + /// Fetch the cached session for `sessionId` (namespaced by model kind), or + /// build one bound with the given instructions + tools. Tools/instructions are + /// fixed at creation time (Apple's design), which is fine because Cairn's tool + /// set and system prompt are stable for the life of a chat thread. + /// + /// NOTE: the initializers differ by OS. iOS 26 has + /// `init(model: SystemLanguageModel = .default, ...)` (concrete). The unified + /// `init(model: some LanguageModel, ...)` — needed to pass a + /// PrivateCloudComputeLanguageModel — is iOS 27+ ONLY. So the server path uses + /// the 27-only init under `#if CAIRN_PCC_SDK`, and the on-device path uses the + /// 26 concrete-SystemLanguageModel init. There is no single shared init. @available(iOS 26.0, *) - private func ensureSession(sessionId: String, system: String?, tools: [AppleLlmTool]) throws -> LanguageModelSession { - if let existing = withState({ $0.sessionStore[sessionId] }) as? LanguageModelSession { + private func ensureSession(sessionId: String, system: String?, tools: [AppleLlmTool], useServer: Bool) throws -> LanguageModelSession { + let cacheKey = (useServer ? "server:" : "device:") + sessionId + if let existing = withState({ $0.sessionStore[cacheKey] }) as? LanguageModelSession { return existing } let bridge = self.toolBridge @@ -300,18 +467,68 @@ public class AppleLlmModule: Module { ) } let instructions = (system?.isEmpty == false) ? Instructions(system!) : nil - let session = LanguageModelSession(model: SystemLanguageModel.default, tools: bridgedTools, instructions: instructions) - withState { $0.sessionStore[sessionId] = session } + + let session: LanguageModelSession + #if CAIRN_PCC_SDK + if useServer, #available(iOS 27.0, *) { + // iOS 27 unified init taking `some LanguageModel` (PCC conforms to it). + session = LanguageModelSession( + model: PrivateCloudComputeLanguageModel(), + tools: bridgedTools, + instructions: instructions + ) + } else { + // On-device: iOS 26 concrete-SystemLanguageModel init. + session = LanguageModelSession( + model: SystemLanguageModel.default, + tools: bridgedTools, + instructions: instructions + ) + } + #else + // Built against a pre-iOS-27 SDK: on-device model only. + session = LanguageModelSession( + model: SystemLanguageModel.default, + tools: bridgedTools, + instructions: instructions + ) + #endif + + withState { $0.sessionStore[cacheKey] = session } return session } + /// Map a JS reasoning-level string to the FoundationModels enum (iOS 27+). + #if CAIRN_PCC_SDK + @available(iOS 27.0, *) + private static func reasoning(from level: String?) -> ContextOptions.ReasoningLevel? { + switch level?.lowercased() { + case "light": return .light + case "moderate": return .moderate + case "deep": return .deep + default: return nil + } + } + #endif + @available(iOS 26.0, *) private static func mapError(_ error: Error) -> (AppleLlmCode, String) { + // Private Cloud Compute daily-quota exhaustion (iOS 27+). Checked first so + // the JS side can steer the user to an iCloud+ upgrade instead of retrying. + #if CAIRN_PCC_SDK + if #available(iOS 27.0, *) { + if let pccError = error as? PrivateCloudComputeLanguageModel.Error { + if case .quotaLimitReached = pccError { + return (.quotaExceeded, "You've reached your daily Private Cloud Compute limit. It resets later, or upgrade iCloud+ for more.") + } + } + } + #endif if let genError = error as? LanguageModelSession.GenerationError { switch genError { case .exceededContextWindowSize: - return (.contextWindowExceeded, "The request exceeded the model's 4096-token context window.") + return (.contextWindowExceeded, "The request exceeded the model's context window.") case .assetsUnavailable: return (.modelUnavailable, "The on-device model assets aren't available yet.") default: @@ -321,6 +538,12 @@ public class AppleLlmModule: Module { if let toolError = error as? AppleLlmToolError { return (.toolCallError, toolError.message) } + // A PCC request with no connectivity surfaces as a URL/network error; flag it + // so JS can retry on-device. + let ns = error as NSError + if ns.domain == NSURLErrorDomain { + return (.networkUnavailable, "Private Cloud Compute needs a connection. Reconnect and try again.") + } return (.generationError, error.localizedDescription) } diff --git a/mobile/src/chat/agent.ts b/mobile/src/chat/agent.ts index 49ac20d3d..05faa39c5 100644 --- a/mobile/src/chat/agent.ts +++ b/mobile/src/chat/agent.ts @@ -160,8 +160,13 @@ export async function runAgent( } } } catch (e) { - const msg = - e instanceof Error && /network|fetch|abort|failed|\(5\d\d\)/i.test(e.message) + // AppleLLMError already carries a user-friendly message (quota, PCC network, + // unavailable, etc.) — pass it through verbatim rather than prefixing + // "Error:". Otherwise map generic network failures to a connection hint. + const isAppleErr = e instanceof Error && e.name === "AppleLLMError"; + const msg = isAppleErr + ? (e as Error).message + : e instanceof Error && /network|fetch|abort|failed|\(5\d\d\)/i.test(e.message) ? "Chat needs a connection. Reconnect and try again." : `Error: ${e instanceof Error ? e.message : String(e)}`; onEvent?.({ type: "error", text: msg }); diff --git a/mobile/src/chat/ai-config.ts b/mobile/src/chat/ai-config.ts index 51d8ddccb..8e033744e 100644 --- a/mobile/src/chat/ai-config.ts +++ b/mobile/src/chat/ai-config.ts @@ -25,7 +25,8 @@ const SECURE_KEY_APIKEY = "ai.openai.apiKey"; // secure-store key /** * Which backend the user prefers when more than one is available. - * - "apple": on-device Apple Foundation Models (offline, no key; iOS 26+ only) + * - "apple": Apple Intelligence — Private Cloud Compute (iOS 27+, no key, + * privacy-preserving) when available, else the dev on-device model. * - "rork": built-in first-party endpoint (network) * - "openai": user-supplied OpenAI-compatible endpoint (network) */ diff --git a/mobile/src/chat/providers/apple.ts b/mobile/src/chat/providers/apple.ts index 20b365b49..8a93ebb45 100644 --- a/mobile/src/chat/providers/apple.ts +++ b/mobile/src/chat/providers/apple.ts @@ -1,31 +1,31 @@ /** - * Apple on-device Foundation Models provider (Apple Intelligence). + * Apple Foundation Models provider (Apple Intelligence) — two model kinds behind + * one native module (modules/apple-llm), NOT a third-party package. * - * Wraps our own native module (modules/apple-llm) — NOT a third-party package — - * which streams tokens from Apple's FoundationModels framework on iOS 26+. - * Everything runs on-device: no network, no API key, works offline. + * 1. PRIVATE CLOUD COMPUTE (server, iOS 27+) — the USER-FACING provider. + * 32K context + stronger reasoning, no API key, privacy-preserving. Uses the + * FULL Cairn tool set, the agent's full system prompt, and rich tool schemas + * (the on-device leanness isn't needed with 32K). Online-only; on a network + * failure the caller can retry another provider. Daily per-user quota (see + * quota handling below). This is what `appleProvider` resolves to. * - * TUNED FOR A SMALL ON-DEVICE MODEL (per Apple's guidance): - * - Persistent session per chat thread: we keep ONE native LanguageModelSession - * (keyed by a sessionId) and reuse it, so FoundationModels holds the - * transcript natively instead of us re-sending flattened history each turn. - * Better grounding, far fewer tokens. A new chat → resetAppleSession() → - * fresh 4096-token window. - * - Short, imperative system prompt (APPLE_SYSTEM), not the long frontier-model - * one — the 3B model gets confused by long/indirect instructions. - * - Lean tool schemas: verbose descriptions are stripped before sending, since - * schema text counts against the 4096-token window. + * 2. ON-DEVICE (iOS 26+) — DEV-ONLY, hidden behind EXPO_PUBLIC_APPLE_LLM_DEV. + * The ~3B model + fixed 4096-token window proved too weak for Cairn's agentic + * tool use (see docs/plans/apple-foundation-models-provider.md), so it's kept + * only for local iteration: terse system prompt + lean schemas + small reply + * cap. This is `appleOnDeviceProvider`. * - * TOOL-CALLING (native): each Cairn tool's JSON Schema becomes a native - * GenerationSchema via guided generation, so the model is *constrained* to emit - * valid arguments (no text parsing). Apple drives the multi-tool turn inside one - * generate(); on each tool call the native module suspends and fires onToolCall. - * We execute the tool locally (TOOL_MAP → expo-sqlite), resolve it back into the - * turn, and surface a `tool-executed` StreamEvent so agent.ts shows it in the - * tool-trail WITHOUT re-running it. + * TOOL-CALLING (native, both kinds): each Cairn tool's JSON Schema becomes a + * native GenerationSchema via guided generation, so the model is *constrained* + * to emit valid arguments (no text parsing). Apple drives the multi-tool turn + * inside one generate(); on each tool call the native module suspends and fires + * onToolCall. We execute the tool locally (TOOL_MAP → expo-sqlite), resolve it + * back into the turn, and surface a `tool-executed` StreamEvent so agent.ts + * shows it in the tool-trail WITHOUT re-running it. * - * CONTEXT WINDOW: fixed 4096 tokens (instructions + transcript + tools + output). - * Overflow surfaces as AppleLLMErrorCodes.ContextWindowExceeded. + * PERSISTENT SESSION per chat thread (both kinds): ONE native LanguageModelSession + * per sessionId + model kind; reuse keeps the transcript natively (better + * grounding, fewer tokens). A new chat → resetAppleSession() → fresh window. */ import { @@ -33,7 +33,9 @@ import { AppleLLMError, AppleLLMErrorCodes, isAppleLlmAvailable, + isAppleServerAvailable, type AppleErrorEvent, + type AppleReasoningLevel, type AppleTool, type AppleToolCallEvent, } from "@modules/apple-llm"; @@ -49,28 +51,43 @@ import { } from "./types"; /** - * Dev-only gate. The Apple on-device provider is experimental and NOT viable for - * Cairn's agentic tool use yet (see docs/plans/apple-foundation-models-provider.md), - * so it's hidden from end users. It's exposed only in LOCAL builds where the - * git-ignored `.env` sets EXPO_PUBLIC_APPLE_LLM_DEV=1 — EAS builds don't get that - * file, so the provider never appears or resolves in shipped apps. (The native - * module still compiles; it's dormant unless this flag turns the provider on.) + * Dev-only gate for the ON-DEVICE model. It's experimental and NOT viable for + * Cairn's agentic tool use, so it's hidden from end users — exposed only in + * LOCAL builds where the git-ignored `.env` sets EXPO_PUBLIC_APPLE_LLM_DEV=1. + * PCC (the user-facing Apple provider) is NOT behind this flag. */ export function isAppleDevEnabled(): boolean { const v = process.env.EXPO_PUBLIC_APPLE_LLM_DEV; return v === "1" || v === "true"; } -/** Whether the on-device Apple provider can run right now (dev-gated). */ -export function isAppleProviderAvailable(): boolean { +/** Whether the DEV on-device Apple provider can run right now (dev-gated). */ +export function isAppleOnDeviceAvailable(): boolean { return isAppleDevEnabled() && isAppleLlmAvailable(); } /** - * Short, imperative system prompt for the on-device model. Deliberately terse + * Whether the user-facing Apple (PCC) provider can run right now: an iOS 27+ SDK + * build on an eligible iOS 27+ device with PCC ready. False on current EAS/ + * shipped builds (iOS 26 SDK → PCC compiled out) until an iOS 27 SDK build ships. + */ +export function isAppleServerProviderAvailable(): boolean { + return isAppleServerAvailable(); +} + +/** + * Whether ANY Apple provider is available (PCC for users, or on-device in dev). + * Used for provider selection + settings gating. + */ +export function isAppleProviderAvailable(): boolean { + return isAppleServerProviderAvailable() || isAppleOnDeviceAvailable(); +} + +/** + * Short, imperative system prompt for the ON-DEVICE model. Deliberately terse * (Apple: "reduce prompts to no more than three paragraphs", "an on-device model - * may get confused with a long and indirect instruction"). The full frontier - * prompt in agent.ts is skipped for this provider. + * may get confused with a long and indirect instruction"). PCC uses the agent's + * full system prompt instead (extracted from the conversation). */ function appleSystemPrompt(): string { const iso = new Date().toISOString().slice(0, 10); @@ -82,6 +99,16 @@ function appleSystemPrompt(): string { ].join(" "); } +/** Extract the system-role text from the conversation (agent.ts injects it). */ +function systemFromConversation(messages: UIMessage[]): string { + const sys = messages.find((m) => m.role === "system"); + if (!sys) return ""; + return sys.parts + .map((p) => (p.type === "text" ? (p as TextPart).text : "")) + .join("\n") + .trim(); +} + // ── session lifecycle ──────────────────────────────────────────────────────── // One persistent native session per chat thread. Mobile has a single chat, so we @@ -98,9 +125,12 @@ export function resetAppleSession(): void { /** Warm the model for the current session to cut first-token latency. */ export function prewarmAppleSession(tools: Record): void { - if (!AppleLlm || !isAppleLlmAvailable()) return; + if (!AppleLlm) return; + const server = isAppleServerProviderAvailable(); + if (!server && !isAppleLlmAvailable()) return; try { - AppleLlm.prewarm(_sessionId, appleSystemPrompt(), buildTools(tools)); + // Server (PCC) prewarm uses full schemas; on-device uses lean ones. + AppleLlm.prewarm(_sessionId, server ? undefined : appleSystemPrompt(), buildTools(tools, server), server); } catch { // best-effort } @@ -197,14 +227,29 @@ function leanSchema(schema: unknown): unknown { return out; } -/** Map the agent's tools to the native tool shape with lean, short schemas. */ -function buildTools(tools: Record): AppleTool[] { - return Object.entries(tools).map(([name, t]) => ({ - name, - // Keep the description to one short line — it counts against the window. - description: t.description.split(/[.\n]/)[0].slice(0, 120), - jsonSchema: JSON.stringify(leanSchema(t.jsonSchema ?? { type: "object", properties: {} })), - })); +/** + * Map the agent's tools to the native tool shape. On-device uses lean, short + * schemas (the 4096 window can't fit verbose descriptions). PCC keeps full + * descriptions + schemas — the 32K window has room, and richer schemas improve + * tool selection. + */ +function buildTools(tools: Record, server: boolean): AppleTool[] { + return Object.entries(tools).map(([name, t]) => { + const rawSchema = t.jsonSchema ?? { type: "object", properties: {} }; + if (server) { + return { + name, + description: t.description, + jsonSchema: JSON.stringify(rawSchema), + }; + } + return { + name, + // Keep the description to one short line — it counts against the window. + description: t.description.split(/[.\n]/)[0].slice(0, 120), + jsonSchema: JSON.stringify(leanSchema(rawSchema)), + }; + }); } /** Execute a tool locally and return its result as a JSON string. */ @@ -235,133 +280,155 @@ function runToolToJson(toolName: string, inputJson: string): { resultJson: strin * Bridge the native module's event stream into an async generator of * StreamEvents. Text deltas stream live. Tool calls are executed locally and * resolved back into the native turn; each is also surfaced as - * `tool-input-available` (+ its result) so the chat UI can show the tool-trail. + * `tool-executed` so the chat UI can show the tool-trail. + * + * `server` selects PCC (full prompt/schemas/32K, reasoning) vs on-device (terse + * prompt/lean schemas/small window). */ -async function* streamApple( - messages: UIMessage[], - tools: Record, - signal?: AbortSignal, -): AsyncGenerator { - if (!AppleLlm) { - throw new AppleLLMError( - AppleLLMErrorCodes.ModelUnavailable, - "On-device AI isn't available in this build.", - ); - } - // If the caller already aborted before we started, short-circuit before - // registering any listeners or kicking off a generation. - if (signal?.aborted) { - throw new AppleLLMError(AppleLLMErrorCodes.Cancelled, "Generation cancelled."); - } - - const prompt = latestPrompt(messages); - const nativeTools = buildTools(tools); - const requestId = nextRequestId(); - const sessionId = _sessionId; - - type Item = - | { kind: "token"; delta: string } - | { kind: "toolCall"; callId: string; toolName: string; input: string } - | { kind: "done"; finishReason: string } - | { kind: "error"; err: AppleLLMError }; - const queue: Item[] = []; - let notify: (() => void) | null = null; - const push = (item: Item) => { - queue.push(item); - notify?.(); - }; - - const subToken = AppleLlm.addListener("onToken", (e) => { - if (e.requestId === requestId) push({ kind: "token", delta: e.delta }); - }); - const subDone = AppleLlm.addListener("onDone", (e) => { - if (e.requestId === requestId) push({ kind: "done", finishReason: e.finishReason }); - }); - const subError = AppleLlm.addListener("onError", (e: AppleErrorEvent) => { - if (e.requestId === requestId) push({ kind: "error", err: new AppleLLMError(e.code, e.message) }); - }); - // Tool calls are routed by sessionId (a session runs one generation at a time). - const subTool = AppleLlm.addListener("onToolCall", (e: AppleToolCallEvent) => { - if (e.sessionId === sessionId) { - push({ kind: "toolCall", callId: e.callId, toolName: e.toolName, input: e.input }); +function makeStreamApple(server: boolean) { + return async function* streamApple( + messages: UIMessage[], + tools: Record, + signal?: AbortSignal, + ): AsyncGenerator { + if (!AppleLlm) { + throw new AppleLLMError( + AppleLLMErrorCodes.ModelUnavailable, + server ? "Private Cloud Compute isn't available in this build." : "On-device AI isn't available in this build.", + ); + } + // If the caller already aborted before we started, short-circuit before + // registering any listeners or kicking off a generation. + if (signal?.aborted) { + throw new AppleLLMError(AppleLLMErrorCodes.Cancelled, "Generation cancelled."); } - }); - const onAbort = () => AppleLlm?.cancel(requestId); - signal?.addEventListener("abort", onAbort); - - // Persistent session keeps the transcript; send only the newest user prompt. - // Instructions are bound at session creation (first turn) via `system`. - void AppleLlm.generate(requestId, sessionId, prompt, nativeTools, { - system: appleSystemPrompt(), - // Conservative reply cap to stay within the shared 4096-token window. - maxTokens: 1024, - }).catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err); - push({ kind: "error", err: new AppleLLMError(AppleLLMErrorCodes.GenerationError, message) }); - }); + const prompt = latestPrompt(messages); + const nativeTools = buildTools(tools, server); + const requestId = nextRequestId(); + const sessionId = _sessionId; + // PCC: use the agent's full system prompt (from the conversation). On-device: + // the terse prompt. Fall back to the terse one if none is present. + const system = server ? systemFromConversation(messages) || appleSystemPrompt() : appleSystemPrompt(); - try { - while (true) { - if (queue.length === 0) { - await new Promise((resolve) => { - notify = resolve; - }); - notify = null; + type Item = + | { kind: "token"; delta: string } + | { kind: "toolCall"; callId: string; toolName: string; input: string } + | { kind: "done"; finishReason: string } + | { kind: "error"; err: AppleLLMError }; + const queue: Item[] = []; + let notify: (() => void) | null = null; + const push = (item: Item) => { + queue.push(item); + notify?.(); + }; + + const subToken = AppleLlm.addListener("onToken", (e) => { + if (e.requestId === requestId) push({ kind: "token", delta: e.delta }); + }); + const subDone = AppleLlm.addListener("onDone", (e) => { + if (e.requestId === requestId) push({ kind: "done", finishReason: e.finishReason }); + }); + const subError = AppleLlm.addListener("onError", (e: AppleErrorEvent) => { + if (e.requestId === requestId) push({ kind: "error", err: new AppleLLMError(e.code, e.message) }); + }); + // Tool calls are routed by sessionId (a session runs one generation at a time). + const subTool = AppleLlm.addListener("onToolCall", (e: AppleToolCallEvent) => { + if (e.sessionId === sessionId) { + push({ kind: "toolCall", callId: e.callId, toolName: e.toolName, input: e.input }); } - const item = queue.shift(); - if (!item) continue; + }); - if (item.kind === "error") throw item.err; + const onAbort = () => AppleLlm?.cancel(requestId); + signal?.addEventListener("abort", onAbort); - if (item.kind === "token") { - yield { type: "text-delta", delta: item.delta }; - continue; - } + // Persistent session keeps the transcript; send only the newest user prompt. + // PCC gets the 32K window + a reasoning level; on-device a small reply cap. + const reasoningLevel: AppleReasoningLevel | undefined = server ? "moderate" : undefined; + void AppleLlm.generate(requestId, sessionId, prompt, nativeTools, { + system, + useServer: server, + reasoningLevel, + maxTokens: server ? 4096 : 1024, + }).catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + push({ kind: "error", err: new AppleLLMError(AppleLLMErrorCodes.GenerationError, message) }); + }); - if (item.kind === "toolCall") { - // Execute locally, surface for the UI tool-trail (as an already-executed - // tool so agent.ts doesn't re-run it), then resolve back into the native - // turn. Apple continues generating once resolved. - let input: unknown = {}; - try { - input = item.input ? JSON.parse(item.input) : {}; - } catch { - input = { _raw: item.input }; + try { + while (true) { + if (queue.length === 0) { + await new Promise((resolve) => { + notify = resolve; + }); + notify = null; } - const { resultJson, error } = runToolToJson(item.toolName, item.input); - let output: unknown; - try { - output = JSON.parse(resultJson); - } catch { - output = resultJson; + const item = queue.shift(); + if (!item) continue; + + if (item.kind === "error") throw item.err; + + if (item.kind === "token") { + yield { type: "text-delta", delta: item.delta }; + continue; } - yield { - type: "tool-executed", - toolCallId: item.callId, - toolName: item.toolName, - input, - output, - }; - AppleLlm.resolveToolCall(item.callId, resultJson, error); - continue; - } - // done - yield { type: "finish", finishReason: item.finishReason || "stop" }; - return; + if (item.kind === "toolCall") { + // Execute locally, surface for the UI tool-trail (as an already-executed + // tool so agent.ts doesn't re-run it), then resolve back into the native + // turn. Apple continues generating once resolved. + let input: unknown = {}; + try { + input = item.input ? JSON.parse(item.input) : {}; + } catch { + input = { _raw: item.input }; + } + const { resultJson, error } = runToolToJson(item.toolName, item.input); + let output: unknown; + try { + output = JSON.parse(resultJson); + } catch { + output = resultJson; + } + yield { + type: "tool-executed", + toolCallId: item.callId, + toolName: item.toolName, + input, + output, + }; + AppleLlm.resolveToolCall(item.callId, resultJson, error); + continue; + } + + // done + yield { type: "finish", finishReason: item.finishReason || "stop" }; + return; + } + } finally { + subToken.remove(); + subDone.remove(); + subError.remove(); + subTool.remove(); + signal?.removeEventListener("abort", onAbort); } - } finally { - subToken.remove(); - subDone.remove(); - subError.remove(); - subTool.remove(); - signal?.removeEventListener("abort", onAbort); - } + }; } -/** The on-device Apple Foundation Models provider. */ +/** + * The USER-FACING Apple provider: Private Cloud Compute (server model, iOS 27+). + * Full tool set, full system prompt, 32K window, reasoning. Online-only. + */ export const appleProvider: ChatProvider = { + name: "Apple Intelligence", + stream: makeStreamApple(true), +}; + +/** + * The DEV-ONLY on-device provider (hidden behind EXPO_PUBLIC_APPLE_LLM_DEV). + * Terse prompt, lean schemas, small window — kept for local iteration only. + */ +export const appleOnDeviceProvider: ChatProvider = { name: "Apple Intelligence (on-device)", - stream: streamApple, + stream: makeStreamApple(false), }; diff --git a/mobile/src/chat/providers/index.ts b/mobile/src/chat/providers/index.ts index 5f51960c6..168dbe70e 100644 --- a/mobile/src/chat/providers/index.ts +++ b/mobile/src/chat/providers/index.ts @@ -14,7 +14,12 @@ */ import { getProviderPref, resolveOpenAIConfig } from "../ai-config"; -import { appleProvider, isAppleProviderAvailable } from "./apple"; +import { + appleOnDeviceProvider, + appleProvider, + isAppleOnDeviceAvailable, + isAppleServerProviderAvailable, +} from "./apple"; import { makeOpenAIProvider } from "./openai"; import { isRorkAvailable, rorkProvider } from "./rork"; import type { ChatProvider } from "./types"; @@ -32,36 +37,47 @@ export class NoProviderError extends Error { } /** - * Resolve the active provider. Honours the user's preference first (Apple when - * on-device is available; OpenAI when configured), then Rork when built in, then - * a configured OpenAI, then on-device Apple as a universal offline fallback, - * else NoProviderError. Async because reading the API key touches the keychain. + * The Apple provider to use, if any: Private Cloud Compute (user-facing) when + * available, else the on-device model when the dev flag exposes it. null when + * neither is available. + */ +function appleProviderOrNull(): ChatProvider | null { + if (isAppleServerProviderAvailable()) return appleProvider; + if (isAppleOnDeviceAvailable()) return appleOnDeviceProvider; + return null; +} + +/** + * Resolve the active provider. Honours the user's preference first (Apple → + * PCC when available, else dev on-device; OpenAI when configured), then Rork + * when built in, then a configured OpenAI, then Apple as a fallback, else + * NoProviderError. Async because reading the API key touches the keychain. */ export async function resolveProvider(): Promise { const rork = isRorkAvailable(); - const apple = isAppleProviderAvailable(); + const apple = appleProviderOrNull(); const pref = getProviderPref(rork); if (pref === "apple") { - if (apple) return appleProvider; - // Preferred Apple but unavailable (older OS / disabled) — fall through. + if (apple) return apple; + // Preferred Apple but unavailable (older OS / not entitled / disabled) — fall through. } else if (pref === "openai") { const openai = await resolveOpenAIConfig(); if (openai) return makeOpenAIProvider(openai); // Preferred OpenAI but not configured — fall back below. } - // Fallback order: Rork → configured OpenAI → on-device Apple. + // Fallback order: Rork → configured OpenAI → Apple (PCC or dev on-device). if (rork) return rorkProvider; const openai = await resolveOpenAIConfig(); if (openai) return makeOpenAIProvider(openai); - if (apple) return appleProvider; + if (apple) return apple; throw new NoProviderError(); } /** Whether any provider is usable right now (for gating the composer). */ export async function hasProvider(): Promise { if (isRorkAvailable()) return true; - if (isAppleProviderAvailable()) return true; + if (appleProviderOrNull()) return true; return (await resolveOpenAIConfig()) != null; } diff --git a/mobile/src/components/AiSettingsForm.tsx b/mobile/src/components/AiSettingsForm.tsx index 0be1adf6c..149ba8b03 100644 --- a/mobile/src/components/AiSettingsForm.tsx +++ b/mobile/src/components/AiSettingsForm.tsx @@ -26,8 +26,18 @@ import { type ProviderPref, } from "@/chat/ai-config"; import { isRorkAvailable } from "@/chat/providers/rork"; -import { isAppleProviderAvailable, isAppleDevEnabled } from "@/chat/providers/apple"; -import { appleLlmUnavailableReason } from "@modules/apple-llm"; +import { + isAppleProviderAvailable, + isAppleServerProviderAvailable, + isAppleDevEnabled, +} from "@/chat/providers/apple"; +import { + appleLlmUnavailableReason, + appleServerUnavailableReason, + appleQuotaStatus, + showAppleQuotaUpgrade, + type AppleQuotaStatus, +} from "@modules/apple-llm"; import { listModels } from "@/chat/providers/openai"; /** @@ -50,12 +60,23 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { const styles = useMemo(() => makeStyles(t), [t]); const rorkBuiltIn = isRorkAvailable(); const appleAvailable = isAppleProviderAvailable(); - // Only surface an Apple availability reason when Apple is a dev-enabled option - // (isAppleProviderAvailable already false when the dev flag is off, so this - // stays empty in shipped builds and no Apple messaging leaks to end users). - const appleReason = useMemo( - () => (isAppleDevEnabled() && !appleAvailable ? appleLlmUnavailableReason() : ""), - [appleAvailable], + // Which Apple model backs the "Apple Intelligence" option: PCC (user-facing) + // or the dev-only on-device model. + const appleIsServer = isAppleServerProviderAvailable(); + // Availability reason, tailored to which Apple backend is expected: PCC first + // (user-facing), then the dev on-device model (only when the dev flag is on so + // no Apple messaging leaks to shipped builds). + const appleReason = useMemo(() => { + if (appleAvailable) return ""; + // Prefer the PCC reason; fall back to the on-device reason under the dev flag. + const server = appleServerUnavailableReason(); + return isAppleDevEnabled() ? appleLlmUnavailableReason() : server; + }, [appleAvailable]); + // PCC daily-quota snapshot (only meaningful when PCC is the active Apple model). + // Seeded from a synchronous native getter via a lazy initializer (like the + // provider/baseUrl values below) rather than an effect. + const [quota] = useState(() => + isAppleServerProviderAvailable() ? appleQuotaStatus() : null, ); // Whether to show the provider chooser at all: only when there's a choice. const showChooser = rorkBuiltIn || appleAvailable; @@ -163,7 +184,7 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { {appleAvailable && ( setPref("apple")} t={t} @@ -191,10 +212,38 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { - Apple Intelligence runs entirely on your device — private, offline, - no API key. Best for quick chats; it has a small context window, so - long conversations may need a fresh start. + {appleIsServer + ? "Apple Intelligence runs on Private Cloud Compute — private, no API key, with a larger context window and stronger reasoning. Needs a connection and has a daily usage limit." + : "Apple Intelligence runs entirely on your device — private, offline, no API key. Best for quick chats; it has a small context window, so long conversations may need a fresh start."} + + + )} + {pref === "apple" && appleIsServer && quota?.available && quota.status !== "below" && ( + + + {quota.isLimitReached + ? "You've reached your daily Private Cloud Compute limit." + : "You're approaching your daily Private Cloud Compute limit."} + {quota.resetDate ? ` Resets ${new Date(quota.resetDate).toLocaleDateString()}.` : ""} + {quota.canUpgrade && ( + { + haptics.selection(); + showAppleQuotaUpgrade(); + }} + hitSlop={8} + > + + Show options + + + )} )} {pref === "rork" && ( From 8b2274b5902c31d21d011147a0ebbdc2d0671f82 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 8 Jul 2026 16:43:09 -0400 Subject: [PATCH 03/24] =?UTF-8?q?feat(mobile):=20tab=20bar=20UX=20for=20iO?= =?UTF-8?q?S=2027=20=E2=80=94=20search=20placement,=20keyboard=20offsets,?= =?UTF-8?q?=20empty=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-on polish after the iOS 27 scene migration, which surfaced tab-bar changes. Search tab moved to the far right (after Chat) to match the iOS search-tab convention. On the iOS 27 SDK, react-native-screens still uses the legacy UITabBarSystemItemSearch, which no longer renders the integrated tab-bar search field (it needs the modern UISearchTab, not yet adopted upstream), so the search tab is a plain tab for now — tracked separately. Gate the tab-bar search-field height: add theme.hasTabBarSearchField (iOS < 27). The search screen only reserves the ~52pt field height / offset on iOS <=26; on iOS 27 it drops it so there's no dead gap. Unify keyboard-sticky offsets between chat + search via shared theme helpers tabBarClosedLift(insetBottom) and KEYBOARD_OPEN_GAP: closed rests hugging the tab bar, open clears the keyboard by a small gap. Fixes chat sitting flush on the keyboard and the search scope bar sitting too low/high. Empty state: on iOS 27 the FlatList's automatic search-header inset isn't applied to ListEmptyComponent, so it rendered behind the header and the screen scrolled. EmptyState gains topOffset/bottomOffset (centres within the carved region, matching centred screens); search passes header + bottom-controls offsets and switches the list to non-scrolling flexGrow-only when empty. type-check + lint clean. --- mobile/app/(tabs)/_layout.tsx | 12 +++--- mobile/app/(tabs)/chat/index.tsx | 26 +++++------- mobile/app/(tabs)/search/index.tsx | 63 ++++++++++++++++++++-------- mobile/src/components/EmptyState.tsx | 27 +++++++++++- mobile/src/theme.ts | 38 ++++++++++++++++- 5 files changed, 125 insertions(+), 41 deletions(-) diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index 5d58dae86..1c426ab17 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -32,15 +32,17 @@ export default function TabsLayout() { Graph - - - Search - - Chat + + {/* Search last so it sits on the far right — matches the iOS search-tab + convention and keeps it out of the primary nav flow. */} + + + Search + ); diff --git a/mobile/app/(tabs)/chat/index.tsx b/mobile/app/(tabs)/chat/index.tsx index 67048f7d5..fa17c77ef 100644 --- a/mobile/app/(tabs)/chat/index.tsx +++ b/mobile/app/(tabs)/chat/index.tsx @@ -20,7 +20,7 @@ import { EmptyState } from "@/components/EmptyState"; import { GlassBar, glassActive } from "@/components/GlassBar"; import { MarkdownView } from "@/components/MarkdownView"; import { ICON_DELETE, ICON_AI } from "@/components/toolbar-icons"; -import { useTheme, withAlpha, type as typeScale, type Theme } from "@/theme"; +import { useTheme, withAlpha, tabBarClosedLift, KEYBOARD_OPEN_GAP, type as typeScale, type Theme } from "@/theme"; import { runAgent, userMessage, assistantMessage, type AgentEvent, type Attachment } from "@/chat/agent"; import { haptics, toolbarPress } from "@/haptics"; import { pickImages, takePhoto } from "@/chat/attachments"; @@ -30,8 +30,6 @@ import { resetAppleSession } from "@/chat/providers/apple"; import { prettifyToolLabel } from "@cairn/shared/ui/constants"; import type { UIMessage } from "@/chat/providers/types"; -/** Standard UIKit tab bar content height (excludes the home-indicator inset). */ -const TAB_BAR_BASE = 49; /** Composer height assumed before its first onLayout measurement. */ const COMPOSER_FALLBACK_H = 60; @@ -115,24 +113,19 @@ export default function ChatScreen() { ); const { height: kbHeight } = useGradualAnimation(); - // Approximate the native (translucent) iOS tab bar height. NativeTabs doesn't - // expose BottomTabBarHeightContext, so we reconstruct it: the standard UIKit - // tab bar is 49pt tall and sits above the home-indicator safe area. - // - // Lift the composer just above the tab bar when the keyboard is closed. We - // lift by only slightly more than the bar's visible height (not the full - // safe-area-inclusive height) so the composer hugs the bar instead of - // floating well above it. - const closedLift = TAB_BAR_BASE + insets.bottom * 0.5; + // Lift the composer just above the tab bar when the keyboard is closed + // (shared with the search scope bar so both rest at the same height). + const closedLift = tabBarClosedLift(insets.bottom); // Height the composer occupies, measured lazily (falls back before layout). const [composerH, setComposerH] = useState(COMPOSER_FALLBACK_H); // Animated spacer at the BOTTOM of the scroll content. It always keeps the // last message clear of the floating composer, and grows to clear the // keyboard when it opens (so you can scroll the newest message above the // keyboard). When closed it matches where the composer actually rests - // (closedLift above the screen bottom) so there's no dead space. + // (closedLift above the screen bottom) so there's no dead space. The +GAP on + // the open case matches the composer's KEYBOARD_OPEN_GAP lift. const bottomSpacer = useAnimatedStyle(() => ({ - height: composerH + 12 + Math.max(kbHeight.value, closedLift), + height: composerH + 12 + Math.max(kbHeight.value + KEYBOARD_OPEN_GAP, closedLift), }), [composerH, closedLift]); const send = useCallback(async () => { @@ -302,9 +295,10 @@ export default function ChatScreen() { {/* Sticky composer: pinned to the bottom, rides up with the keyboard automatically. When the keyboard is closed it's offset up above the - translucent tab bar; when open it sits flush on the keyboard. */} + translucent tab bar; when open it sits just above the keyboard with a + small gap (KEYBOARD_OPEN_GAP), matching the search scope bar. */} setComposerH(e.nativeEvent.layout.height)}> diff --git a/mobile/app/(tabs)/search/index.tsx b/mobile/app/(tabs)/search/index.tsx index 944bcac4a..5b52562dc 100644 --- a/mobile/app/(tabs)/search/index.tsx +++ b/mobile/app/(tabs)/search/index.tsx @@ -14,7 +14,7 @@ import { semanticSearch, catchUpIndex, type SemanticHit } from "@/notes/embeddin import { haptics } from "@/haptics"; import { isAppleEmbeddingsSupported, appleEmbeddingsUnavailableReason } from "@modules/apple-embeddings"; import { stripMarkdown } from "@cairn/shared/notes/text"; -import { useTheme, elevation, PRIORITY_COLOR, TAB_BAR_BASE, type as typeScale, type Theme } from "@/theme"; +import { useTheme, elevation, PRIORITY_COLOR, TAB_BAR_BASE, hasTabBarSearchField, tabBarClosedLift, KEYBOARD_OPEN_GAP, type as typeScale, type Theme } from "@/theme"; type Scope = "notes" | "tasks" | "semantic"; @@ -23,6 +23,11 @@ type Scope = "notes" | "tasks" | "semantic"; const SEARCH_FIELD_H = 52; // Breathing room between the scope bar and the search field. const SCOPE_GAP = 8; +// Approx height of the nav header + attached search bar (below the status-bar +// safe-area inset). Used to carve the empty state clear of the header. +const HEADER_SEARCH_H = 96; +// Approx rendered height of the scope-switch bar (padding + button). +const SCOPE_BAR_H = 40; /** * Search screen using the native iOS search bar (headerSearchBarOptions) — the @@ -168,6 +173,9 @@ export default function SearchScreen() { ); const hasQuery = query.trim().length > 0; + // Whether the active scope's list has zero rows (drives the empty-state layout: + // no results padding, no scrolling). + const isListEmpty = (scope === "semantic" ? hits : scope === "notes" ? notes : tasks).length === 0; // Always show the Semantic scope so it's discoverable; if on-device // embeddings aren't usable we explain why in the results area rather than // silently hiding the option. @@ -207,18 +215,32 @@ export default function SearchScreen() { // render behind it. With no query it's the branded resting state; during an // active search with no matches it's a light top-anchored text hint. const listEmpty = hasQuery ? ( - + {emptyHint.primary} {emptyHint.secondary ? {emptyHint.secondary} : null} ) : ( - + ); const listProps = { - // flexGrow:1 lets ListEmptyComponent fill the viewport (so the branded - // state's top-bias is measured against the full content area, below header). - contentContainerStyle: [styles.list, styles.listGrow], + // Empty state: use flexGrow-only (no results padding) so the content is + // exactly the viewport height and the screen doesn't scroll. With results: + // apply the list padding that clears the scope bar / tab bar / keyboard. + contentContainerStyle: isListEmpty ? styles.listGrow : styles.list, + // Nothing to scroll when empty — also stops the anchored empty state from + // being draggable past the header. + scrollEnabled: !isListEmpty, contentInsetAdjustmentBehavior: "automatic" as const, keyboardShouldPersistTaps: "handled" as const, keyboardDismissMode: "on-drag" as const, @@ -307,13 +329,17 @@ export default function SearchScreen() { /> )} - {/* Persistent scope switch, pinned to the bottom just above the native - search field. With the iOS 26 search tab the search field lives at the - bottom (above the keyboard), so the toggle rides the keyboard via - KeyboardStickyView: when closed it sits above the tab-bar search field; - when open it lifts to clear the field that's now docked on the keyboard. */} + {/* Persistent scope switch, pinned to the bottom. On iOS ≤26 the tab-bar + search field docks above the keyboard, so we clear it (SEARCH_FIELD_H) + when open and rest on insets.bottom when closed. On iOS 27 there's no + tab-bar search field (see hasTabBarSearchField), so it matches the chat + composer exactly: rest above the tab bar (tabBarClosedLift) when closed, + and clear the keyboard by KEYBOARD_OPEN_GAP when open. */} @@ -352,9 +378,9 @@ function makeStyles(t: Theme) { scopeBtnActive: { backgroundColor: t.accent }, scopeText: { ...typeScale.control, color: t.textSecondary }, scopeTextActive: { color: t.accentFg }, - // Bottom pad clears the pinned scope bar + the native search field + tab bar - // so the last result is scrollable into view above them. - list: { padding: 12, paddingBottom: 12 + TAB_BAR_BASE + SEARCH_FIELD_H + 48 }, + // Bottom pad clears the pinned scope bar + tab bar (+ the native search + // field on iOS ≤26; none on iOS 27, so drop that reservation). + list: { padding: 12, paddingBottom: 12 + TAB_BAR_BASE + (hasTabBarSearchField ? SEARCH_FIELD_H : 0) + 48 }, row: { paddingVertical: 10, paddingHorizontal: 14, @@ -372,9 +398,10 @@ function makeStyles(t: Theme) { // Lets ListEmptyComponent fill the viewport so the branded state's top-bias // is measured against the full content area (below the header). listGrow: { flexGrow: 1 }, - // Active-search "no matches" hint — top-anchored (~25% down) so it stays - // clear of the keyboard rather than centring under it. - emptyHint: { flex: 1, alignItems: "center", paddingTop: "25%", paddingHorizontal: 32 }, + // Active-search "no matches" hint — top-anchored (paddingTop supplied + // inline from insets.top so it clears the header on iOS 27 where the list's + // automatic header inset doesn't apply) and clear of the keyboard. + emptyHint: { flex: 1, alignItems: "center", paddingHorizontal: 32 }, hint: { textAlign: "center", color: t.textTertiary }, statHint: { ...typeScale.caption, textAlign: "center", color: t.textTertiary, marginTop: 8 }, }); diff --git a/mobile/src/components/EmptyState.tsx b/mobile/src/components/EmptyState.tsx index 4f18fcda8..768a17e89 100644 --- a/mobile/src/components/EmptyState.tsx +++ b/mobile/src/components/EmptyState.tsx @@ -35,6 +35,8 @@ export function EmptyState({ children, compact = false, align = "center", + topOffset, + bottomOffset, }: { title?: string; subtitle?: string; @@ -45,10 +47,23 @@ export function EmptyState({ /** "center" (default) fills and centres; "top" biases the content toward the * upper ~22% so it stays readable when a keyboard covers the lower half. */ align?: "center" | "top"; + /** Explicit top padding (px) — the header/safe-area region to stay clear of. + * Use when the container's own top isn't reliable (e.g. a list whose + * automatic header inset doesn't apply, as on iOS 27). With `bottomOffset` + * the content CENTRES in the region between them (matching other centred + * screens); alone with `align="top"` it just pins below the offset. */ + topOffset?: number; + /** Explicit bottom padding (px) — the region occupied by pinned bottom + * controls (scope bar, tab bar). Pair with `topOffset` to centre between. */ + bottomOffset?: number; }) { const t = useTheme(); const styles = makeStyles(t); const iconSize = compact ? 56 : 76; + // When both offsets are given, centre within the carved region (top→bottom), + // matching the look of other centred screens. Otherwise fall back to the + // align-based behaviour. + const bounded = topOffset != null && bottomOffset != null; // Easter egg: 5 quick taps on the Cairn icon launches Breakout. const [gameOpen, setGameOpen] = useState(false); @@ -64,7 +79,17 @@ export function EmptyState({ }; return ( - + diff --git a/mobile/src/theme.ts b/mobile/src/theme.ts index 922b87aba..555a67c76 100644 --- a/mobile/src/theme.ts +++ b/mobile/src/theme.ts @@ -7,7 +7,7 @@ * system colour scheme. */ -import { useColorScheme } from "react-native"; +import { Platform, useColorScheme } from "react-native"; export interface Theme { background: string; @@ -169,6 +169,42 @@ export const iconSize = { */ export const TAB_BAR_BASE = 49; +/** + * How far a bottom-pinned floating control (chat composer, search scope bar) + * should lift above the screen bottom when the keyboard is CLOSED, so it hugs + * the native tab bar instead of the home indicator. Slightly more than the bar's + * visible height — `insets.bottom * 0.5` accounts for the home-indicator area + * without floating the control well above the bar. Shared so chat + search rest + * at the same height. (Verified on device: this is the correct resting spot.) + */ +export function tabBarClosedLift(insetBottom: number): number { + return TAB_BAR_BASE + insetBottom * 0.5; +} + +/** + * Gap between a keyboard-sticky floating control and the top of the keyboard + * when it's OPEN. A small breathing space so the control doesn't touch the + * keyboard. Shared by chat + search. + */ +export const KEYBOARD_OPEN_GAP = 8; + +/** + * Whether the OS renders the integrated search field inside the tab bar for a + * `role="search"` NativeTabs tab. + * + * iOS ≤26 (via react-native-screens' legacy `UITabBarSystemItemSearch`) shows a + * search field docked in the tab bar. The iOS 27 SDK dropped that behaviour for + * the legacy system-item (it now needs the modern `UISearchTab`, which rn-screens + * hasn't adopted — see the "iOS 27: search tab renders as plain tab" task), so + * the search tab is just a normal tab with no extra field. + * + * Layout that reserved space for that field (padding, keyboard-sticky offsets) + * must gate on this so iOS 27 doesn't leave a ~52pt gap where the field used to + * be. Android and web never had it. + */ +export const hasTabBarSearchField = + Platform.OS === "ios" && parseInt(String(Platform.Version), 10) < 27; + /** Returns the theme for the current system colour scheme. */ export function useTheme(): Theme { From 72b7688418541b09a68a1cedcf740a6ae43b1d69 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 8 Jul 2026 17:06:03 -0400 Subject: [PATCH 04/24] fix(mobile): match search empty state position to chat on iOS 27 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iOS 27 the search tab's FlatList frame isn't the full screen (the headerSearchBarOptions search controller shrinks it), so EmptyState's relative "25%" top-bias was measured against a smaller box and rendered the icon behind the header. Chat's absoluteFill ScrollView is full-screen, so its 25% lands correctly — the two never matched. Add an EmptyState `topBias` prop that overrides the default "25%" with an explicit value, and have search anchor from the SCREEN TOP at screenH * 0.257 (where chat's icon actually lands: 14 + 25% of screenH-28). Both empty states now sit at the same screen-relative position on any device. Applied to the branded state and the "no matches" hint. type-check + lint clean. --- mobile/app/(tabs)/search/index.tsx | 36 ++++++++++++---------------- mobile/src/components/EmptyState.tsx | 33 +++++++++---------------- 2 files changed, 26 insertions(+), 43 deletions(-) diff --git a/mobile/app/(tabs)/search/index.tsx b/mobile/app/(tabs)/search/index.tsx index 5b52562dc..1142e0913 100644 --- a/mobile/app/(tabs)/search/index.tsx +++ b/mobile/app/(tabs)/search/index.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useRef, useState } from "react"; -import { Text, View, FlatList, StyleSheet, RefreshControl, Pressable, Alert, Keyboard } from "react-native"; +import { Text, View, FlatList, StyleSheet, RefreshControl, Pressable, Alert, Keyboard, useWindowDimensions } from "react-native"; import { Stack, useRouter, useFocusEffect, type Href } from "expo-router"; import type { SearchBarCommands } from "react-native-screens"; import { searchNotes, searchTasks, listWorkspaceIds, embeddingIndexStats, type NoteRow, type CardRow } from "@/db/queries"; @@ -23,11 +23,6 @@ type Scope = "notes" | "tasks" | "semantic"; const SEARCH_FIELD_H = 52; // Breathing room between the scope bar and the search field. const SCOPE_GAP = 8; -// Approx height of the nav header + attached search bar (below the status-bar -// safe-area inset). Used to carve the empty state clear of the header. -const HEADER_SEARCH_H = 96; -// Approx rendered height of the scope-switch bar (padding + button). -const SCOPE_BAR_H = 40; /** * Search screen using the native iOS search bar (headerSearchBarOptions) — the @@ -48,6 +43,14 @@ export default function SearchScreen() { const styles = useMemo(() => makeStyles(t), [t]); const searchRef = useRef(null); const insets = useSafeAreaInsets(); + const { height: screenH } = useWindowDimensions(); + // Match the chat empty state's icon position exactly. Chat's ScrollView fills + // the screen and its EmptyState biases 25% down, so its icon lands ~25.7% of + // the screen height from the top. Search's FlatList content box isn't the full + // screen height on iOS 27 (the search header/insets shrink it), so a relative + // 25% would land too high (behind the header). Anchor from the SCREEN TOP with + // the same fraction instead, so both screens match on any device. + const emptyTop = screenH * 0.257; const semanticAvailable = isAppleEmbeddingsSupported(); // Monotonic token so a slow semantic query can't overwrite a newer one. const semanticSeq = useRef(0); @@ -215,22 +218,14 @@ export default function SearchScreen() { // render behind it. With no query it's the branded resting state; during an // active search with no matches it's a light top-anchored text hint. const listEmpty = hasQuery ? ( - + {emptyHint.primary} {emptyHint.secondary ? {emptyHint.secondary} : null} ) : ( - + // topBias = screen-top-relative position matching chat's icon (~25.7% down), + // since the search list frame isn't the full screen on iOS 27. + ); const listProps = { @@ -398,9 +393,8 @@ function makeStyles(t: Theme) { // Lets ListEmptyComponent fill the viewport so the branded state's top-bias // is measured against the full content area (below the header). listGrow: { flexGrow: 1 }, - // Active-search "no matches" hint — top-anchored (paddingTop supplied - // inline from insets.top so it clears the header on iOS 27 where the list's - // automatic header inset doesn't apply) and clear of the keyboard. + // Active-search "no matches" hint — paddingTop supplied inline (emptyTop) so + // it matches the branded state and clears the header / keyboard. emptyHint: { flex: 1, alignItems: "center", paddingHorizontal: 32 }, hint: { textAlign: "center", color: t.textTertiary }, statHint: { ...typeScale.caption, textAlign: "center", color: t.textTertiary, marginTop: 8 }, diff --git a/mobile/src/components/EmptyState.tsx b/mobile/src/components/EmptyState.tsx index 768a17e89..1273e41b2 100644 --- a/mobile/src/components/EmptyState.tsx +++ b/mobile/src/components/EmptyState.tsx @@ -35,8 +35,7 @@ export function EmptyState({ children, compact = false, align = "center", - topOffset, - bottomOffset, + topBias, }: { title?: string; subtitle?: string; @@ -45,25 +44,17 @@ export function EmptyState({ /** Smaller icon + tighter spacing, for overlays over a list. */ compact?: boolean; /** "center" (default) fills and centres; "top" biases the content toward the - * upper ~22% so it stays readable when a keyboard covers the lower half. */ + * upper third so it stays readable when a keyboard covers the lower half. */ align?: "center" | "top"; - /** Explicit top padding (px) — the header/safe-area region to stay clear of. - * Use when the container's own top isn't reliable (e.g. a list whose - * automatic header inset doesn't apply, as on iOS 27). With `bottomOffset` - * the content CENTRES in the region between them (matching other centred - * screens); alone with `align="top"` it just pins below the offset. */ - topOffset?: number; - /** Explicit bottom padding (px) — the region occupied by pinned bottom - * controls (scope bar, tab bar). Pair with `topOffset` to centre between. */ - bottomOffset?: number; + /** Overrides the `align="top"` bias (default "25%"). Pass an absolute px value + * when the container height can't be trusted (e.g. an iOS 27 search list + * whose frame isn't the full screen) so the content lands at a screen-top- + * relative position that matches other screens. */ + topBias?: number | `${number}%`; }) { const t = useTheme(); const styles = makeStyles(t); const iconSize = compact ? 56 : 76; - // When both offsets are given, centre within the carved region (top→bottom), - // matching the look of other centred screens. Otherwise fall back to the - // align-based behaviour. - const bounded = topOffset != null && bottomOffset != null; // Easter egg: 5 quick taps on the Cairn icon launches Breakout. const [gameOpen, setGameOpen] = useState(false); @@ -82,12 +73,10 @@ export function EmptyState({ From 1f1efc74c5b6ac233f959784739562f810d73c8d Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 8 Jul 2026 21:36:54 -0400 Subject: [PATCH 05/24] feat(mobile): context-window usage ring in chat (Apple provider) Mirror the desktop ContextRing on mobile: a small SVG donut that fills with the fraction of the model's context window the conversation is using, with the same accent/warning/danger thresholds (<=65 / 65-85 / >85%). Apple Foundation Models actually exposes the telemetry (contrary to the earlier countTokens stub): SystemLanguageModel.tokenCount(for:) (iOS 26.4+) and PrivateCloudComputeLanguageModel.contextSize (iOS 27+). After each turn the native module computes (promptTokens = tokenCount of the session transcript, contextLimit = PCC contextSize / 4096 on-device) and reports it on the onDone event. On the PCC path the token count is a close estimate (on-device tokenizer, same family). countTokens is now implemented via tokenCount too. The usage flows AppleDoneEvent -> apple.ts finish event (StreamEvent.usage: ChatUsage) -> agent "final" event -> chat stores it and renders as a glass pill floating top-right below the header, cleared on new chat. Only the Apple provider reports usage, so the ring is hidden for Rork/OpenAI. Native builds on the iOS 27 SDK and parses clean on the iOS 26 SDK (all PCC bits stay under CAIRN_PCC_SDK / #available); type-check + lint clean. --- mobile/app/(tabs)/chat/index.tsx | 20 +++++- mobile/modules/apple-llm/AppleLlm.types.ts | 4 ++ .../apple-llm/ios/AppleLlmModule.swift | 46 ++++++++++-- mobile/src/chat/agent.ts | 12 +++- mobile/src/chat/providers/apple.ts | 14 +++- mobile/src/chat/providers/types.ts | 8 ++- mobile/src/components/ContextRing.tsx | 72 +++++++++++++++++++ 7 files changed, 163 insertions(+), 13 deletions(-) create mode 100644 mobile/src/components/ContextRing.tsx diff --git a/mobile/app/(tabs)/chat/index.tsx b/mobile/app/(tabs)/chat/index.tsx index fa17c77ef..a09a2333e 100644 --- a/mobile/app/(tabs)/chat/index.tsx +++ b/mobile/app/(tabs)/chat/index.tsx @@ -28,7 +28,8 @@ import { loadChatHistory, saveChatMessage, clearChatHistory } from "@/db/chat-st import { hasProvider } from "@/chat/providers"; import { resetAppleSession } from "@/chat/providers/apple"; import { prettifyToolLabel } from "@cairn/shared/ui/constants"; -import type { UIMessage } from "@/chat/providers/types"; +import type { UIMessage, ChatUsage } from "@/chat/providers/types"; +import { ContextRing } from "@/components/ContextRing"; /** Composer height assumed before its first onLayout measurement. */ const COMPOSER_FALLBACK_H = 60; @@ -78,6 +79,8 @@ export default function ChatScreen() { const [attachments, setAttachments] = useState([]); const [busy, setBusy] = useState(false); const [configured, setConfigured] = useState(true); + // Context-window usage for the ring (Apple provider reports it per turn). + const [usage, setUsage] = useState(null); const scrollRef = useRef(null); const router = useRouter(); // Persistent agent conversation (UIMessage parts format) across turns. Seeded @@ -173,6 +176,8 @@ export default function ChatScreen() { toolTrail.push({ tool: e.tool, ok }); patchAssistant({ tools: [...toolTrail] }); haptics.impact(); // agent ran a tool + } else if (e.type === "final" && e.usage) { + setUsage(e.usage); // context-window ring (Apple provider) } setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 20); }; @@ -236,6 +241,7 @@ export default function ChatScreen() { clearChatHistory(); conversation.current = []; setMessages([]); + setUsage(null); // Drop the on-device session so a new chat gets a fresh context window. resetAppleSession(); }, @@ -263,6 +269,15 @@ export default function ChatScreen() { /> + {/* Context-window usage ring — floats at the top-right, below the header, + when the Apple provider reports usage. Mirrors the desktop ContextRing. */} + {usage && messages.length > 0 ? ( + + + + + + ) : null} {/* Full-height scroll: messages scroll BEHIND the sticky composer and the translucent native tab bar. The animated bottom spacer clears both the composer and (when open) the keyboard. */} @@ -393,6 +408,9 @@ const Bubble = memo(function Bubble({ m, t, styles }: { m: UiMessage; t: Theme; function makeStyles(t: Theme) { return StyleSheet.create({ + ringOverlay: { position: "absolute", right: 12, zIndex: 10 }, + ringBar: { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 999, overflow: "hidden" }, + ringBarFallback: { backgroundColor: withAlpha(t.surface2, 0.92), borderWidth: 1, borderColor: t.border }, list: { padding: 14, paddingBottom: 20 }, // Grow to fill the viewport when empty so the branded EmptyState's top-bias // measures against the full content area (below the header). diff --git a/mobile/modules/apple-llm/AppleLlm.types.ts b/mobile/modules/apple-llm/AppleLlm.types.ts index e673ee584..f777de473 100644 --- a/mobile/modules/apple-llm/AppleLlm.types.ts +++ b/mobile/modules/apple-llm/AppleLlm.types.ts @@ -60,6 +60,10 @@ export interface AppleTokenEvent { export interface AppleDoneEvent { requestId: string; finishReason: string; + /** Context-window usage for the ring: transcript token count (-1 if unknown). */ + promptTokens?: number; + /** Model context size (32K PCC / 4096 on-device; -1 if unknown). */ + contextLimit?: number; } export interface AppleErrorEvent { requestId: string; diff --git a/mobile/modules/apple-llm/ios/AppleLlmModule.swift b/mobile/modules/apple-llm/ios/AppleLlmModule.swift index 34642da28..24d46ea8a 100644 --- a/mobile/modules/apple-llm/ios/AppleLlmModule.swift +++ b/mobile/modules/apple-llm/ios/AppleLlmModule.swift @@ -117,10 +117,15 @@ public class AppleLlmModule: Module { return Self.showQuotaUpgrade() } - // Best-effort token count. Returns -1 ("unknown"): the current - // FoundationModels SDK exposes no public token-counting API. JS surface kept - // so a future SDK can fill it in without a bridge change. - AsyncFunction("countTokens") { (_: String) -> Int in + // Token count of a plain string via the on-device tokenizer (iOS 26.4+). + // Returns -1 when unavailable. Usage for the context ring is reported on the + // onDone event; this stays for ad-hoc counts. + AsyncFunction("countTokens") { (text: String) -> Int in + #if canImport(FoundationModels) + if #available(iOS 26.4, *) { + return (try? await SystemLanguageModel.default.tokenCount(for: text)) ?? -1 + } + #endif return -1 } @@ -400,7 +405,16 @@ public class AppleLlmModule: Module { self.sendEvent(EVENT_TOKEN, ["requestId": requestId, "delta": text]) } } - self.sendEvent(EVENT_DONE, ["requestId": requestId, "finishReason": "stop"]) + // Report context-window usage for the ring: token count of the session + // transcript (numerator) over the model's context size (denominator). + // Best-effort — never fails the turn. + let (promptTokens, contextLimit) = await Self.usage(for: session, useServer: useServer) + self.sendEvent(EVENT_DONE, [ + "requestId": requestId, + "finishReason": "stop", + "promptTokens": promptTokens, + "contextLimit": contextLimit, + ]) self.removeTask(requestId) } catch is CancellationError { self.emitError(requestId, .cancelled, "Generation cancelled.") @@ -436,6 +450,28 @@ public class AppleLlmModule: Module { return session.streamResponse(to: prompt, options: genOptions) } + /// Context-window usage for the ring: (promptTokens, contextLimit). + /// promptTokens = token count of the session transcript via the on-device + /// tokenizer (iOS 26.4+); on the PCC path this is a close estimate (same + /// tokenizer family). contextLimit = PCC contextSize (iOS 27) or the on-device + /// 4096. Returns (-1, -1) when unavailable so JS can hide the ring. Best-effort. + @available(iOS 26.0, *) + private static func usage(for session: LanguageModelSession, useServer: Bool) async -> (Int, Int) { + var promptTokens = -1 + if #available(iOS 26.4, *) { + promptTokens = (try? await SystemLanguageModel.default.tokenCount(for: session.transcript)) ?? -1 + } + + var contextLimit = useServer ? -1 : 4096 + #if CAIRN_PCC_SDK + if useServer, #available(iOS 27.0, *) { + contextLimit = (try? await PrivateCloudComputeLanguageModel().contextSize) ?? -1 + } + #endif + + return (promptTokens, contextLimit) + } + /// Fetch the cached session for `sessionId` (namespaced by model kind), or /// build one bound with the given instructions + tools. Tools/instructions are /// fixed at creation time (Apple's design), which is fine because Cairn's tool diff --git a/mobile/src/chat/agent.ts b/mobile/src/chat/agent.ts index 05faa39c5..5bad80a13 100644 --- a/mobile/src/chat/agent.ts +++ b/mobile/src/chat/agent.ts @@ -16,6 +16,7 @@ import { type UIPart, type ToolPart, type FilePart, + type ChatUsage, } from "./providers/types"; import { toolsForAgent, TOOL_MAP } from "./tools"; @@ -28,6 +29,7 @@ export interface AgentEvent { args?: unknown; result?: unknown; text?: string; // full text for final / error + usage?: ChatUsage; // context-window usage, on "final" } /** Build the system message (as a UIMessage part). */ @@ -107,6 +109,8 @@ export async function runAgent( ): Promise { const tools = toolsForAgent(); let finalText = ""; + // Context-window usage from the latest turn's finish event (Apple provider). + let usage: ChatUsage | undefined; // Ensure the conversation is led by an up-to-date system prompt. Refresh it // each run so the injected current date doesn't go stale in a long session. @@ -157,6 +161,8 @@ export async function runAgent( onEvent?.({ type: "tool", tool: e.toolName, args: e.input, result: e.output }); } else if (ev.type === "finish") { finishReason = (ev as { finishReason?: string }).finishReason; + const u = (ev as { usage?: ChatUsage }).usage; + if (u) usage = u; } } } catch (e) { @@ -182,7 +188,7 @@ export async function runAgent( // Ensure the assistant turn is in history even if empty-ish. if (assistant.parts.length === 0) assistant.parts.push({ type: "text", text: finalText }); conversation.push(assistant); - onEvent?.({ type: "final", text: finalText }); + onEvent?.({ type: "final", text: finalText, usage }); return finalText; } @@ -218,13 +224,13 @@ export async function runAgent( conversation.push(assistant); // Loop for the model's follow-up turn (it now sees the tool outputs). if (finishReason === "stop") { - onEvent?.({ type: "final", text: finalText }); + onEvent?.({ type: "final", text: finalText, usage }); return finalText; } } const msg = finalText || "I couldn't finish that in a reasonable number of steps."; - onEvent?.({ type: "final", text: msg }); + onEvent?.({ type: "final", text: msg, usage }); return msg; } diff --git a/mobile/src/chat/providers/apple.ts b/mobile/src/chat/providers/apple.ts index 8a93ebb45..5c8c7900f 100644 --- a/mobile/src/chat/providers/apple.ts +++ b/mobile/src/chat/providers/apple.ts @@ -314,7 +314,7 @@ function makeStreamApple(server: boolean) { type Item = | { kind: "token"; delta: string } | { kind: "toolCall"; callId: string; toolName: string; input: string } - | { kind: "done"; finishReason: string } + | { kind: "done"; finishReason: string; promptTokens?: number; contextLimit?: number } | { kind: "error"; err: AppleLLMError }; const queue: Item[] = []; let notify: (() => void) | null = null; @@ -327,7 +327,8 @@ function makeStreamApple(server: boolean) { if (e.requestId === requestId) push({ kind: "token", delta: e.delta }); }); const subDone = AppleLlm.addListener("onDone", (e) => { - if (e.requestId === requestId) push({ kind: "done", finishReason: e.finishReason }); + if (e.requestId === requestId) + push({ kind: "done", finishReason: e.finishReason, promptTokens: e.promptTokens, contextLimit: e.contextLimit }); }); const subError = AppleLlm.addListener("onError", (e: AppleErrorEvent) => { if (e.requestId === requestId) push({ kind: "error", err: new AppleLLMError(e.code, e.message) }); @@ -402,7 +403,14 @@ function makeStreamApple(server: boolean) { } // done - yield { type: "finish", finishReason: item.finishReason || "stop" }; + yield { + type: "finish", + finishReason: item.finishReason || "stop", + usage: + item.promptTokens != null && item.promptTokens >= 0 && item.contextLimit != null && item.contextLimit > 0 + ? { promptTokens: item.promptTokens, contextLimit: item.contextLimit } + : undefined, + }; return; } } finally { diff --git a/mobile/src/chat/providers/types.ts b/mobile/src/chat/providers/types.ts index aebb3fc9f..77a26fad2 100644 --- a/mobile/src/chat/providers/types.ts +++ b/mobile/src/chat/providers/types.ts @@ -75,10 +75,16 @@ export type StreamEvent = input: unknown; output: unknown; } - | { type: "finish"; finishReason?: string } + | { type: "finish"; finishReason?: string; usage?: ChatUsage } | { type: "reasoning-delta"; delta?: string } | { type: string; [k: string]: unknown }; +/** Context-window usage for the chat ring (prompt tokens over the model limit). */ +export interface ChatUsage { + promptTokens: number; + contextLimit: number; +} + /** A provider streams normalised events for a turn. */ export interface ChatProvider { /** Human label for diagnostics / the settings UI. */ diff --git a/mobile/src/components/ContextRing.tsx b/mobile/src/components/ContextRing.tsx new file mode 100644 index 000000000..5793e8dbc --- /dev/null +++ b/mobile/src/components/ContextRing.tsx @@ -0,0 +1,72 @@ +import { useMemo } from "react"; +import { View, Text, StyleSheet } from "react-native"; +import Svg, { Circle } from "react-native-svg"; +import { useTheme, type as typeScale } from "@/theme"; + +/** + * Context-window usage ring — the mobile analogue of the desktop ContextRing + * (src/components/agent/ContextRing.tsx). An SVG donut whose arc fills with the + * fraction of the model's context window currently used by the transcript. + * + * Threshold colours match desktop: accent <=65%, warning 65–85%, danger >85%. + * Driven by the Apple provider's per-turn usage (session token count over the + * model contextSize); hidden when usage is unavailable (other providers). + */ +export function ContextRing({ + promptTokens, + contextLimit, + size = 20, + stroke = 2.5, +}: { + promptTokens: number; + contextLimit: number; + size?: number; + stroke?: number; +}) { + const t = useTheme(); + + const { pct, colour, r, circ, dash } = useMemo(() => { + const p = contextLimit > 0 ? Math.min(promptTokens / contextLimit, 1) : 0; + const radius = (size - stroke) / 2; + const circumference = 2 * Math.PI * radius; + return { + pct: p, + r: radius, + circ: circumference, + dash: p * circumference, + colour: p > 0.85 ? t.danger : p > 0.65 ? t.warning : t.accent, + }; + }, [promptTokens, contextLimit, size, stroke, t]); + + const pctLabel = `${Math.round(pct * 100)}%`; + const half = size / 2; + + return ( + + + {/* -90° rotation so the arc starts at 12 o'clock, like desktop. */} + + + + {pctLabel} + + ); +} + +const styles = StyleSheet.create({ + wrap: { flexDirection: "row", alignItems: "center", gap: 6 }, + label: { ...typeScale.caption, fontVariant: ["tabular-nums"] }, +}); From 444320b08f5d8bb7e7548d46a284fa20415cb89c Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 8 Jul 2026 21:45:54 -0400 Subject: [PATCH 06/24] feat(mobile): 3-state PCC usage bar in AI settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the approaching/exceeded-only text line with a persistent 3-segment Private Cloud Compute usage bar, shown whenever PCC is the active Apple provider. Apple exposes no exact usage numbers — only a 3-state status (below / approaching / reached) + a reset date — so the bar fills to the current state (green → amber → red) rather than a precise gauge, matching Apple's guidance to "communicate the current status." Includes the status label, the reset date when near/at the limit, and an iCloud+ upgrade action (showAppleQuotaUpgrade) when a limitIncreaseSuggestion is available. type-check + lint clean. --- mobile/src/components/AiSettingsForm.tsx | 124 ++++++++++++++++++----- 1 file changed, 97 insertions(+), 27 deletions(-) diff --git a/mobile/src/components/AiSettingsForm.tsx b/mobile/src/components/AiSettingsForm.tsx index 149ba8b03..e497c4a12 100644 --- a/mobile/src/components/AiSettingsForm.tsx +++ b/mobile/src/components/AiSettingsForm.tsx @@ -218,33 +218,16 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { )} - {pref === "apple" && appleIsServer && quota?.available && quota.status !== "below" && ( - - - {quota.isLimitReached - ? "You've reached your daily Private Cloud Compute limit." - : "You're approaching your daily Private Cloud Compute limit."} - {quota.resetDate ? ` Resets ${new Date(quota.resetDate).toLocaleDateString()}.` : ""} - - {quota.canUpgrade && ( - { - haptics.selection(); - showAppleQuotaUpgrade(); - }} - hitSlop={8} - > - - Show options - - - )} - + {pref === "apple" && appleIsServer && quota?.available && ( + { + haptics.selection(); + showAppleQuotaUpgrade(); + }} + t={t} + styles={styles} + /> )} {pref === "rork" && ( @@ -405,6 +388,74 @@ function Field({ ); } +/** + * Private Cloud Compute daily-usage indicator. Apple exposes no exact numbers, + * only a 3-state status (below / approaching / reached) + a reset date, so we + * render a 3-segment bar that fills to the current state (green → amber → red) + * rather than a precise gauge — matching Apple's "communicate the current + * status" guidance. Shows a reset date and an iCloud+ upgrade action. + */ +function QuotaBar({ + quota, + onUpgrade, + t, + styles, +}: { + quota: AppleQuotaStatus; + onUpgrade: () => void; + t: Theme; + styles: ReturnType; +}) { + // How many of the 3 segments are lit, and the fill colour, per state. + const level = quota.isLimitReached ? 3 : quota.status === "approaching" ? 2 : 1; + const colour = quota.isLimitReached ? t.danger : quota.status === "approaching" ? t.warning : t.success; + const label = quota.isLimitReached + ? "Daily limit reached" + : quota.status === "approaching" + ? "Approaching daily limit" + : "Within daily limit"; + const reset = + quota.resetDate && quota.status !== "below" + ? `Resets ${new Date(quota.resetDate).toLocaleDateString()}` + : ""; + + return ( + + + Private Cloud Compute + {label} + + + {[0, 1, 2].map((i) => ( + + ))} + + + + {reset || "Usage resets daily. No API key needed."} + + {quota.canUpgrade && ( + + Get more with iCloud+ + + )} + + + ); +} + function makeStyles(t: Theme) { return StyleSheet.create({ flex: { flex: 1, backgroundColor: t.surface }, @@ -453,6 +504,25 @@ function makeStyles(t: Theme) { }, rorkNoteText: { flex: 1, ...typeScale.caption, color: t.textSecondary, lineHeight: 18 }, + // PCC daily-usage 3-state bar. + quotaCard: { + backgroundColor: t.surface2, + borderRadius: 10, + padding: 12, + marginTop: 4, + gap: 8, + }, + quotaHeaderRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, + quotaTitle: { ...typeScale.caption, color: t.textSecondary, fontWeight: "600" }, + quotaStatus: { ...typeScale.caption, fontWeight: "600" }, + quotaTrack: { flexDirection: "row", gap: 3, height: 6 }, + quotaSeg: { flex: 1, height: 6 }, + quotaSegFirst: { borderTopLeftRadius: 3, borderBottomLeftRadius: 3 }, + quotaSegLast: { borderTopRightRadius: 3, borderBottomRightRadius: 3 }, + quotaFootRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8 }, + quotaHint: { flex: 1, ...typeScale.caption, color: t.textTertiary }, + quotaUpgrade: { ...typeScale.caption, color: t.accent, fontWeight: "600" }, + fields: { gap: 14, marginTop: 12 }, field: { gap: 6 }, fieldLabel: { ...typeScale.label, color: t.textSecondary }, From 32074b62316ab46b6ee64f6623f72faae32acffc Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 8 Jul 2026 22:21:59 -0400 Subject: [PATCH 07/24] feat(mobile): context ring for all providers + header-left placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the chat context ring beyond Apple to OpenAI and Rork, move it into the header, and refine its interaction. Token counts: - OpenAI: request stream_options.include_usage and read the real usage.prompt_tokens. - Rork: the underlying model is uncertain, so use a server usage part when the stream carries one, else estimate client-side (js-tiktoken lite o200k_base, lazily loaded) and mark it estimated. Fixed 200K context assumption. - Apple: unchanged (real tokenCount). Context limits: - models.dev catalog (fetched once, cached in app_settings + memory) maps model id -> limit.context for the OpenAI provider; default 65536 when unknown. - Optional manual "Context window" override field in AI settings, with a hint showing the detected models.dev value for the current model. - Rork fixed at 200K (conservative; model unknown). UI: - ContextRing is now ring-only (no inline %), thicker stroke, and tappable — opens a native popup with the exact percent + token counts (and an "estimated" note for Rork). Moved from a body overlay into the chat header-left. - Shorten the Apple provider segment to an Apple glyph + "Intelligence". ChatUsage gains `estimated`. type-check + lint clean; iOS JS bundle exports clean (js-tiktoken bundles under Metro/Hermes). --- mobile/app/(tabs)/chat/index.tsx | 30 +++--- mobile/package-lock.json | 10 ++ mobile/package.json | 1 + mobile/src/chat/ai-config.ts | 24 ++++- mobile/src/chat/models-dev.ts | 131 +++++++++++++++++++++++ mobile/src/chat/providers/openai.ts | 24 ++++- mobile/src/chat/providers/rork.ts | 64 ++++++++++- mobile/src/chat/providers/types.ts | 4 + mobile/src/chat/tokens.ts | 39 +++++++ mobile/src/components/AiSettingsForm.tsx | 57 +++++++++- mobile/src/components/ContextRing.tsx | 50 ++++++--- 11 files changed, 391 insertions(+), 43 deletions(-) create mode 100644 mobile/src/chat/models-dev.ts create mode 100644 mobile/src/chat/tokens.ts diff --git a/mobile/app/(tabs)/chat/index.tsx b/mobile/app/(tabs)/chat/index.tsx index a09a2333e..edf62cbfa 100644 --- a/mobile/app/(tabs)/chat/index.tsx +++ b/mobile/app/(tabs)/chat/index.tsx @@ -251,7 +251,23 @@ export default function ChatScreen() { return ( - + 0 + ? () => ( + + ) + : undefined, + }} + /> - {/* Context-window usage ring — floats at the top-right, below the header, - when the Apple provider reports usage. Mirrors the desktop ContextRing. */} - {usage && messages.length > 0 ? ( - - - - - - ) : null} {/* Full-height scroll: messages scroll BEHIND the sticky composer and the translucent native tab bar. The animated bottom spacer clears both the composer and (when open) the keyboard. */} @@ -408,9 +415,6 @@ const Bubble = memo(function Bubble({ m, t, styles }: { m: UiMessage; t: Theme; function makeStyles(t: Theme) { return StyleSheet.create({ - ringOverlay: { position: "absolute", right: 12, zIndex: 10 }, - ringBar: { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 999, overflow: "hidden" }, - ringBarFallback: { backgroundColor: withAlpha(t.surface2, 0.92), borderWidth: 1, borderColor: t.border }, list: { padding: 14, paddingBottom: 20 }, // Grow to fill the viewport when empty so the branded EmptyState's top-bias // measures against the full content area (below the header). diff --git a/mobile/package-lock.json b/mobile/package-lock.json index d6b3375b4..350e7bf47 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -28,6 +28,7 @@ "expo-sqlite": "~57.0.0", "expo-status-bar": "~57.0.0", "expo-updates": "~57.0.6", + "js-tiktoken": "^1.0.21", "katex": "^0.17.0", "lowlight": "^3.3.0", "lucide-react-native": "^1.23.0", @@ -9561,6 +9562,15 @@ "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", "license": "MIT" }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index 4dd8d6aba..2a27f8f29 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -36,6 +36,7 @@ "expo-sqlite": "~57.0.0", "expo-status-bar": "~57.0.0", "expo-updates": "~57.0.6", + "js-tiktoken": "^1.0.21", "katex": "^0.17.0", "lowlight": "^3.3.0", "lucide-react-native": "^1.23.0", diff --git a/mobile/src/chat/ai-config.ts b/mobile/src/chat/ai-config.ts index 8e033744e..a956a1ed9 100644 --- a/mobile/src/chat/ai-config.ts +++ b/mobile/src/chat/ai-config.ts @@ -20,6 +20,7 @@ import { getDb } from "../db"; const KEY_BASE_URL = "ai.openai.baseUrl"; const KEY_MODEL = "ai.openai.model"; +const KEY_CONTEXT = "ai.openai.contextLimit"; // optional manual override (tokens) const KEY_PROVIDER = "ai.provider"; // "rork" | "openai" | "apple" const SECURE_KEY_APIKEY = "ai.openai.apiKey"; // secure-store key @@ -40,6 +41,9 @@ export interface OpenAIConfig { baseUrl: string; model: string; apiKey: string; + /** Optional manual context-window override (tokens) for the ring, when the + * model isn't in the models.dev catalog. Undefined = use catalog/default. */ + contextLimit?: number; } function getSetting(key: string): string | null { @@ -68,10 +72,19 @@ export function getOpenAIModel(): string { return getSetting(KEY_MODEL)?.trim() || DEFAULT_OPENAI_MODEL; } -/** Persist the non-secret base URL + model. Empty values reset to defaults. */ -export function setOpenAIEndpoint(baseUrl: string, model: string): void { +/** Optional manual context-window override (tokens), or undefined if unset/invalid. */ +export function getOpenAIContextLimit(): number | undefined { + const raw = getSetting(KEY_CONTEXT)?.trim(); + if (!raw) return undefined; + const n = parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : undefined; +} + +/** Persist the non-secret base URL + model + optional context override. */ +export function setOpenAIEndpoint(baseUrl: string, model: string, contextLimit?: number): void { setSetting(KEY_BASE_URL, baseUrl.trim()); setSetting(KEY_MODEL, model.trim()); + setSetting(KEY_CONTEXT, contextLimit && contextLimit > 0 ? String(Math.floor(contextLimit)) : ""); } /** @@ -127,7 +140,12 @@ function hasCustomBaseUrl(): boolean { export async function resolveOpenAIConfig(): Promise { const apiKey = (await getOpenAIApiKey()) ?? ""; if (!apiKey && !hasCustomBaseUrl()) return null; - return { baseUrl: getOpenAIBaseUrl(), model: getOpenAIModel(), apiKey }; + return { + baseUrl: getOpenAIBaseUrl(), + model: getOpenAIModel(), + apiKey, + contextLimit: getOpenAIContextLimit(), + }; } /** Whether the OpenAI provider is usable (has a key, or a custom keyless endpoint). */ diff --git a/mobile/src/chat/models-dev.ts b/mobile/src/chat/models-dev.ts new file mode 100644 index 000000000..e1eda9907 --- /dev/null +++ b/mobile/src/chat/models-dev.ts @@ -0,0 +1,131 @@ +/** + * models.dev context-window lookup for the chat context ring. + * + * Fetches the public models.dev catalog (https://models.dev/api.json) and maps a + * model id → its context window (limit.context). Used to size the ring for the + * OpenAI-compatible provider, where the model id is known. Rork's underlying + * model is uncertain, so it uses a fixed conservative cap instead (not this). + * + * The catalog is ~3MB, so we fetch it at most once per app run (in-memory) and + * persist the small id→context map to the app_settings table so subsequent runs + * resolve instantly and offline. Everything is best-effort: on any failure the + * caller falls back to a default limit. + */ + +import { fetch as expoFetch } from "expo/fetch"; +import { getDb } from "../db"; + +const API_URL = "https://models.dev/api.json"; +const CACHE_KEY = "ai.modelContext.cache"; // JSON { [modelId]: contextTokens } +const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // refresh weekly +const CACHE_AT_KEY = "ai.modelContext.cachedAt"; + +/** Default when a model isn't found in the catalog. */ +export const DEFAULT_CONTEXT_LIMIT = 65536; + +type ContextMap = Record; + +let memoryCache: ContextMap | null = null; +let inflight: Promise | null = null; + +function readSetting(key: string): string | null { + try { + const row = getDb().getFirstSync<{ value: string }>( + "SELECT value FROM app_settings WHERE key = ?", + key, + ); + return row?.value ?? null; + } catch { + return null; + } +} + +function writeSetting(key: string, value: string): void { + try { + getDb().runSync( + "INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + key, + value, + ); + } catch { + // best-effort + } +} + +/** Flatten the models.dev catalog into a single id → context-window map. */ +function buildMap(catalog: unknown): ContextMap { + const map: ContextMap = {}; + if (!catalog || typeof catalog !== "object") return map; + for (const provider of Object.values(catalog as Record)) { + const models = (provider as { models?: unknown })?.models; + if (!models || typeof models !== "object") continue; + for (const [id, model] of Object.entries(models as Record)) { + const ctx = (model as { limit?: { context?: unknown } })?.limit?.context; + if (typeof ctx === "number" && ctx > 0) map[id] = ctx; + } + } + return map; +} + +/** Load the cached map from SQLite (if fresh), else null. */ +function loadCache(): ContextMap | null { + const at = Number(readSetting(CACHE_AT_KEY) ?? 0); + if (!at || Date.now() - at > CACHE_TTL_MS) return null; + const raw = readSetting(CACHE_KEY); + if (!raw) return null; + try { + return JSON.parse(raw) as ContextMap; + } catch { + return null; + } +} + +/** + * Ensure the id→context map is loaded (memory → SQLite → network). Best-effort; + * returns an empty map on total failure. Safe to call repeatedly; de-duped. + */ +async function ensureMap(): Promise { + if (memoryCache) return memoryCache; + const cached = loadCache(); + if (cached) { + memoryCache = cached; + return cached; + } + if (inflight) return inflight; + inflight = (async () => { + try { + const res = await expoFetch(API_URL, { headers: { Accept: "application/json" } }); + if (!res.ok) throw new Error(`models.dev ${res.status}`); + const map = buildMap(await res.json()); + memoryCache = map; + writeSetting(CACHE_KEY, JSON.stringify(map)); + writeSetting(CACHE_AT_KEY, String(Date.now())); + return map; + } catch { + memoryCache = {}; + return {}; + } finally { + inflight = null; + } + })(); + return inflight; +} + +/** + * Context window (tokens) for a model id, or `fallback` when unknown. Async so it + * can fetch/refresh the catalog; results are cached in memory + SQLite. Never + * throws. + */ +export async function contextLimitForModel( + modelId: string, + fallback = DEFAULT_CONTEXT_LIMIT, +): Promise { + if (!modelId) return fallback; + const map = await ensureMap(); + return map[modelId] ?? fallback; +} + +/** Warm the catalog cache in the background (best-effort, fire-and-forget). */ +export function prewarmModelCatalog(): void { + void ensureMap(); +} diff --git a/mobile/src/chat/providers/openai.ts b/mobile/src/chat/providers/openai.ts index 300fd002d..793e0e107 100644 --- a/mobile/src/chat/providers/openai.ts +++ b/mobile/src/chat/providers/openai.ts @@ -18,9 +18,11 @@ import { fetch as expoFetch } from "expo/fetch"; import type { OpenAIConfig } from "../ai-config"; +import { contextLimitForModel } from "../models-dev"; import { type AiTool, type ChatProvider, + type ChatUsage, type FilePart, type StreamEvent, type TextPart, @@ -118,6 +120,9 @@ function makeStreamer(config: OpenAIConfig) { model: config.model, messages: messages.flatMap(mapMessage), stream: true, + // Ask for a final usage chunk (prompt/completion tokens) to drive the + // context ring. Harmless on endpoints that ignore it. + stream_options: { include_usage: true }, }; const mapped = mapTools(tools); if (mapped.length > 0) body.tools = mapped; @@ -143,6 +148,16 @@ function makeStreamer(config: OpenAIConfig) { let buffer = ""; const toolAccum = new Map(); let finishReason: string | undefined; + let promptTokens: number | undefined; + + // Build the ring usage from the server's prompt_tokens + the model's context + // window (models.dev, cached). Undefined when the endpoint reported no usage. + const buildUsage = async (): Promise => { + if (promptTokens == null) return undefined; + // Manual override wins; else look up the model in models.dev; else default. + const contextLimit = config.contextLimit ?? (await contextLimitForModel(config.model)); + return { promptTokens, contextLimit }; + }; const flushTools = function* (): Generator { for (const acc of toolAccum.values()) { @@ -171,7 +186,7 @@ function makeStreamer(config: OpenAIConfig) { const payload = trimmed.slice(5).trim(); if (payload === "[DONE]") { yield* flushTools(); - yield { type: "finish", finishReason: finishReason ?? "stop" }; + yield { type: "finish", finishReason: finishReason ?? "stop", usage: await buildUsage() }; return; } let chunk: { @@ -186,12 +201,17 @@ function makeStreamer(config: OpenAIConfig) { }; finish_reason?: string; }[]; + usage?: { prompt_tokens?: number }; }; try { chunk = JSON.parse(payload); } catch { continue; } + // Usage arrives in its own chunk (choices empty) when include_usage is on. + if (typeof chunk.usage?.prompt_tokens === "number") { + promptTokens = chunk.usage.prompt_tokens; + } const choice = chunk.choices?.[0]; if (!choice) continue; const delta = choice.delta; @@ -215,7 +235,7 @@ function makeStreamer(config: OpenAIConfig) { // Stream ended without an explicit [DONE]. yield* flushTools(); - yield { type: "finish", finishReason: finishReason ?? "stop" }; + yield { type: "finish", finishReason: finishReason ?? "stop", usage: await buildUsage() }; }; } diff --git a/mobile/src/chat/providers/rork.ts b/mobile/src/chat/providers/rork.ts index fa2722f87..37235947e 100644 --- a/mobile/src/chat/providers/rork.ts +++ b/mobile/src/chat/providers/rork.ts @@ -15,7 +15,8 @@ */ import { fetch as expoFetch } from "expo/fetch"; -import { newRunId, type AiTool, type ChatProvider, type StreamEvent, type UIMessage } from "./types"; +import { countTextTokens } from "../tokens"; +import { newRunId, type AiTool, type ChatProvider, type ChatUsage, type StreamEvent, type UIMessage } from "./types"; /** The build-time-injected Rork base URL, or null if not configured. */ export function rorkBaseUrl(): string | null { @@ -29,6 +30,24 @@ export function isRorkAvailable(): boolean { return rorkBaseUrl() != null; } +// Rork's underlying model is not guaranteed, so we assume a conservative context +// window for the ring (many modern models are >=200K; this errs toward showing +// "full" sooner rather than underestimating and overflowing silently). +const RORK_CONTEXT_LIMIT = 200_000; + +/** Plain text of all message parts, for a client-side token estimate. */ +function conversationText(messages: UIMessage[]): string { + const chunks: string[] = []; + for (const m of messages) { + for (const p of m.parts) { + if (p.type === "text" && typeof (p as { text?: string }).text === "string") { + chunks.push((p as { text: string }).text); + } + } + } + return chunks.join("\n"); +} + async function* streamRork( messages: UIMessage[], tools: Record, @@ -62,6 +81,23 @@ async function* streamRork( const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; + // Prefer a server-reported prompt-token count if the stream carries one; + // otherwise fall back to a client estimate (marked estimated) at finish. + let serverPromptTokens: number | undefined; + let sawFinish = false; + + const usageEvent = (): ChatUsage => { + if (serverPromptTokens != null) { + return { promptTokens: serverPromptTokens, contextLimit: RORK_CONTEXT_LIMIT }; + } + // Estimate from the outgoing conversation (o200k_base — approximate for + // whatever model Rork serves, which is all a fill gauge needs). + return { + promptTokens: countTextTokens(conversationText(messages)), + contextLimit: RORK_CONTEXT_LIMIT, + estimated: true, + }; + }; while (true) { const { done, value } = await reader.read(); @@ -77,15 +113,35 @@ async function* streamRork( const trimmed = line.trim(); if (!trimmed.startsWith("data:")) continue; const payload = trimmed.slice(5).trim(); - if (payload === "[DONE]") return; + if (payload === "[DONE]") { + // Ensure a usage-carrying finish even if the server never sent one. + if (!sawFinish) yield { type: "finish", finishReason: "stop", usage: usageEvent() }; + return; + } + let ev: StreamEvent; try { - yield JSON.parse(payload) as StreamEvent; + ev = JSON.parse(payload) as StreamEvent; } catch { - // ignore keep-alives / partial frames + continue; // ignore keep-alives / partial frames + } + // Capture any usage the server includes (AI SDK totalUsage/usage shapes). + const raw = ev as { usage?: { promptTokens?: number; inputTokens?: number }; totalUsage?: { promptTokens?: number; inputTokens?: number } }; + const u = raw.totalUsage ?? raw.usage; + const pt = u?.promptTokens ?? u?.inputTokens; + if (typeof pt === "number") serverPromptTokens = pt; + + if (ev.type === "finish") { + sawFinish = true; + yield { type: "finish", finishReason: (ev as { finishReason?: string }).finishReason ?? "stop", usage: usageEvent() }; + } else { + yield ev; } } } } + + // Stream ended without [DONE] or a finish part. + if (!sawFinish) yield { type: "finish", finishReason: "stop", usage: usageEvent() }; } export const rorkProvider: ChatProvider = { diff --git a/mobile/src/chat/providers/types.ts b/mobile/src/chat/providers/types.ts index 77a26fad2..d8a2256db 100644 --- a/mobile/src/chat/providers/types.ts +++ b/mobile/src/chat/providers/types.ts @@ -83,6 +83,10 @@ export type StreamEvent = export interface ChatUsage { promptTokens: number; contextLimit: number; + /** True when promptTokens is a client-side estimate (no server usage / wrong + * tokenizer family, e.g. Rork/Gemini counted with o200k_base). The ring shows + * a "~" and an "estimated" hint. */ + estimated?: boolean; } /** A provider streams normalised events for a turn. */ diff --git a/mobile/src/chat/tokens.ts b/mobile/src/chat/tokens.ts new file mode 100644 index 000000000..34bef567d --- /dev/null +++ b/mobile/src/chat/tokens.ts @@ -0,0 +1,39 @@ +/** + * Client-side token counting for the chat context ring. + * + * Uses js-tiktoken's pure-JS `lite` build with the o200k_base rank (the encoder + * for gpt-4o / o200k-family models) — no WASM, works under Hermes. It's the + * right encoder for OpenAI models and a good-enough approximation for others + * (Rork), which is all a context-fill gauge needs. + * + * The rank table is ~loaded lazily on first use so the ~2MB JSON never touches + * startup — only when a provider without server-reported usage needs an estimate. + */ + +import { Tiktoken } from "js-tiktoken/lite"; + +let enc: Tiktoken | null = null; + +function encoder(): Tiktoken { + if (!enc) { + // Required lazily so the rank JSON is excluded from the startup bundle path. + // The rank is the module's default export under ESM but a direct object + // under the CJS build Metro resolves — handle both. + // eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy-load the ~2MB rank table only on first use + const mod = require("js-tiktoken/ranks/o200k_base"); + const o200k = mod?.default ?? mod; + enc = new Tiktoken(o200k); + } + return enc; +} + +/** Token count for a piece of text (o200k_base). 0 for empty. */ +export function countTextTokens(text: string): number { + if (!text) return 0; + try { + return encoder().encode(text).length; + } catch { + // Never let token counting break a turn — fall back to a rough chars/4. + return Math.ceil(text.length / 4); + } +} diff --git a/mobile/src/components/AiSettingsForm.tsx b/mobile/src/components/AiSettingsForm.tsx index e497c4a12..992f0045b 100644 --- a/mobile/src/components/AiSettingsForm.tsx +++ b/mobile/src/components/AiSettingsForm.tsx @@ -9,7 +9,7 @@ import { ActivityIndicator, } from "react-native"; import { Stack } from "expo-router"; -import { Check, ShieldCheck, RefreshCw, Cpu } from "lucide-react-native"; +import { Check, ShieldCheck, RefreshCw, Cpu, Apple, type LucideIcon } from "lucide-react-native"; import { ICON_CHECK } from "@/components/toolbar-icons"; import { haptics, toolbarPress } from "@/haptics"; import { useTheme, type as typeScale, type Theme } from "@/theme"; @@ -19,6 +19,7 @@ import { getOpenAIApiKey, getOpenAIBaseUrl, getOpenAIModel, + getOpenAIContextLimit, getProviderPref, setOpenAIApiKey, setOpenAIEndpoint, @@ -39,6 +40,7 @@ import { type AppleQuotaStatus, } from "@modules/apple-llm"; import { listModels } from "@/chat/providers/openai"; +import { contextLimitForModel } from "@/chat/models-dev"; /** * AI settings form body. Presented as a native `formSheet` route @@ -88,7 +90,15 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { const [pref, setPref] = useState(() => getProviderPref(rorkBuiltIn)); const [baseUrl, setBaseUrl] = useState(() => getOpenAIBaseUrl()); const [model, setModel] = useState(() => getOpenAIModel()); + // Optional manual context-window override (blank = auto via models.dev). + const [contextLimit, setContextLimit] = useState(() => { + const v = getOpenAIContextLimit(); + return v ? String(v) : ""; + }); const [apiKey, setApiKey] = useState(""); + // Context window detected from models.dev for the current model (null = not + // found / not looked up). Shown as a hint when there's no manual override. + const [detectedContext, setDetectedContext] = useState(null); const [loadedKey, setLoadedKey] = useState(""); const [hadKey, setHadKey] = useState(false); const [loading, setLoading] = useState(true); @@ -113,6 +123,20 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { cancelled = true; }; }, []); + + // Look up the model's context window from models.dev (cached) so we can show + // it as a hint. Only meaningful for the OpenAI provider; re-runs when the + // model id changes. Best-effort — null when the model isn't in the catalog. + useEffect(() => { + let cancelled = false; + const id = model.trim(); + contextLimitForModel(id || "\u0000", 0).then((n) => { + if (!cancelled) setDetectedContext(id && n > 0 ? n : null); + }); + return () => { + cancelled = true; + }; + }, [model]); const fetchModels = async () => { const key = apiKey.trim(); if (!key) { @@ -138,7 +162,7 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { // Persist the chosen provider. When there's no chooser (only OpenAI is // available), force "openai". setProviderPref(showChooser ? pref : "openai"); - setOpenAIEndpoint(baseUrl, model); + setOpenAIEndpoint(baseUrl, model, contextLimit.trim() ? parseInt(contextLimit, 10) : undefined); // Only touch the keychain if the key field actually changed from what we // loaded — avoids a redundant write (and allows clearing it). if (apiKey !== loadedKey) await setOpenAIApiKey(apiKey); @@ -184,7 +208,8 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { {appleAvailable && ( setPref("apple")} t={t} @@ -328,6 +353,24 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { )} + setContextLimit(v.replace(/[^0-9]/g, ""))} + placeholder={detectedContext ? `Auto — ${detectedContext.toLocaleString()}` : "Auto (from models.dev)"} + keyboardType="number-pad" + t={t} + styles={styles} + /> + + Sizes the usage ring.{" "} + {contextLimit.trim() + ? "Using your manual value." + : detectedContext + ? `Detected ${detectedContext.toLocaleString()} tokens for “${model.trim()}”.` + : "Leave blank to detect from the model, or set it for models we can’t look up."} + + Works with OpenAI, Azure OpenAI, OpenRouter, Together, Groq, LM Studio, Ollama, and other OpenAI-compatible APIs. @@ -342,12 +385,14 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { function SegmentButton({ label, + icon: Icon, selected, onPress, t, styles, }: { label: string; + icon?: LucideIcon; selected: boolean; onPress: () => void; t: Theme; @@ -358,7 +403,11 @@ function SegmentButton({ style={[styles.segmentBtn, selected && styles.segmentBtnActive]} onPress={onPress} > - {selected && } + {selected ? ( + + ) : Icon ? ( + + ) : null} {label} diff --git a/mobile/src/components/ContextRing.tsx b/mobile/src/components/ContextRing.tsx index 5793e8dbc..ade122c3c 100644 --- a/mobile/src/components/ContextRing.tsx +++ b/mobile/src/components/ContextRing.tsx @@ -1,25 +1,29 @@ -import { useMemo } from "react"; -import { View, Text, StyleSheet } from "react-native"; +import { useCallback, useMemo } from "react"; +import { Alert, Pressable, StyleSheet } from "react-native"; import Svg, { Circle } from "react-native-svg"; -import { useTheme, type as typeScale } from "@/theme"; +import { haptics } from "@/haptics"; +import { useTheme } from "@/theme"; /** * Context-window usage ring — the mobile analogue of the desktop ContextRing - * (src/components/agent/ContextRing.tsx). An SVG donut whose arc fills with the - * fraction of the model's context window currently used by the transcript. + * (src/components/agent/ContextRing.tsx). A compact SVG donut whose arc fills + * with the fraction of the model's context window used by the conversation. * - * Threshold colours match desktop: accent <=65%, warning 65–85%, danger >85%. - * Driven by the Apple provider's per-turn usage (session token count over the - * model contextSize); hidden when usage is unavailable (other providers). + * Ring-only (no inline label); tapping it opens a native popup with the exact + * percentage + token counts. Threshold colours match desktop: accent <=65%, + * warning 65–85%, danger >85%. */ export function ContextRing({ promptTokens, contextLimit, - size = 20, - stroke = 2.5, + estimated = false, + size = 22, + stroke = 3.5, }: { promptTokens: number; contextLimit: number; + /** promptTokens is a client-side estimate (shown as "~" / "about"). */ + estimated?: boolean; size?: number; stroke?: number; }) { @@ -38,13 +42,27 @@ export function ContextRing({ }; }, [promptTokens, contextLimit, size, stroke, t]); - const pctLabel = `${Math.round(pct * 100)}%`; + const pctLabel = `${estimated ? "~" : ""}${Math.round(pct * 100)}%`; const half = size / 2; + const showDetail = useCallback(() => { + haptics.selection(); + const approx = estimated ? "about " : ""; + Alert.alert( + "Context usage", + `${pctLabel} of the model's context window is in use.\n\n${approx}${promptTokens.toLocaleString()} of ${contextLimit.toLocaleString()} tokens.` + + (estimated ? "\n\nToken count is estimated for this provider." : ""), + [{ text: "OK" }], + ); + }, [pctLabel, promptTokens, contextLimit, estimated]); + return ( - {/* -90° rotation so the arc starts at 12 o'clock, like desktop. */} @@ -61,12 +79,10 @@ export function ContextRing({ transform={`rotate(-90 ${half} ${half})`} /> - {pctLabel} - + ); } const styles = StyleSheet.create({ - wrap: { flexDirection: "row", alignItems: "center", gap: 6 }, - label: { ...typeScale.caption, fontVariant: ["tabular-nums"] }, + wrap: { alignItems: "center", justifyContent: "center", paddingHorizontal: 4 }, }); From d432e9f93ff38580576884320b354dc3aaf6c1a9 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 8 Jul 2026 22:24:52 -0400 Subject: [PATCH 08/24] feat(mobile): show context-window size in Apple + Rork provider notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round out the context-limit hints beyond the OpenAI field: the Apple and Rork provider notes in AI settings now state the context window too — Apple PCC ~32K / on-device ~4K, Rork ~200K (estimated, since the underlying model is uncertain) — so the value backing the usage ring is visible for every provider. type-check + lint clean. --- mobile/src/components/AiSettingsForm.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mobile/src/components/AiSettingsForm.tsx b/mobile/src/components/AiSettingsForm.tsx index 992f0045b..f4da35652 100644 --- a/mobile/src/components/AiSettingsForm.tsx +++ b/mobile/src/components/AiSettingsForm.tsx @@ -238,8 +238,8 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { {appleIsServer - ? "Apple Intelligence runs on Private Cloud Compute — private, no API key, with a larger context window and stronger reasoning. Needs a connection and has a daily usage limit." - : "Apple Intelligence runs entirely on your device — private, offline, no API key. Best for quick chats; it has a small context window, so long conversations may need a fresh start."} + ? "Apple Intelligence runs on Private Cloud Compute — private, no API key, with a larger context window and stronger reasoning. Needs a connection and has a daily usage limit. Context window: ~32K tokens." + : "Apple Intelligence runs entirely on your device — private, offline, no API key. Best for quick chats; it has a small context window, so long conversations may need a fresh start. Context window: ~4K tokens."} )} @@ -259,7 +259,8 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { Rork is built into this app — no configuration needed. Switch to - OpenAI-compatible to use your own endpoint and key. + OpenAI-compatible to use your own endpoint and key. Context window: + ~200K tokens (estimated). )} From 681505ac08841a269db73879d7a85e2bc8747b7c Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Thu, 9 Jul 2026 07:08:40 -0400 Subject: [PATCH 09/24] PR Review --- mobile/app/(tabs)/chat/index.tsx | 5 +- .../apple-llm/ios/AppleLlmModule.swift | 65 ++++++++++--------- mobile/src/chat/models-dev.ts | 3 +- mobile/src/chat/providers/apple.ts | 8 ++- mobile/src/chat/providers/openai.ts | 33 ++++++---- mobile/src/components/AiSettingsForm.tsx | 2 +- 6 files changed, 68 insertions(+), 48 deletions(-) diff --git a/mobile/app/(tabs)/chat/index.tsx b/mobile/app/(tabs)/chat/index.tsx index edf62cbfa..314029a0c 100644 --- a/mobile/app/(tabs)/chat/index.tsx +++ b/mobile/app/(tabs)/chat/index.tsx @@ -177,7 +177,10 @@ export default function ChatScreen() { patchAssistant({ tools: [...toolTrail] }); haptics.impact(); // agent ran a tool } else if (e.type === "final" && e.usage) { - setUsage(e.usage); // context-window ring (Apple provider) + // Only drive the ring with valid token counts — a negative prompt + // count or non-positive limit renders a broken/empty ring. + const u = e.usage; + if (u.promptTokens >= 0 && u.contextLimit > 0) setUsage(u); } setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 20); }; diff --git a/mobile/modules/apple-llm/ios/AppleLlmModule.swift b/mobile/modules/apple-llm/ios/AppleLlmModule.swift index 24d46ea8a..64b616b85 100644 --- a/mobile/modules/apple-llm/ios/AppleLlmModule.swift +++ b/mobile/modules/apple-llm/ios/AppleLlmModule.swift @@ -278,34 +278,36 @@ public class AppleLlmModule: Module { /// PCC daily-quota snapshot as JSON (see the `quotaStatus` Function doc). private static func quotaStatusJson() -> String { #if CAIRN_PCC_SDK - if #available(iOS 27.0, *), case .available = PrivateCloudComputeLanguageModel().availability { + if #available(iOS 27.0, *) { let model = PrivateCloudComputeLanguageModel() - let usage = model.quotaUsage - var status = "unknown" - var approaching = false - if usage.isLimitReached { - status = "exceeded" - } else if case .belowLimit(let info) = usage.status { - approaching = info.isApproachingLimit - status = approaching ? "approaching" : "below" - } - let iso: String - if let reset = usage.resetDate { - let fmt = ISO8601DateFormatter() - iso = fmt.string(from: reset) - } else { - iso = "" - } - let dict: [String: Any] = [ - "available": true, - "status": status, - "isLimitReached": usage.isLimitReached, - "canUpgrade": usage.limitIncreaseSuggestion != nil, - "resetDate": iso, - ] - if let data = try? JSONSerialization.data(withJSONObject: dict), - let json = String(data: data, encoding: .utf8) { - return json + if case .available = model.availability { + let usage = model.quotaUsage + var status = "unknown" + var approaching = false + if usage.isLimitReached { + status = "exceeded" + } else if case .belowLimit(let info) = usage.status { + approaching = info.isApproachingLimit + status = approaching ? "approaching" : "below" + } + let iso: String + if let reset = usage.resetDate { + let fmt = ISO8601DateFormatter() + iso = fmt.string(from: reset) + } else { + iso = "" + } + let dict: [String: Any] = [ + "available": true, + "status": status, + "isLimitReached": usage.isLimitReached, + "canUpgrade": usage.limitIncreaseSuggestion != nil, + "resetDate": iso, + ] + if let data = try? JSONSerialization.data(withJSONObject: dict), + let json = String(data: data, encoding: .utf8) { + return json + } } } #endif @@ -420,7 +422,7 @@ public class AppleLlmModule: Module { self.emitError(requestId, .cancelled, "Generation cancelled.") self.removeTask(requestId) } catch { - let (code, message) = Self.mapError(error) + let (code, message) = Self.mapError(error, useServer: useServer) self.emitError(requestId, code, message) self.removeTask(requestId) } @@ -549,7 +551,7 @@ public class AppleLlmModule: Module { @available(iOS 26.0, *) - private static func mapError(_ error: Error) -> (AppleLlmCode, String) { + private static func mapError(_ error: Error, useServer: Bool) -> (AppleLlmCode, String) { // Private Cloud Compute daily-quota exhaustion (iOS 27+). Checked first so // the JS side can steer the user to an iCloud+ upgrade instead of retrying. #if CAIRN_PCC_SDK @@ -575,9 +577,10 @@ public class AppleLlmModule: Module { return (.toolCallError, toolError.message) } // A PCC request with no connectivity surfaces as a URL/network error; flag it - // so JS can retry on-device. + // so JS can retry on-device. Only PCC actually needs the network — on-device + // generation is offline, so don't blame connectivity there. let ns = error as NSError - if ns.domain == NSURLErrorDomain { + if useServer, ns.domain == NSURLErrorDomain { return (.networkUnavailable, "Private Cloud Compute needs a connection. Reconnect and try again.") } return (.generationError, error.localizedDescription) diff --git a/mobile/src/chat/models-dev.ts b/mobile/src/chat/models-dev.ts index e1eda9907..24298c007 100644 --- a/mobile/src/chat/models-dev.ts +++ b/mobile/src/chat/models-dev.ts @@ -102,7 +102,8 @@ async function ensureMap(): Promise { writeSetting(CACHE_AT_KEY, String(Date.now())); return map; } catch { - memoryCache = {}; + // Don't cache the empty result — leaving memoryCache unset lets the next + // ensureMap() call retry (inflight still de-dupes concurrent callers). return {}; } finally { inflight = null; diff --git a/mobile/src/chat/providers/apple.ts b/mobile/src/chat/providers/apple.ts index 5c8c7900f..b33add887 100644 --- a/mobile/src/chat/providers/apple.ts +++ b/mobile/src/chat/providers/apple.ts @@ -253,7 +253,7 @@ function buildTools(tools: Record, server: boolean): AppleTool[] } /** Execute a tool locally and return its result as a JSON string. */ -function runToolToJson(toolName: string, inputJson: string): { resultJson: string; error?: string } { +async function runToolToJson(toolName: string, inputJson: string): Promise<{ resultJson: string; error?: string }> { const tool = TOOL_MAP.get(toolName); if (!tool) { return { resultJson: JSON.stringify({ error: `Unknown tool: ${toolName}` }) }; @@ -265,7 +265,9 @@ function runToolToJson(toolName: string, inputJson: string): { resultJson: strin args = {}; } try { - const result = tool.run(args); + // Tools may be sync or async (e.g. semantic_search_notes) — await either so + // a Promise isn't stringified as "{}" and the real output lost. + const result = await tool.run(args); return { resultJson: JSON.stringify(result ?? {}) }; } catch (e) { const message = e instanceof Error ? e.message : String(e); @@ -384,7 +386,7 @@ function makeStreamApple(server: boolean) { } catch { input = { _raw: item.input }; } - const { resultJson, error } = runToolToJson(item.toolName, item.input); + const { resultJson, error } = await runToolToJson(item.toolName, item.input); let output: unknown; try { output = JSON.parse(resultJson); diff --git a/mobile/src/chat/providers/openai.ts b/mobile/src/chat/providers/openai.ts index 793e0e107..5641432a3 100644 --- a/mobile/src/chat/providers/openai.ts +++ b/mobile/src/chat/providers/openai.ts @@ -121,23 +121,34 @@ function makeStreamer(config: OpenAIConfig) { messages: messages.flatMap(mapMessage), stream: true, // Ask for a final usage chunk (prompt/completion tokens) to drive the - // context ring. Harmless on endpoints that ignore it. + // context ring. Standard OpenAI honours it; some OpenAI-compatible + // gateways reject unknown fields with a 400, so we retry without it below. stream_options: { include_usage: true }, }; const mapped = mapTools(tools); if (mapped.length > 0) body.tools = mapped; const url = new URL("chat/completions", config.baseUrl.replace(/\/?$/, "/")).toString(); - const res = await expoFetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "text/event-stream", - Authorization: `Bearer ${config.apiKey}`, - }, - body: JSON.stringify(body), - signal, - }); + const send = (payload: Record) => + expoFetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + Authorization: `Bearer ${config.apiKey}`, + }, + body: JSON.stringify(payload), + signal, + }); + + let res = await send(body); + // A 400 may mean the gateway rejected `stream_options`; retry once without + // it (we just lose server-reported usage, falling back to no ring). + if (res.status === 400 && "stream_options" in body) { + const rest = { ...body }; + delete rest.stream_options; + res = await send(rest); + } if (!res.ok || !res.body) { const detail = res.ok ? "no response body" : `HTTP ${res.status}`; throw new Error(`OpenAI provider error (${detail})`); diff --git a/mobile/src/components/AiSettingsForm.tsx b/mobile/src/components/AiSettingsForm.tsx index f4da35652..6cf943f09 100644 --- a/mobile/src/components/AiSettingsForm.tsx +++ b/mobile/src/components/AiSettingsForm.tsx @@ -269,7 +269,7 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { {appleReason - ? `On-device AI is unavailable: ${appleReason} Configure an OpenAI-compatible endpoint and API key below to enable chat.` + ? `Apple Intelligence is unavailable: ${appleReason} Configure an OpenAI-compatible endpoint and API key below to enable chat.` : "This build has no bundled AI endpoint. Configure an OpenAI-compatible endpoint and API key below to enable chat."} From 5b3e92e444b00c9737ff04589c2fba288d41f2c7 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Thu, 9 Jul 2026 08:13:28 -0400 Subject: [PATCH 10/24] feat(mobile): user-selectable PCC reasoning effort Replace the hardcoded "moderate" PCC reasoning level with a persisted, user-selectable setting. - ai-config: getAppleReasoningLevel / setAppleReasoningLevel backed by app_settings (key ai.apple.reasoningLevel), default "moderate". - apple.ts: the PCC provider reads the persisted level instead of hardcoding it; on-device is unchanged (no reasoning level). - AiSettingsForm: a Light / Moderate / Deep segmented control under the Apple section, shown only when Private Cloud Compute is the active Apple backend, with a hint that deeper reasoning is slower and uses more context. Reasoning effort is a PCC/iOS-27 concept; the on-device model ignores it. type-check + lint clean. --- mobile/src/chat/ai-config.ts | 20 +++++++++++++ mobile/src/chat/providers/apple.ts | 6 ++-- mobile/src/components/AiSettingsForm.tsx | 38 +++++++++++++++++++++++- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/mobile/src/chat/ai-config.ts b/mobile/src/chat/ai-config.ts index a956a1ed9..ab921d5f8 100644 --- a/mobile/src/chat/ai-config.ts +++ b/mobile/src/chat/ai-config.ts @@ -17,11 +17,13 @@ import * as SecureStore from "expo-secure-store"; import { getDb } from "../db"; +import type { AppleReasoningLevel } from "@modules/apple-llm"; const KEY_BASE_URL = "ai.openai.baseUrl"; const KEY_MODEL = "ai.openai.model"; const KEY_CONTEXT = "ai.openai.contextLimit"; // optional manual override (tokens) const KEY_PROVIDER = "ai.provider"; // "rork" | "openai" | "apple" +const KEY_APPLE_REASONING = "ai.apple.reasoningLevel"; // "light" | "moderate" | "deep" const SECURE_KEY_APIKEY = "ai.openai.apiKey"; // secure-store key /** @@ -104,6 +106,24 @@ export function setProviderPref(pref: ProviderPref): void { setSetting(KEY_PROVIDER, pref); } +/** Default PCC reasoning effort when the user hasn't chosen one. */ +export const DEFAULT_APPLE_REASONING: AppleReasoningLevel = "moderate"; + +/** + * The user's chosen PCC reasoning level (Apple Intelligence via Private Cloud + * Compute, iOS 27+). Deeper reasoning trades latency + context for stronger + * multi-step analysis. Ignored on-device / by non-Apple providers. + */ +export function getAppleReasoningLevel(): AppleReasoningLevel { + const v = getSetting(KEY_APPLE_REASONING); + return v === "light" || v === "moderate" || v === "deep" ? v : DEFAULT_APPLE_REASONING; +} + +/** Persist the PCC reasoning level. */ +export function setAppleReasoningLevel(level: AppleReasoningLevel): void { + setSetting(KEY_APPLE_REASONING, level); +} + /** Read the API key from the keychain (null if none stored). */ export async function getOpenAIApiKey(): Promise { try { diff --git a/mobile/src/chat/providers/apple.ts b/mobile/src/chat/providers/apple.ts index b33add887..ac9333380 100644 --- a/mobile/src/chat/providers/apple.ts +++ b/mobile/src/chat/providers/apple.ts @@ -40,6 +40,7 @@ import { type AppleToolCallEvent, } from "@modules/apple-llm"; import { TOOL_MAP } from "../tools"; +import { getAppleReasoningLevel } from "../ai-config"; import { type AiTool, type ChatProvider, @@ -346,8 +347,9 @@ function makeStreamApple(server: boolean) { signal?.addEventListener("abort", onAbort); // Persistent session keeps the transcript; send only the newest user prompt. - // PCC gets the 32K window + a reasoning level; on-device a small reply cap. - const reasoningLevel: AppleReasoningLevel | undefined = server ? "moderate" : undefined; + // PCC gets the 32K window + the user's chosen reasoning level; on-device a + // small reply cap and no reasoning (the on-device model doesn't expose it). + const reasoningLevel: AppleReasoningLevel | undefined = server ? getAppleReasoningLevel() : undefined; void AppleLlm.generate(requestId, sessionId, prompt, nativeTools, { system, useServer: server, diff --git a/mobile/src/components/AiSettingsForm.tsx b/mobile/src/components/AiSettingsForm.tsx index 6cf943f09..e8b10f525 100644 --- a/mobile/src/components/AiSettingsForm.tsx +++ b/mobile/src/components/AiSettingsForm.tsx @@ -9,7 +9,7 @@ import { ActivityIndicator, } from "react-native"; import { Stack } from "expo-router"; -import { Check, ShieldCheck, RefreshCw, Cpu, Apple, type LucideIcon } from "lucide-react-native"; +import { Check, ShieldCheck, RefreshCw, Cpu, Apple, Brain, type LucideIcon } from "lucide-react-native"; import { ICON_CHECK } from "@/components/toolbar-icons"; import { haptics, toolbarPress } from "@/haptics"; import { useTheme, type as typeScale, type Theme } from "@/theme"; @@ -24,6 +24,8 @@ import { setOpenAIApiKey, setOpenAIEndpoint, setProviderPref, + getAppleReasoningLevel, + setAppleReasoningLevel, type ProviderPref, } from "@/chat/ai-config"; import { isRorkAvailable } from "@/chat/providers/rork"; @@ -38,6 +40,7 @@ import { appleQuotaStatus, showAppleQuotaUpgrade, type AppleQuotaStatus, + type AppleReasoningLevel, } from "@modules/apple-llm"; import { listModels } from "@/chat/providers/openai"; import { contextLimitForModel } from "@/chat/models-dev"; @@ -80,6 +83,9 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { const [quota] = useState(() => isAppleServerProviderAvailable() ? appleQuotaStatus() : null, ); + // PCC reasoning effort (persisted). Only meaningful when PCC is the active + // Apple backend; deeper levels trade latency + context for stronger analysis. + const [reasoning, setReasoning] = useState(() => getAppleReasoningLevel()); // Whether to show the provider chooser at all: only when there's a choice. const showChooser = rorkBuiltIn || appleAvailable; @@ -254,6 +260,34 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { styles={styles} /> )} + {pref === "apple" && appleIsServer && ( + + + + Reasoning effort + + + {(["light", "moderate", "deep"] as const).map((level) => ( + { + haptics.selection(); + setReasoning(level); + setAppleReasoningLevel(level); + }} + t={t} + styles={styles} + /> + ))} + + + Deeper reasoning gives stronger multi-step answers but is slower and + uses more of the context window. + + + )} {pref === "rork" && ( @@ -530,6 +564,8 @@ function makeStyles(t: Theme) { borderRadius: 12, padding: 4, }, + reasoningBlock: { gap: 8 }, + reasoningHead: { flexDirection: "row", alignItems: "center", gap: 6 }, segmentBtn: { flex: 1, flexDirection: "row", From 02ccf5375c06bc3444c9e978490d1b7af6c1c6cf Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Thu, 9 Jul 2026 08:33:03 -0400 Subject: [PATCH 11/24] feat(mobile): stream + render PCC reasoning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the model's reasoning ("thinking") for Private Cloud Compute turns and render it as a collapsible block in the chat. Native (iOS 27+, PCC only): - The response stream's Snapshot exposes `transcriptEntries`; extract the text segments of `.reasoning` entries and emit the new tail as an `onReasoning` event, interleaved with `onToken`. This reuses the existing streamResponse(String) path — no DynamicProfile/onReasoning session rewrite needed. Fully behind `#if CAIRN_PCC_SDK` + `#available(iOS 27)`; the on-device model has no reasoning channel, so it's a no-op there and the code still parses under the iOS 26 EAS SDK with the flag off. JS: - apple-llm module: `onReasoning` event + `AppleReasoningEvent` type. - apple.ts provider: forward reasoning as `reasoning-delta` StreamEvents. - agent.ts: accumulate reasoning across a run; add `reasoning-delta` AgentEvent and pass the accumulated text on `final`. - chat: a collapsible "Thinking…/Reasoning" disclosure above the assistant bubble, auto-expanded while streaming. Session-only (not persisted to chat_local — consistent with how image attachments are handled). type-check + lint clean; Swift parses with the flag on and off; iOS JS bundle exports clean. --- mobile/app/(tabs)/chat/index.tsx | 74 ++++++++++++++++++- mobile/modules/apple-llm/AppleLlm.types.ts | 10 +++ .../apple-llm/ios/AppleLlmModule.swift | 44 ++++++++++- mobile/src/chat/agent.ts | 17 +++-- mobile/src/chat/providers/apple.ts | 12 +++ 5 files changed, 150 insertions(+), 7 deletions(-) diff --git a/mobile/app/(tabs)/chat/index.tsx b/mobile/app/(tabs)/chat/index.tsx index 314029a0c..87776f51e 100644 --- a/mobile/app/(tabs)/chat/index.tsx +++ b/mobile/app/(tabs)/chat/index.tsx @@ -13,7 +13,7 @@ import { import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import { KeyboardStickyView, useKeyboardHandler } from "react-native-keyboard-controller"; import { Stack, useRouter, useFocusEffect } from "expo-router"; -import { CheckCircle, Bot, User, Send, ImagePlus, X, Settings2 } from "lucide-react-native"; +import { CheckCircle, Bot, User, Send, ImagePlus, X, Settings2, Brain, ChevronRight } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { TabScreen } from "@/components/TabScreen"; import { EmptyState } from "@/components/EmptyState"; @@ -39,6 +39,9 @@ interface UiMessage { content: string; images?: string[]; // data URIs for user attachments tools?: { tool: string; ok: boolean }[]; + /** Live reasoning ("thinking") text for PCC turns. Shown collapsibly; like + * images it's session-only and not persisted to chat_local. */ + reasoning?: string; streaming?: boolean; } @@ -151,6 +154,7 @@ export default function ChatScreen() { // Persist the user turn locally (on-device only — chat never syncs). saveChatMessage({ role: "user", content: text, images: atts.map((a) => a.url) }); let acc = ""; + let reasoningAcc = ""; const toolTrail: { tool: string; ok: boolean }[] = []; const patchAssistant = (patch: Partial) => { @@ -171,6 +175,9 @@ export default function ChatScreen() { if (e.type === "text-delta" && e.delta) { acc += e.delta; patchAssistant({ content: acc }); + } else if (e.type === "reasoning-delta" && e.delta) { + reasoningAcc += e.delta; + patchAssistant({ reasoning: reasoningAcc }); } else if (e.type === "tool" && e.tool) { const ok = !(e.result && typeof e.result === "object" && "error" in (e.result as object)); toolTrail.push({ tool: e.tool, ok }); @@ -373,6 +380,46 @@ export default function ChatScreen() { // Memoised so a stream-token setMessages (which replaces only the streaming // assistant message object) re-renders just that one bubble — not every prior // message, each of which would otherwise re-run its MarkdownView parse per token. +/** + * Collapsible "reasoning" (thinking) disclosure for PCC turns. Expanded while + * the answer is still streaming so the user sees the model think; collapses to a + * one-line summary once done. Session-only (not persisted). + */ +function ReasoningBlock({ + text, + streaming, + t, + styles, +}: { + text: string; + streaming?: boolean; + t: Theme; + styles: ReturnType; +}) { + const [open, setOpen] = useState(false); + const expanded = open || !!streaming; + return ( + + setOpen((v) => !v)} + hitSlop={6} + accessibilityRole="button" + accessibilityLabel={expanded ? "Hide reasoning" : "Show reasoning"} + > + + {streaming ? "Thinking…" : "Reasoning"} + + + {expanded ? {text} : null} + + ); +} + const Bubble = memo(function Bubble({ m, t, styles }: { m: UiMessage; t: Theme; styles: ReturnType }) { const isUser = m.role === "user"; return ( @@ -384,6 +431,9 @@ const Bubble = memo(function Bubble({ m, t, styles }: { m: UiMessage; t: Theme; {/* Column: tool chips, bubble, timestamp */} + {!isUser && m.reasoning ? ( + + ) : null} {!isUser && m.tools && m.tools.length > 0 && ( {m.tools.map((tt, i) => ( @@ -458,6 +508,28 @@ function makeStyles(t: Theme) { }, toolChipText: { ...typeScale.caption, color: t.textSecondary }, + reasoning: { + alignSelf: "flex-start", + maxWidth: "94%", + gap: 4, + marginBottom: 2, + paddingLeft: 2, + }, + reasoningHeader: { flexDirection: "row", alignItems: "center", gap: 5 }, + reasoningLabel: { + ...typeScale.caption, + color: t.textTertiary, + textTransform: "uppercase", + letterSpacing: 0.5, + }, + reasoningText: { + ...typeScale.caption, + color: t.textTertiary, + fontStyle: "italic", + lineHeight: 17, + paddingLeft: 16, + }, + bubble: { maxWidth: "94%", paddingHorizontal: 12, paddingVertical: 10, borderRadius: 14 }, aiBubble: { backgroundColor: t.surface2, borderWidth: 1, borderColor: t.border, borderTopLeftRadius: 4, alignSelf: "flex-start" }, userBubble: { backgroundColor: t.accent, borderTopRightRadius: 4, alignSelf: "flex-end" }, diff --git a/mobile/modules/apple-llm/AppleLlm.types.ts b/mobile/modules/apple-llm/AppleLlm.types.ts index f777de473..40d030437 100644 --- a/mobile/modules/apple-llm/AppleLlm.types.ts +++ b/mobile/modules/apple-llm/AppleLlm.types.ts @@ -82,12 +82,22 @@ export interface AppleToolCallEvent { toolName: string; input: string; } +/** + * A chunk of the model's reasoning ("thinking") text, keyed by `requestId`. + * Only emitted on the PCC path (iOS 27+); the on-device model has no exposed + * reasoning channel. Deltas are the new tail of the cumulative reasoning. + */ +export interface AppleReasoningEvent { + requestId: string; + delta: string; +} export type AppleLlmEvents = { onToken: (e: AppleTokenEvent) => void; onDone: (e: AppleDoneEvent) => void; onError: (e: AppleErrorEvent) => void; onToolCall: (e: AppleToolCallEvent) => void; + onReasoning: (e: AppleReasoningEvent) => void; }; /** diff --git a/mobile/modules/apple-llm/ios/AppleLlmModule.swift b/mobile/modules/apple-llm/ios/AppleLlmModule.swift index 64b616b85..5afd1c4ed 100644 --- a/mobile/modules/apple-llm/ios/AppleLlmModule.swift +++ b/mobile/modules/apple-llm/ios/AppleLlmModule.swift @@ -11,6 +11,10 @@ private let EVENT_TOKEN = "onToken" private let EVENT_DONE = "onDone" private let EVENT_ERROR = "onError" private let EVENT_TOOL_CALL = "onToolCall" +// PCC reasoning text (iOS 27+). Streams the model's thinking before/with the +// answer. Only emitted on the server (PCC) path; the on-device model has no +// exposed reasoning channel. +private let EVENT_REASONING = "onReasoning" // Stable public error codes mirrored on the JS side (AppleLLMErrorCodes). Treat // `code` as the control-flow contract; `message` is display/debug text. @@ -76,7 +80,7 @@ public class AppleLlmModule: Module { public func definition() -> ModuleDefinition { Name("AppleLlm") - Events(EVENT_TOKEN, EVENT_DONE, EVENT_ERROR, EVENT_TOOL_CALL) + Events(EVENT_TOKEN, EVENT_DONE, EVENT_ERROR, EVENT_TOOL_CALL, EVENT_REASONING) // Synchronous availability probe. False on simulators, non-Apple-Intelligence // devices, and anything below iOS 26. Safe to call on any OS. @@ -379,6 +383,7 @@ public class AppleLlmModule: Module { let task = Task { [weak self] in guard let self else { return } var previous = "" + var previousReasoning = "" do { let stream = Self.makeStream( session: session, @@ -393,6 +398,24 @@ public class AppleLlmModule: Module { self.removeTask(requestId) return } + // PCC surfaces the model's thinking as reasoning entries in the + // snapshot transcript (iOS 27+). Emit the new tail so JS can render a + // live "reasoning" block. On-device has no reasoning entries → no-op. + #if CAIRN_PCC_SDK + if useServer, #available(iOS 27.0, *) { + let reasoning = Self.reasoningText(from: snapshot) + if reasoning.count > previousReasoning.count, reasoning.hasPrefix(previousReasoning) { + let delta = String(reasoning.dropFirst(previousReasoning.count)) + previousReasoning = reasoning + if !delta.isEmpty { + self.sendEvent(EVENT_REASONING, ["requestId": requestId, "delta": delta]) + } + } else if reasoning != previousReasoning { + previousReasoning = reasoning + self.sendEvent(EVENT_REASONING, ["requestId": requestId, "delta": reasoning]) + } + } + #endif // ResponseStream.Snapshot.content is the cumulative text so // far (String.PartiallyGenerated == String). Emit only the new tail. let text = snapshot.content @@ -452,6 +475,25 @@ public class AppleLlmModule: Module { return session.streamResponse(to: prompt, options: genOptions) } + #if CAIRN_PCC_SDK + /// Cumulative reasoning text in a PCC response snapshot (iOS 27+): concatenate + /// the text segments of every `.reasoning` transcript entry. Returns "" when + /// there's no reasoning (e.g. the model didn't think, or on-device). + @available(iOS 27.0, *) + private static func reasoningText(from snapshot: LanguageModelSession.ResponseStream.Snapshot) -> String { + var out = "" + for entry in snapshot.transcriptEntries { + guard case .reasoning(let reasoning) = entry else { continue } + for segment in reasoning.segments { + if case .text(let textSegment) = segment { + out += textSegment.content + } + } + } + return out + } + #endif + /// Context-window usage for the ring: (promptTokens, contextLimit). /// promptTokens = token count of the session transcript via the on-device /// tokenizer (iOS 26.4+); on the PCC path this is a close estimate (same diff --git a/mobile/src/chat/agent.ts b/mobile/src/chat/agent.ts index 5bad80a13..07b3844e1 100644 --- a/mobile/src/chat/agent.ts +++ b/mobile/src/chat/agent.ts @@ -23,12 +23,13 @@ import { toolsForAgent, TOOL_MAP } from "./tools"; const MAX_TURNS = 8; export interface AgentEvent { - type: "text-delta" | "tool" | "final" | "error"; - delta?: string; // for text-delta + type: "text-delta" | "reasoning-delta" | "tool" | "final" | "error"; + delta?: string; // for text-delta / reasoning-delta tool?: string; args?: unknown; result?: unknown; text?: string; // full text for final / error + reasoning?: string; // accumulated reasoning text, on "final" usage?: ChatUsage; // context-window usage, on "final" } @@ -109,6 +110,8 @@ export async function runAgent( ): Promise { const tools = toolsForAgent(); let finalText = ""; + // Accumulated reasoning ("thinking") text across the run's turns (PCC only). + let reasoning = ""; // Context-window usage from the latest turn's finish event (Apple provider). let usage: ChatUsage | undefined; @@ -142,6 +145,10 @@ export async function runAgent( const delta = (ev as { delta: string }).delta; text += delta; onEvent?.({ type: "text-delta", delta }); + } else if (ev.type === "reasoning-delta" && typeof (ev as { delta?: string }).delta === "string") { + const delta = (ev as { delta: string }).delta; + reasoning += delta; + onEvent?.({ type: "reasoning-delta", delta }); } else if (ev.type === "tool-input-available") { const e = ev as { toolCallId: string; toolName: string; input: unknown }; toolCalls.push({ id: e.toolCallId, name: e.toolName, input: e.input }); @@ -188,7 +195,7 @@ export async function runAgent( // Ensure the assistant turn is in history even if empty-ish. if (assistant.parts.length === 0) assistant.parts.push({ type: "text", text: finalText }); conversation.push(assistant); - onEvent?.({ type: "final", text: finalText, usage }); + onEvent?.({ type: "final", text: finalText, reasoning: reasoning || undefined, usage }); return finalText; } @@ -224,13 +231,13 @@ export async function runAgent( conversation.push(assistant); // Loop for the model's follow-up turn (it now sees the tool outputs). if (finishReason === "stop") { - onEvent?.({ type: "final", text: finalText, usage }); + onEvent?.({ type: "final", text: finalText, reasoning: reasoning || undefined, usage }); return finalText; } } const msg = finalText || "I couldn't finish that in a reasonable number of steps."; - onEvent?.({ type: "final", text: msg, usage }); + onEvent?.({ type: "final", text: msg, reasoning: reasoning || undefined, usage }); return msg; } diff --git a/mobile/src/chat/providers/apple.ts b/mobile/src/chat/providers/apple.ts index ac9333380..4bace81b8 100644 --- a/mobile/src/chat/providers/apple.ts +++ b/mobile/src/chat/providers/apple.ts @@ -35,6 +35,7 @@ import { isAppleLlmAvailable, isAppleServerAvailable, type AppleErrorEvent, + type AppleReasoningEvent, type AppleReasoningLevel, type AppleTool, type AppleToolCallEvent, @@ -316,6 +317,7 @@ function makeStreamApple(server: boolean) { type Item = | { kind: "token"; delta: string } + | { kind: "reasoning"; delta: string } | { kind: "toolCall"; callId: string; toolName: string; input: string } | { kind: "done"; finishReason: string; promptTokens?: number; contextLimit?: number } | { kind: "error"; err: AppleLLMError }; @@ -329,6 +331,10 @@ function makeStreamApple(server: boolean) { const subToken = AppleLlm.addListener("onToken", (e) => { if (e.requestId === requestId) push({ kind: "token", delta: e.delta }); }); + // PCC reasoning text (iOS 27+); no-op on-device (never emitted there). + const subReasoning = AppleLlm.addListener("onReasoning", (e: AppleReasoningEvent) => { + if (e.requestId === requestId) push({ kind: "reasoning", delta: e.delta }); + }); const subDone = AppleLlm.addListener("onDone", (e) => { if (e.requestId === requestId) push({ kind: "done", finishReason: e.finishReason, promptTokens: e.promptTokens, contextLimit: e.contextLimit }); @@ -378,6 +384,11 @@ function makeStreamApple(server: boolean) { continue; } + if (item.kind === "reasoning") { + yield { type: "reasoning-delta", delta: item.delta }; + continue; + } + if (item.kind === "toolCall") { // Execute locally, surface for the UI tool-trail (as an already-executed // tool so agent.ts doesn't re-run it), then resolve back into the native @@ -419,6 +430,7 @@ function makeStreamApple(server: boolean) { } } finally { subToken.remove(); + subReasoning.remove(); subDone.remove(); subError.remove(); subTool.remove(); From 6ae47324dc733ab2c50ef447d083a2c6bc040301 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Thu, 9 Jul 2026 08:58:46 -0400 Subject: [PATCH 12/24] accessibilityState fix --- mobile/app/(tabs)/chat/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/app/(tabs)/chat/index.tsx b/mobile/app/(tabs)/chat/index.tsx index 87776f51e..ea6e6c465 100644 --- a/mobile/app/(tabs)/chat/index.tsx +++ b/mobile/app/(tabs)/chat/index.tsx @@ -406,6 +406,7 @@ function ReasoningBlock({ hitSlop={6} accessibilityRole="button" accessibilityLabel={expanded ? "Hide reasoning" : "Show reasoning"} + accessibilityState={{ expanded }} > {streaming ? "Thinking…" : "Reasoning"} From 3f3fe35d2ba15a3dfccb288f209c773f56faccd3 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Thu, 9 Jul 2026 09:24:16 -0400 Subject: [PATCH 13/24] feat(mobile): stream reasoning from OpenAI-compatible endpoints Extend the reasoning ("thinking") block beyond Apple PCC to OpenAI-compatible endpoints that stream reasoning in their chat.completion deltas: - delta.reasoning_content (DeepSeek and DeepSeek models via Together/OpenRouter) - delta.reasoning (OpenRouter's unified field) Emitted as reasoning-delta before the answer content, reusing the existing agent -> AgentEvent -> collapsible ReasoningBlock pipeline built for PCC. No UI or gating changes needed. First-party OpenAI o-series doesn't stream reasoning content, so nothing shows for those models (expected). Broadened the reasoning doc comments to note the OpenAI path. type-check + lint clean; iOS JS bundle exports clean. --- mobile/app/(tabs)/chat/index.tsx | 12 +++++++----- mobile/src/chat/providers/openai.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/mobile/app/(tabs)/chat/index.tsx b/mobile/app/(tabs)/chat/index.tsx index ea6e6c465..09af7b32c 100644 --- a/mobile/app/(tabs)/chat/index.tsx +++ b/mobile/app/(tabs)/chat/index.tsx @@ -39,8 +39,9 @@ interface UiMessage { content: string; images?: string[]; // data URIs for user attachments tools?: { tool: string; ok: boolean }[]; - /** Live reasoning ("thinking") text for PCC turns. Shown collapsibly; like - * images it's session-only and not persisted to chat_local. */ + /** Live reasoning ("thinking") text for turns whose model streams it (Apple + * PCC, or OpenAI-compatible endpoints exposing reasoning/reasoning_content). + * Shown collapsibly; like images it's session-only and not persisted. */ reasoning?: string; streaming?: boolean; } @@ -381,9 +382,10 @@ export default function ChatScreen() { // assistant message object) re-renders just that one bubble — not every prior // message, each of which would otherwise re-run its MarkdownView parse per token. /** - * Collapsible "reasoning" (thinking) disclosure for PCC turns. Expanded while - * the answer is still streaming so the user sees the model think; collapses to a - * one-line summary once done. Session-only (not persisted). + * Collapsible "reasoning" (thinking) disclosure for models that stream it + * (Apple PCC, or OpenAI-compatible endpoints like DeepSeek/OpenRouter). + * Expanded while the answer is still streaming so the user sees the model think; + * collapses to a one-line summary once done. Session-only (not persisted). */ function ReasoningBlock({ text, diff --git a/mobile/src/chat/providers/openai.ts b/mobile/src/chat/providers/openai.ts index 5641432a3..4f56e2d5a 100644 --- a/mobile/src/chat/providers/openai.ts +++ b/mobile/src/chat/providers/openai.ts @@ -204,6 +204,12 @@ function makeStreamer(config: OpenAIConfig) { choices?: { delta?: { content?: string; + // Reasoning ("thinking") text streamed by some OpenAI-compatible + // endpoints. DeepSeek uses `reasoning_content`; OpenRouter's + // unified field is `reasoning`. First-party OpenAI o-series does + // NOT stream reasoning, so this is simply absent there. + reasoning_content?: string; + reasoning?: string; tool_calls?: { index: number; id?: string; @@ -226,6 +232,12 @@ function makeStreamer(config: OpenAIConfig) { const choice = chunk.choices?.[0]; if (!choice) continue; const delta = choice.delta; + // Reasoning tail (if the endpoint streams it) — surface it before the + // answer content, mirroring the Apple/PCC reasoning block. + const reasoning = delta?.reasoning_content ?? delta?.reasoning; + if (reasoning) { + yield { type: "reasoning-delta", delta: reasoning }; + } if (delta?.content) { yield { type: "text-delta", delta: delta.content }; } From e9e32656cd83978e3da1b4ad5ba94ef9354e94a5 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Thu, 9 Jul 2026 15:22:26 -0400 Subject: [PATCH 14/24] fix(mobile): drop Apple PCC reasoning extraction (redacted, not usable) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device testing + Apple's docs confirm PCC reasoning is not exposed as usable text: ContextOptions.ReasoningLevel controls how much the model "thinks", but Transcript.Reasoning carries an opaque, producer-signed signature and its text segments come back redacted for Private Cloud Compute. The transcript-scraping path we added read that (empty/redacted) channel, so the Apple reasoning block only ever showed "[REDACTED]". Remove the Apple-side reasoning plumbing (transcriptEntries extraction, onReasoning event, AppleReasoningEvent type, and the temporary diagnostics). Kept intact: - OpenAI-compatible reasoning (reasoning_content / reasoning) — verified working. - The reasoning-level toggle — it still tunes PCC thinking effort (affecting answer quality/latency), even though the thinking text isn't shown. - The agent -> AgentEvent -> collapsible ReasoningBlock pipeline, now driven solely by providers that actually stream readable reasoning (OpenAI). type-check + lint clean; Swift parses with the flag on and off; iOS JS bundle exports clean. --- mobile/modules/apple-llm/AppleLlm.types.ts | 10 ----- .../apple-llm/ios/AppleLlmModule.swift | 44 +------------------ mobile/src/chat/providers/apple.ts | 12 ----- 3 files changed, 1 insertion(+), 65 deletions(-) diff --git a/mobile/modules/apple-llm/AppleLlm.types.ts b/mobile/modules/apple-llm/AppleLlm.types.ts index 40d030437..f777de473 100644 --- a/mobile/modules/apple-llm/AppleLlm.types.ts +++ b/mobile/modules/apple-llm/AppleLlm.types.ts @@ -82,22 +82,12 @@ export interface AppleToolCallEvent { toolName: string; input: string; } -/** - * A chunk of the model's reasoning ("thinking") text, keyed by `requestId`. - * Only emitted on the PCC path (iOS 27+); the on-device model has no exposed - * reasoning channel. Deltas are the new tail of the cumulative reasoning. - */ -export interface AppleReasoningEvent { - requestId: string; - delta: string; -} export type AppleLlmEvents = { onToken: (e: AppleTokenEvent) => void; onDone: (e: AppleDoneEvent) => void; onError: (e: AppleErrorEvent) => void; onToolCall: (e: AppleToolCallEvent) => void; - onReasoning: (e: AppleReasoningEvent) => void; }; /** diff --git a/mobile/modules/apple-llm/ios/AppleLlmModule.swift b/mobile/modules/apple-llm/ios/AppleLlmModule.swift index 5afd1c4ed..64b616b85 100644 --- a/mobile/modules/apple-llm/ios/AppleLlmModule.swift +++ b/mobile/modules/apple-llm/ios/AppleLlmModule.swift @@ -11,10 +11,6 @@ private let EVENT_TOKEN = "onToken" private let EVENT_DONE = "onDone" private let EVENT_ERROR = "onError" private let EVENT_TOOL_CALL = "onToolCall" -// PCC reasoning text (iOS 27+). Streams the model's thinking before/with the -// answer. Only emitted on the server (PCC) path; the on-device model has no -// exposed reasoning channel. -private let EVENT_REASONING = "onReasoning" // Stable public error codes mirrored on the JS side (AppleLLMErrorCodes). Treat // `code` as the control-flow contract; `message` is display/debug text. @@ -80,7 +76,7 @@ public class AppleLlmModule: Module { public func definition() -> ModuleDefinition { Name("AppleLlm") - Events(EVENT_TOKEN, EVENT_DONE, EVENT_ERROR, EVENT_TOOL_CALL, EVENT_REASONING) + Events(EVENT_TOKEN, EVENT_DONE, EVENT_ERROR, EVENT_TOOL_CALL) // Synchronous availability probe. False on simulators, non-Apple-Intelligence // devices, and anything below iOS 26. Safe to call on any OS. @@ -383,7 +379,6 @@ public class AppleLlmModule: Module { let task = Task { [weak self] in guard let self else { return } var previous = "" - var previousReasoning = "" do { let stream = Self.makeStream( session: session, @@ -398,24 +393,6 @@ public class AppleLlmModule: Module { self.removeTask(requestId) return } - // PCC surfaces the model's thinking as reasoning entries in the - // snapshot transcript (iOS 27+). Emit the new tail so JS can render a - // live "reasoning" block. On-device has no reasoning entries → no-op. - #if CAIRN_PCC_SDK - if useServer, #available(iOS 27.0, *) { - let reasoning = Self.reasoningText(from: snapshot) - if reasoning.count > previousReasoning.count, reasoning.hasPrefix(previousReasoning) { - let delta = String(reasoning.dropFirst(previousReasoning.count)) - previousReasoning = reasoning - if !delta.isEmpty { - self.sendEvent(EVENT_REASONING, ["requestId": requestId, "delta": delta]) - } - } else if reasoning != previousReasoning { - previousReasoning = reasoning - self.sendEvent(EVENT_REASONING, ["requestId": requestId, "delta": reasoning]) - } - } - #endif // ResponseStream.Snapshot.content is the cumulative text so // far (String.PartiallyGenerated == String). Emit only the new tail. let text = snapshot.content @@ -475,25 +452,6 @@ public class AppleLlmModule: Module { return session.streamResponse(to: prompt, options: genOptions) } - #if CAIRN_PCC_SDK - /// Cumulative reasoning text in a PCC response snapshot (iOS 27+): concatenate - /// the text segments of every `.reasoning` transcript entry. Returns "" when - /// there's no reasoning (e.g. the model didn't think, or on-device). - @available(iOS 27.0, *) - private static func reasoningText(from snapshot: LanguageModelSession.ResponseStream.Snapshot) -> String { - var out = "" - for entry in snapshot.transcriptEntries { - guard case .reasoning(let reasoning) = entry else { continue } - for segment in reasoning.segments { - if case .text(let textSegment) = segment { - out += textSegment.content - } - } - } - return out - } - #endif - /// Context-window usage for the ring: (promptTokens, contextLimit). /// promptTokens = token count of the session transcript via the on-device /// tokenizer (iOS 26.4+); on the PCC path this is a close estimate (same diff --git a/mobile/src/chat/providers/apple.ts b/mobile/src/chat/providers/apple.ts index 4bace81b8..ac9333380 100644 --- a/mobile/src/chat/providers/apple.ts +++ b/mobile/src/chat/providers/apple.ts @@ -35,7 +35,6 @@ import { isAppleLlmAvailable, isAppleServerAvailable, type AppleErrorEvent, - type AppleReasoningEvent, type AppleReasoningLevel, type AppleTool, type AppleToolCallEvent, @@ -317,7 +316,6 @@ function makeStreamApple(server: boolean) { type Item = | { kind: "token"; delta: string } - | { kind: "reasoning"; delta: string } | { kind: "toolCall"; callId: string; toolName: string; input: string } | { kind: "done"; finishReason: string; promptTokens?: number; contextLimit?: number } | { kind: "error"; err: AppleLLMError }; @@ -331,10 +329,6 @@ function makeStreamApple(server: boolean) { const subToken = AppleLlm.addListener("onToken", (e) => { if (e.requestId === requestId) push({ kind: "token", delta: e.delta }); }); - // PCC reasoning text (iOS 27+); no-op on-device (never emitted there). - const subReasoning = AppleLlm.addListener("onReasoning", (e: AppleReasoningEvent) => { - if (e.requestId === requestId) push({ kind: "reasoning", delta: e.delta }); - }); const subDone = AppleLlm.addListener("onDone", (e) => { if (e.requestId === requestId) push({ kind: "done", finishReason: e.finishReason, promptTokens: e.promptTokens, contextLimit: e.contextLimit }); @@ -384,11 +378,6 @@ function makeStreamApple(server: boolean) { continue; } - if (item.kind === "reasoning") { - yield { type: "reasoning-delta", delta: item.delta }; - continue; - } - if (item.kind === "toolCall") { // Execute locally, surface for the UI tool-trail (as an already-executed // tool so agent.ts doesn't re-run it), then resolve back into the native @@ -430,7 +419,6 @@ function makeStreamApple(server: boolean) { } } finally { subToken.remove(); - subReasoning.remove(); subDone.remove(); subError.remove(); subTool.remove(); From a6206af601a376555e041b1ebd9addae0de28156 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Thu, 9 Jul 2026 15:50:02 -0400 Subject: [PATCH 15/24] feat(mobile): present Sync + AI settings as native formSheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the `sync` and `settings/ai` routes from full-screen `presentation: "modal"` to `"formSheet"` — the modern iOS sheet (rounded corners, detents, glass chrome) that leaves the previous screen partly visible behind it. Both are scrollable detail/settings screens that suit a sheet, and this matches what their own doc comments already claimed ("form-sheet modal"); updated the sync comment to match. The full-content editing modals (note/new, card/new) stay as `"modal"` — a form sheet's partial height is a worse fit for full note/task composition, and transparent presentations aren't appropriate for opaque content screens. type-check + lint clean. --- mobile/app/_layout.tsx | 4 ++-- mobile/app/sync.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 78b9595c7..b87ec502f 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -116,9 +116,9 @@ export default function RootLayout() { - + - + diff --git a/mobile/app/sync.tsx b/mobile/app/sync.tsx index 407f70f8e..35adb8184 100644 --- a/mobile/app/sync.tsx +++ b/mobile/app/sync.tsx @@ -11,10 +11,10 @@ import { useModalOpenHaptic, toolbarPress } from "@/haptics"; import { useTheme, type as typeScale, type Theme } from "@/theme"; /** - * Sync detail presented as a native modal (see the `sync` screen's - * `presentation: "modal"` in the root layout). Reached from the header sync + * Sync detail presented as a native form-sheet modal (see the `sync` screen's + * `presentation: "formSheet"` in the root layout). Reached from the header sync * badge (SyncStatusBadge). A native Stack.Toolbar "Done" button — plus the - * modal's swipe-down — dismisses it, matching the new-note / AI-settings modal + * sheet's swipe-down — dismisses it, matching the new-note / AI-settings modal * pattern. Owns the things the badge can't: iCloud availability diagnostics, the * conflict-resolution entry point, and the last sync result. */ From b60116e77e6fab689d0cede46af4d858ae8908ba Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Thu, 9 Jul 2026 16:04:25 -0400 Subject: [PATCH 16/24] fix(mobile): revert Sync/AI settings to modal; unify modal backgrounds The formSheet presentation showed empty content when reopened, so revert both `sync` and `settings/ai` back to `presentation: "modal"`. Also unify the modal surface palette on the look preferred in AI settings (page = surface, raised elements = surface2). The Sync modal previously used the base `background` for its page with `surface` cards, which read as inverted next to AI settings. Now: - Sync page: background -> surface - Sync cards (folder/result/conflict) + EmbeddingsCard card: surface -> surface2 - Active conflict row + EmbeddingsCard button step to surface3 to stay distinct - Sync content padding 20 -> 18 to match the AI settings body type-check + lint clean. --- mobile/app/_layout.tsx | 4 ++-- mobile/app/sync.tsx | 18 +++++++++--------- mobile/src/components/EmbeddingsCard.tsx | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index b87ec502f..78b9595c7 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -116,9 +116,9 @@ export default function RootLayout() { - + - + diff --git a/mobile/app/sync.tsx b/mobile/app/sync.tsx index 35adb8184..75a8695a6 100644 --- a/mobile/app/sync.tsx +++ b/mobile/app/sync.tsx @@ -11,10 +11,10 @@ import { useModalOpenHaptic, toolbarPress } from "@/haptics"; import { useTheme, type as typeScale, type Theme } from "@/theme"; /** - * Sync detail presented as a native form-sheet modal (see the `sync` screen's - * `presentation: "formSheet"` in the root layout). Reached from the header sync + * Sync detail presented as a native modal (see the `sync` screen's + * `presentation: "modal"` in the root layout). Reached from the header sync * badge (SyncStatusBadge). A native Stack.Toolbar "Done" button — plus the - * sheet's swipe-down — dismisses it, matching the new-note / AI-settings modal + * modal's swipe-down — dismisses it, matching the new-note / AI-settings modal * pattern. Owns the things the badge can't: iCloud availability diagnostics, the * conflict-resolution entry point, and the last sync result. */ @@ -39,7 +39,7 @@ export default function SyncScreen() { }; return ( - + Done @@ -113,8 +113,8 @@ export default function SyncScreen() { function makeStyles(t: Theme) { return StyleSheet.create({ root: { flex: 1 }, - container: { padding: 20 }, - card: { padding: 16, backgroundColor: t.surface, borderRadius: 12, borderWidth: 1, borderColor: t.border }, + container: { padding: 18 }, + card: { padding: 16, backgroundColor: t.surface2, borderRadius: 12, borderWidth: 1, borderColor: t.border }, cardLabel: { fontSize: 12, fontWeight: "600", color: t.textTertiary, textTransform: "uppercase", letterSpacing: 0.5 }, statusRow: { flexDirection: "row", alignItems: "center", gap: 8, marginTop: 10 }, dot: { width: 9, height: 9, borderRadius: 5 }, @@ -124,10 +124,10 @@ function makeStyles(t: Theme) { buttonDisabled: { opacity: 0.6 }, buttonText: { ...typeScale.control, color: t.accentFg }, autoNote: { marginTop: 10, ...typeScale.caption, color: t.textTertiary, lineHeight: 17 }, - result: { marginTop: 16, padding: 14, backgroundColor: t.surface, borderRadius: 10, borderWidth: 1, borderColor: t.border }, + result: { marginTop: 16, padding: 14, backgroundColor: t.surface2, borderRadius: 10, borderWidth: 1, borderColor: t.border }, resultLine: { ...typeScale.caption, color: t.textPrimary, marginBottom: 4 }, - conflictRow: { flexDirection: "row", alignItems: "center", gap: 12, marginTop: 16, padding: 14, backgroundColor: t.surface, borderRadius: 12, borderWidth: 1, borderColor: t.border }, - conflictRowActive: { borderColor: t.warning, backgroundColor: t.surface2 }, + conflictRow: { flexDirection: "row", alignItems: "center", gap: 12, marginTop: 16, padding: 14, backgroundColor: t.surface2, borderRadius: 12, borderWidth: 1, borderColor: t.border }, + conflictRowActive: { borderColor: t.warning, backgroundColor: t.surface3 }, conflictTitle: { ...typeScale.control, color: t.textSecondary }, conflictHelp: { ...typeScale.caption, color: t.textTertiary, marginTop: 2, lineHeight: 16 }, note: { marginTop: 24, ...typeScale.caption, color: t.textTertiary, lineHeight: 18 }, diff --git a/mobile/src/components/EmbeddingsCard.tsx b/mobile/src/components/EmbeddingsCard.tsx index 756ba2a6b..e06be71da 100644 --- a/mobile/src/components/EmbeddingsCard.tsx +++ b/mobile/src/components/EmbeddingsCard.tsx @@ -101,14 +101,14 @@ export function EmbeddingsCard() { function makeStyles(t: Theme) { return StyleSheet.create({ - card: { padding: 16, backgroundColor: t.surface, borderRadius: 12, borderWidth: 1, borderColor: t.border, marginTop: 16 }, + card: { padding: 16, backgroundColor: t.surface2, borderRadius: 12, borderWidth: 1, borderColor: t.border, marginTop: 16 }, header: { flexDirection: "row", alignItems: "center", gap: 8 }, cardLabel: { fontSize: 12, fontWeight: "600", color: t.textTertiary, textTransform: "uppercase", letterSpacing: 0.5 }, statusRow: { flexDirection: "row", alignItems: "center", gap: 8, marginTop: 10 }, progress: { ...typeScale.control, color: t.textSecondary }, count: { ...typeScale.control, color: t.textPrimary, marginTop: 10 }, help: { ...typeScale.caption, color: t.textTertiary, marginTop: 10, lineHeight: 18 }, - button: { backgroundColor: t.surface2, borderWidth: 1, borderColor: t.border, paddingVertical: 12, borderRadius: 10, alignItems: "center", marginTop: 14 }, + button: { backgroundColor: t.surface3, borderWidth: 1, borderColor: t.border, paddingVertical: 12, borderRadius: 10, alignItems: "center", marginTop: 14 }, buttonDisabled: { opacity: 0.6 }, buttonText: { ...typeScale.control, color: t.accent }, }); From f951520a5bbcf660c98779df8f4b03b9480f68c4 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Thu, 9 Jul 2026 16:10:28 -0400 Subject: [PATCH 17/24] style(mobile): unify New Task modal surfaces with Sync/AI settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit card/new was a card-based form using the base background with surface inputs — the same inverted look Sync had. Match the preferred AI-settings convention: page = surface, raised inputs (title/description) = surface2. Left note/new unchanged: it's a bare full-bleed editor that mirrors the note detail screen (note/[id], which uses background), so matching that is more important than matching the card-based modals. type-check + lint clean. --- mobile/app/card/new.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/app/card/new.tsx b/mobile/app/card/new.tsx index d88cde658..38b4fe3d5 100644 --- a/mobile/app/card/new.tsx +++ b/mobile/app/card/new.tsx @@ -105,13 +105,13 @@ export default function NewCard() { function makeStyles(t: Theme) { return StyleSheet.create({ - container: { flex: 1, backgroundColor: t.background }, + container: { flex: 1, backgroundColor: t.surface }, content: { padding: 18, paddingBottom: 60 }, label: { fontSize: 12, fontWeight: "700", color: t.textTertiary, textTransform: "uppercase", letterSpacing: 0.5, marginTop: 18, marginBottom: 8 }, - titleInput: { ...typeScale.title, color: t.textPrimary, backgroundColor: t.surface, borderWidth: 1, borderColor: t.border, borderRadius: 10, padding: 12 }, + titleInput: { ...typeScale.title, color: t.textPrimary, backgroundColor: t.surface2, borderWidth: 1, borderColor: t.border, borderRadius: 10, padding: 12 }, row: { flexDirection: "row", gap: 8, flexWrap: "wrap" }, chip: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16, borderWidth: 1 }, chipText: { ...typeScale.label, textTransform: "capitalize" }, - descInput: { ...typeScale.body, color: t.textPrimary, backgroundColor: t.surface, borderWidth: 1, borderColor: t.border, borderRadius: 10, padding: 12, minHeight: 160, fontFamily: "Menlo" }, + descInput: { ...typeScale.body, color: t.textPrimary, backgroundColor: t.surface2, borderWidth: 1, borderColor: t.border, borderRadius: 10, padding: 12, minHeight: 160, fontFamily: "Menlo" }, }); } From 5d76d2ad51e34d96f1ef2de4be4a7fcc1f93a29f Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Thu, 9 Jul 2026 16:31:25 -0400 Subject: [PATCH 18/24] feat(mobile): context ring opens a native menu instead of an Alert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping the chat context ring now shows a native @expo/ui Menu (glass on supported iOS) with the usage breakdown — percent used, prompt/limit tokens, and an "estimated" note for providers without exact counts — instead of a blocking Alert. Rows are informational, rendered as no-op menu buttons with SF Symbols. - Adds @expo/ui as a direct dependency (~57.0.3; it was already present transitively via expo-router and autolinked as pod ExpoUI, so no prebuild). - iOS uses Host + Menu with the ring as a custom RNHostView label (the documented pattern for embedding an RN view as a menu trigger). - Non-iOS keeps the existing Alert fallback. type-check + lint clean; iOS JS bundle exports clean. --- mobile/package-lock.json | 1 + mobile/package.json | 1 + mobile/src/components/ContextRing.tsx | 106 +++++++++++++++++++------- 3 files changed, 82 insertions(+), 26 deletions(-) diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 350e7bf47..50a921291 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "dependencies": { "@expo/material-symbols": "^0.1.1", + "@expo/ui": "~57.0.3", "@react-native-community/datetimepicker": "9.1.0", "ai": "^7.0.15", "expo": "~57.0.2", diff --git a/mobile/package.json b/mobile/package.json index 2a27f8f29..57feed769 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@expo/material-symbols": "^0.1.1", + "@expo/ui": "~57.0.3", "@react-native-community/datetimepicker": "9.1.0", "ai": "^7.0.15", "expo": "~57.0.2", diff --git a/mobile/src/components/ContextRing.tsx b/mobile/src/components/ContextRing.tsx index ade122c3c..ea1985b78 100644 --- a/mobile/src/components/ContextRing.tsx +++ b/mobile/src/components/ContextRing.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo } from "react"; -import { Alert, Pressable, StyleSheet } from "react-native"; +import { Alert, Platform, Pressable, StyleSheet, View } from "react-native"; import Svg, { Circle } from "react-native-svg"; +import { Host, Menu, Button, RNHostView } from "@expo/ui/swift-ui"; import { haptics } from "@/haptics"; import { useTheme } from "@/theme"; @@ -9,9 +10,10 @@ import { useTheme } from "@/theme"; * (src/components/agent/ContextRing.tsx). A compact SVG donut whose arc fills * with the fraction of the model's context window used by the conversation. * - * Ring-only (no inline label); tapping it opens a native popup with the exact - * percentage + token counts. Threshold colours match desktop: accent <=65%, - * warning 65–85%, danger >85%. + * Ring-only (no inline label); tapping it opens a native menu with the exact + * percentage + token counts (a glass `@expo/ui` Menu on iOS, an Alert fallback + * elsewhere). Threshold colours match desktop: accent <=65%, warning 65–85%, + * danger >85%. */ export function ContextRing({ promptTokens, @@ -43,8 +45,80 @@ export function ContextRing({ }, [promptTokens, contextLimit, size, stroke, t]); const pctLabel = `${estimated ? "~" : ""}${Math.round(pct * 100)}%`; + const tokensLabel = `${estimated ? "~" : ""}${promptTokens.toLocaleString()} / ${contextLimit.toLocaleString()} tokens`; const half = size / 2; + const ring = ( + + {/* -90° rotation so the arc starts at 12 o'clock, like desktop. */} + + + + ); + + const a11yLabel = `Context ${pctLabel} used${estimated ? " (estimated)" : ""}. Tap for details.`; + + // iOS: a native (glass) Menu anchored to the ring. The rows are informational + // — the model's context usage — so they're shown as no-op buttons. + if (Platform.OS === "ios") { + return ( + + + haptics.selection()} + style={styles.wrap} + > + {ring} + + + } + > + + + ); + } + + // Non-iOS fallback: keep the Alert popup. + return ; +} + +function noop() {} + +function RingButton({ + ring, + a11yLabel, + pctLabel, + promptTokens, + contextLimit, + estimated, +}: { + ring: React.ReactNode; + a11yLabel: string; + pctLabel: string; + promptTokens: number; + contextLimit: number; + estimated: boolean; +}) { const showDetail = useCallback(() => { haptics.selection(); const approx = estimated ? "about " : ""; @@ -57,28 +131,8 @@ export function ContextRing({ }, [pctLabel, promptTokens, contextLimit, estimated]); return ( - - - {/* -90° rotation so the arc starts at 12 o'clock, like desktop. */} - - - + + {ring} ); } From 5540c09e2951a11002596a76e6fd93931fda1060 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Thu, 9 Jul 2026 16:42:28 -0400 Subject: [PATCH 19/24] feat(mobile): reusable GlassMenu; use it for context ring + image picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the native glass tap-menu (Host + Menu + RNHostView trigger) into a reusable component so other call sites can get the same morph-into-glass menu. - ContextRing now uses GlassMenu, and moves the long token detail into a Section title so it renders at the smaller, muted caption size (the "~225 / 200,000 tokens" row was too large as a full menu row). - Chat image attach button ("+") now opens a GlassMenu with Photo Library / Take Photo instead of an Alert action sheet. - GlassMenu gains a `disabled` prop (used by the attach button while a turn is streaming) — renders an inert trigger, no menu. Non-iOS falls back to onFallbackPress (the existing Alert), so behaviour is unchanged off iOS. type-check + lint clean; iOS JS bundle exports clean. --- mobile/app/(tabs)/chat/index.tsx | 15 +++- mobile/src/components/ContextRing.tsx | 88 +++++++----------------- mobile/src/components/GlassMenu.tsx | 99 +++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 66 deletions(-) create mode 100644 mobile/src/components/GlassMenu.tsx diff --git a/mobile/app/(tabs)/chat/index.tsx b/mobile/app/(tabs)/chat/index.tsx index 09af7b32c..b72d9f332 100644 --- a/mobile/app/(tabs)/chat/index.tsx +++ b/mobile/app/(tabs)/chat/index.tsx @@ -30,6 +30,8 @@ import { resetAppleSession } from "@/chat/providers/apple"; import { prettifyToolLabel } from "@cairn/shared/ui/constants"; import type { UIMessage, ChatUsage } from "@/chat/providers/types"; import { ContextRing } from "@/components/ContextRing"; +import { GlassMenu } from "@/components/GlassMenu"; +import { Button } from "@expo/ui/swift-ui"; /** Composer height assumed before its first onLayout measurement. */ const COMPOSER_FALLBACK_H = 60; @@ -350,9 +352,16 @@ export default function ChatScreen() { - - - + } + accessibilityLabel="Add image" + disabled={busy} + onFallbackPress={onAttach} + triggerStyle={styles.attachBtn} + > +