Repository navigation
feat(mobile): Apple Intelligence (PCC) + iOS 27 enablement + cross-provider usage ring - #78
Conversation
… pod targets
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).
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.
…ets, empty state 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.
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.
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 <ContextRing> 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.
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.
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).
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.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe PR adds Apple Foundation Models PCC server-mode support, propagates chat usage and reasoning through providers and UI, and updates tab/search/chat layout spacing. It also adds PCC-related docs, env config, and build plugins. ChangesApple Private Cloud Compute Provider
Context-Window Usage Tracking
Tab, Chat, and Search Layout Adjustments
Estimated code review effort: 4 (Complex) | ~75 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (3)
mobile/src/chat/models-dev.ts (1)
95-109: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winConsider allowing retry after fetch failure instead of caching an empty map.
On fetch failure,
memoryCache = {}is set (line 105). Since{}is truthy, all subsequentensureMap()calls return the empty object via theif (memoryCache) return memoryCacheguard, never retrying for the rest of the app session. A transient network failure on first launch means every model resolves toDEFAULT_CONTEXT_LIMITuntil the app is restarted.Removing the
memoryCache = {}assignment (or replacing it withmemoryCache = null) would let subsequent calls fall through to a new fetch attempt, while still being protected byinflightdeduplication.♻️ Proposed fix to allow retry after failure
} catch { - memoryCache = {}; return {}; } finally {This way,
memoryCachestaysnullon failure, and the nextensureMap()call will attemptloadCache()(still null since nothing was persisted) and then start a fresh fetch.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@mobile/src/chat/models-dev.ts` around lines 95 - 109, The fetch failure path in ensureMap/loadCache is incorrectly caching an empty object in memoryCache, which prevents future retries for the rest of the session. Update the catch block in the inflight fetch flow to leave memoryCache unset or reset it to null instead of {}, so the existing guard can fall through and retry on the next ensureMap() call while still relying on inflight for deduplication.mobile/modules/apple-llm/ios/AppleLlmModule.swift (2)
279-313: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueRedundant
PrivateCloudComputeLanguageModel()instantiation inquotaStatusJson.Lines 281 and 282 each create a separate
PrivateCloudComputeLanguageModel()instance — the first for the availability check, the second forquotaUsage. Reuse a single instance to avoid double allocation.♻️ Suggested refactor
`#if` CAIRN_PCC_SDK if `#available`(iOS 27.0, *), case .available = PrivateCloudComputeLanguageModel().availability { - let model = PrivateCloudComputeLanguageModel() + let model = PrivateCloudComputeLanguageModel() + // Reuse the same instance instead of creating a second one above. let usage = model.quotaUsageActually, the availability check on the
ifline also creates an instance. A cleaner approach:- if `#available`(iOS 27.0, *), case .available = PrivateCloudComputeLanguageModel().availability { - let model = PrivateCloudComputeLanguageModel() + if `#available`(iOS 27.0, *) { + let model = PrivateCloudComputeLanguageModel() + guard case .available = model.availability else { return /* fallback */ } let usage = model.quotaUsage🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@mobile/modules/apple-llm/ios/AppleLlmModule.swift` around lines 279 - 313, In `quotaStatusJson`, `PrivateCloudComputeLanguageModel()` is being created twice in the same availability check path. Refactor the `if `#available`` logic to instantiate a single `PrivateCloudComputeLanguageModel` and reuse it for both the availability check and `quotaUsage` access, keeping the existing JSON-building behavior unchanged.
577-582: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value
mapErrornetwork check doesn't distinguish PCC from on-device paths.The
NSURLErrorDomaincheck on line 580 unconditionally returns a PCC-specific message ("Private Cloud Compute needs a connection"). If the on-device path ever throws a network error (unlikely but possible via tool execution), the message would be misleading. Consider passinguseServertomapErroror checking the error domain only when PCC is active.♻️ Suggested refactor
- private static func mapError(_ error: Error) -> (AppleLlmCode, String) { + private static func mapError(_ error: Error, useServer: Bool = false) -> (AppleLlmCode, String) { ... - if ns.domain == NSURLErrorDomain { + if useServer && ns.domain == NSURLErrorDomain { return (.networkUnavailable, "Private Cloud Compute needs a connection. Reconnect and try again.") }Then update the call site on line 423:
- let (code, message) = Self.mapError(error) + let (code, message) = Self.mapError(error, useServer: useServer)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@mobile/modules/apple-llm/ios/AppleLlmModule.swift` around lines 577 - 582, The network error mapping in mapError is too broad and always returns a PCC-specific message for NSURLErrorDomain, even when the on-device path is active. Update AppleLlmModule.mapError to take the current mode (for example useServer) or otherwise gate the NSURLErrorDomain check so it only maps to the Private Cloud Compute retry message when PCC is actually in use, and adjust the call site in the request flow to pass that context.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@mobile/app/`(tabs)/chat/index.tsx:
- Around line 179-180: Drop invalid Apple usage before updating the chat ring:
in the final event handling inside the chat screen component, do not call
setUsage with Apple usage payloads when promptTokens is negative or contextLimit
is not positive. Add a guard or sanitize the usage object before it reaches
ContextRing so the ring only renders with valid token counts.
In `@mobile/src/chat/providers/apple.ts`:
- Line 387: `runToolToJson` is handling async tool implementations
synchronously, so Promise results can be stringified as empty objects and lost.
Update `runToolToJson` to be async and await `tool.run(args)` before JSON
serialization, then update the `makeStreamApple` call site to await the returned
`resultJson`/`error` from `runToolToJson` so `resolveToolCall` receives the
actual tool output.
In `@mobile/src/chat/providers/openai.ts`:
- Around line 123-125: The compatibility path in openai.ts is always attaching
stream_options via the chat request payload, which can break OpenAI-compatible
gateways that reject unknown fields. Update the request-building logic around
the streaming/chat send path to only include stream_options when the backend
supports it, or retry the request without it after a 400; use the existing
OpenAI provider flow and the chat request construction in openai.ts to gate this
field for non-OpenAI endpoints.
In `@mobile/src/components/AiSettingsForm.tsx`:
- Around line 71-76: The `appleReason` path in `AiSettingsForm` now returns
PCC/server-specific availability text, so the existing unavailable label in the
render template should no longer say “On-device AI.” Update the message
construction used for the Apple AI unavailable state to use a backend-agnostic
label that fits both `appleServerUnavailableReason()` and
`appleLlmUnavailableReason()`, and keep the `appleReason` logic in sync with the
text shown to users.
---
Nitpick comments:
In `@mobile/modules/apple-llm/ios/AppleLlmModule.swift`:
- Around line 279-313: In `quotaStatusJson`,
`PrivateCloudComputeLanguageModel()` is being created twice in the same
availability check path. Refactor the `if `#available`` logic to instantiate a
single `PrivateCloudComputeLanguageModel` and reuse it for both the availability
check and `quotaUsage` access, keeping the existing JSON-building behavior
unchanged.
- Around line 577-582: The network error mapping in mapError is too broad and
always returns a PCC-specific message for NSURLErrorDomain, even when the
on-device path is active. Update AppleLlmModule.mapError to take the current
mode (for example useServer) or otherwise gate the NSURLErrorDomain check so it
only maps to the Private Cloud Compute retry message when PCC is actually in
use, and adjust the call site in the request flow to pass that context.
In `@mobile/src/chat/models-dev.ts`:
- Around line 95-109: The fetch failure path in ensureMap/loadCache is
incorrectly caching an empty object in memoryCache, which prevents future
retries for the rest of the session. Update the catch block in the inflight
fetch flow to leave memoryCache unset or reset it to null instead of {}, so the
existing guard can fall through and retry on the next ensureMap() call while
still relying on inflight for deduplication.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: ea0caaf5-6bc8-421b-86e3-be6e07b26f84
⛔ Files ignored due to path filters (1)
mobile/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (27)
docs/plans/apple-foundation-models-provider.mdmobile/.env.examplemobile/app.jsonmobile/app/(tabs)/_layout.tsxmobile/app/(tabs)/chat/index.tsxmobile/app/(tabs)/search/index.tsxmobile/modules/apple-llm/AppleLlm.types.tsmobile/modules/apple-llm/index.tsmobile/modules/apple-llm/ios/AppleLlm.podspecmobile/modules/apple-llm/ios/AppleLlmModule.swiftmobile/package.jsonmobile/plugins/withPodDeploymentTarget.jsmobile/plugins/withPrivateCloudCompute.jsmobile/plugins/withSceneLifecycle.jsmobile/src/chat/agent.tsmobile/src/chat/ai-config.tsmobile/src/chat/models-dev.tsmobile/src/chat/providers/apple.tsmobile/src/chat/providers/index.tsmobile/src/chat/providers/openai.tsmobile/src/chat/providers/rork.tsmobile/src/chat/providers/types.tsmobile/src/chat/tokens.tsmobile/src/components/AiSettingsForm.tsxmobile/src/components/ContextRing.tsxmobile/src/components/EmptyState.tsxmobile/src/theme.ts
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.
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.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@mobile/app/`(tabs)/chat/index.tsx:
- Around line 403-408: The ReasoningBlock disclosure toggle in the Pressable is
missing its announced expanded/collapsed state for assistive tech. Update the
Pressable in ReasoningBlock to include accessibilityState with the current
expanded value alongside the existing accessibilityRole and accessibilityLabel,
so screen readers can report the toggle state correctly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 74b7653d-c151-4be8-8a2d-91d455f60808
📒 Files selected for processing (7)
mobile/app/(tabs)/chat/index.tsxmobile/modules/apple-llm/AppleLlm.types.tsmobile/modules/apple-llm/ios/AppleLlmModule.swiftmobile/src/chat/agent.tsmobile/src/chat/ai-config.tsmobile/src/chat/providers/apple.tsmobile/src/components/AiSettingsForm.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
- mobile/src/components/AiSettingsForm.tsx
- mobile/modules/apple-llm/ios/AppleLlmModule.swift
- mobile/src/chat/providers/apple.ts
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.
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.
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.
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.
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.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
mobile/modules/apple-llm/ios/AppleLlmModule.swift (1)
123-130: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winPass an
Instructionsvalue here
SystemLanguageModel.tokenCount(for:)takesInstructions, notString, sotokenCount(for: text)won’t match the FoundationModels API. Wrap the input in the expected type before calling it.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@mobile/modules/apple-llm/ios/AppleLlmModule.swift` around lines 123 - 130, The countTokens AsyncFunction in AppleLlmModule should pass the input as an Instructions value instead of a raw String, since SystemLanguageModel.tokenCount(for:) expects Instructions. Update the call inside countTokens to construct the expected Instructions object from the text before invoking tokenCount, keeping the existing iOS availability and fallback behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@mobile/modules/apple-llm/ios/AppleLlmModule.swift`:
- Around line 123-130: The countTokens AsyncFunction in AppleLlmModule should
pass the input as an Instructions value instead of a raw String, since
SystemLanguageModel.tokenCount(for:) expects Instructions. Update the call
inside countTokens to construct the expected Instructions object from the text
before invoking tokenCount, keeping the existing iOS availability and fallback
behavior unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 3788184e-e58e-432f-9f3b-cd3827bb43cc
📒 Files selected for processing (6)
mobile/app/card/new.tsxmobile/app/sync.tsxmobile/modules/apple-llm/AppleLlm.types.tsmobile/modules/apple-llm/ios/AppleLlmModule.swiftmobile/src/chat/providers/apple.tsmobile/src/components/EmbeddingsCard.tsx
💤 Files with no reviewable changes (2)
- mobile/modules/apple-llm/AppleLlm.types.ts
- mobile/src/chat/providers/apple.ts
✅ Files skipped from review due to trivial changes (3)
- mobile/src/components/EmbeddingsCard.tsx
- mobile/app/card/new.tsx
- mobile/app/sync.tsx
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.
Extract the native glass tap-menu (Host + Menu + RNHostView trigger) into a
reusable <GlassMenu trigger=... onFallbackPress=...> 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.
The chat input bar (composer) drew a 1px border unconditionally, sitting on top of the GlassView edge when Liquid Glass was active. Gate the border/fill on glassActive so the GlassView is the sole visual container on iOS 26+, keeping the border only as the fallback-surface edge — matching how the search scope bar and note TOC FAB already handle it. type-check + lint clean.
Add a reusable ContextMenuWrapper (Host + @expo/ui ContextMenu, iOS-only with a plain passthrough elsewhere) and use it on the project notes list rows. Long- pressing a note now shows a native contextual menu — Open, Pin/Unpin, Rename, Delete — while a normal tap still opens the note (SwiftUI's contextMenu doesn't consume single taps). Rename uses Alert.prompt, Delete confirms via Alert; both call existing write queries (pinNote/updateNote/softDeleteNote) whose notifyLocalWrite() triggers the screen's useDataChanged(load) to refresh — no manual reload wiring. Rows are only materialized for visible (virtualized) list items, so the per-row SwiftUI host stays bounded. type-check + lint clean; iOS JS bundle exports clean.
This reverts commit 6723e13.
Verified against the live Rork endpoint: on tool-calling turns the model (Gemini) emits a reasoning part whose content is redacted — a reasoning-delta whose delta is the literal "[REDACTED]". Our provider passed it straight through, so the chat showed a "[REDACTED]" thinking block. Filter reasoning-delta parts in both providers so only real reasoning text is surfaced: - rork.ts: skip reasoning-delta parts that are empty or exactly "[REDACTED]". - openai.ts: same guard on reasoning_content / reasoning deltas (and drop the now-stale "Apple/PCC" comment — that path was removed). Confirmed via raw SSE probe: empty-tools turns emit no reasoning part; the redaction only appears on tool-call turns. type-check + lint clean; iOS JS bundle exports clean.
Source the sync oplog by workspace so many devices and workspaces can share one folder while each reader sees only its own workspace. Shared/desktop: - Oplog files are now oplog-<deviceId>-<workspaceId>.ndjson; reads are scoped to a workspace via exact-suffix match (dash-safe, never a naive split). Legacy unsuffixed files stay readable. - desktop-sync publishes/reads under its single workspace id. Mobile (multi-source): - One DB per source (cairn-mobile-<workspaceId>.db) with its own engine; a meta DB holds the shared device id + active source. - getDb/getEngine resolve the active source; setActiveSource/switchSource re-point the whole app (screens re-hydrate via the accessors). - Transport gains listSources (scan/discover) + readSourceOplog + per- workspace writeback. - SourcePicker gates first launch; Sync screen adds a source switcher. - One-time delete of the pre-multi-source cairn-mobile.db on upgrade. Changelogs: desktop v2.4.6, mobile v0.1.2 (also covers iOS 27 Apple Intelligence / PCC chat).
Resolve changelog collision: main's v2.4.6 (sidebar delete-reactivity fix) keeps that slot; the workspace-scoped oplog / multi-source sync changelog moves to v2.4.7.
What does this PR do?
Brings Apple Intelligence via Private Cloud Compute (PCC) to the Cairn iOS app on iOS 27, plus the surrounding iOS 27 build enablement and a cross-provider context-window usage ring. Mobile-only change — no desktop (
electron/,src/) code is touched.The work breaks down into three layers:
AppleLlmModulethat runs on Private Cloud Compute (private, no API key, larger context, daily quota), with the on-device model kept as a DEV-only path. All iOS-27-only symbols are compile-time gated so EAS (iOS 26 SDK) keeps building.Type of change
Screenshots / recording
Checklist
npm run type-check:allpasses — mobilenpm run type-checkclean (this PR is mobile-only; the root desktoptype-check:allis unaffected)npm run lintpasses — mobileeslint .clean (0/0)npm testpasses — N/A: desktop/Electron bundle-guard suite; noelectron/code changednpm run test:e2epasses — N/A: desktop e2e; no desktop UI changed. Verified manually on a physical iPhone 17 Pro Max (iOS 27)themetokens; no raw colours addedtext-[Npx]pixel font classes — mobile uses the sharedtypescale; no single-use sizeshandle()and returnIpcResult<T>— N/A: no IPCschema.ts— N/A: no schema changeelectron/db/queries.ts— N/A: no DB access changeddependencies/devDependenciesadded toROLE_MAP+licenses.jsonregenerated — N/A: the new dep (js-tiktoken) is inmobile/package.json, not the desktop license set--external:<pkg>allowlist updated — N/A: nocompilescript changeNotes for reviewer
EAS-safety is the key design constraint. The EAS image (
macos-tahoe-26.4-xcode-26.4) ships the iOS 26 SDK and has no iOS 27 SDK, so every PCC/iOS-27 symbol is behind a compile-timeCAIRN_PCC_SDKflag thatAppleLlm.podspeconly defines whenxcrun --sdk iphoneos --show-sdk-versionreports major ≥ 27.#available/canImport(FoundationModels)are insufficient here (runtime-only / true on both SDKs). The AppleLlm pod builds under iOS 27 and parses clean under iOS 26 (flag off); the JS bundle exports clean under Metro/Hermes (js-tiktokenbundles fine).Decisions worth a look:
EXPO_PUBLIC_PCC=1— a managed entitlement breaks signing when the profile isn't provisioned for it.EXPO_PUBLIC_APPLE_LLM_DEV); PCC is the user-facing Apple provider.tokenCount; OpenAI = realusage.prompt_tokensviastream_options.include_usage+ models.dev context limit; Rork = server usage if present, else a client-sidejs-tiktoken(o200k_base) estimate marked~/estimated with a conservative 200K cap (underlying model is uncertain). The ring intentionally shows~rather than faking precision.Known follow-ups (tracked, not in this PR):
react-native-screensuses the legacyUITabBarSystemItemSearchrather than the modernUISearchTab(upstream #3999 closed not-planned).QUOTA_EXCEEDEDmessage (Apple's inline pattern) and an image-analysis Vision tool.Summary by CodeRabbit