Skip to content

feat(mobile): Apple Intelligence (PCC) + iOS 27 enablement + cross-provider usage ring - #78

Merged
ddutchie merged 30 commits into
mainfrom
ddutchie/ios27
Jul 10, 2026
Merged

ddutchie merged 30 commits into
mainfrom
ddutchie/ios27

Conversation

@ddutchie

@ddutchie ddutchie commented Jul 9, 2026 •

Copy link
Copy Markdown
Owner

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:

  1. iOS 27 build enablement — config plugins for the UIScene lifecycle (required to launch on iOS 27), the PCC entitlement (flag-gated), and pod deployment targets.
  2. PCC provider — a native AppleLlmModule that 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.
  3. Usage/quota UX — a tappable context-window ring in the chat header for every provider, a 3-state PCC quota bar in AI settings, and per-provider context-window hints.

Type of change

  • Bug fix
  • New feature
  • Refactor / code quality
  • Docs / changelog
  • Tests

Screenshots / recording

Checklist

  • npm run type-check:all passes — mobile npm run type-check clean (this PR is mobile-only; the root desktop type-check:all is unaffected)
  • npm run lint passes — mobile eslint . clean (0/0)
  • npm test passes — N/A: desktop/Electron bundle-guard suite; no electron/ code changed
  • npm run test:e2e passes — N/A: desktop e2e; no desktop UI changed. Verified manually on a physical iPhone 17 Pro Max (iOS 27)
  • No hardcoded colours — mobile uses the shared theme tokens; no raw colours added
  • No text-[Npx] pixel font classes — mobile uses the shared type scale; no single-use sizes
  • New IPC handlers wrapped in handle() and return IpcResult<T> — N/A: no IPC
  • New DB migrations appended (not edited) in schema.ts — N/A: no schema change
  • New SQL goes in electron/db/queries.ts — N/A: no DB access changed
  • New dependencies/devDependencies added to ROLE_MAP + licenses.json regenerated — N/A: the new dep (js-tiktoken) is in mobile/package.json, not the desktop license set
  • New MCP tools registered — N/A: no MCP tools
  • --external:<pkg> allowlist updated — N/A: no compile script change

Notes 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-time CAIRN_PCC_SDK flag that AppleLlm.podspec only defines when xcrun --sdk iphoneos --show-sdk-version reports 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-tiktoken bundles fine).

Decisions worth a look:

  • Scene lifecycle plugin is always-on (not SDK-gated) — it's a valid iOS 26 migration and iOS 27 refuses to launch without it. It throws loudly if the AppDelegate template drifts.
  • PCC entitlement is injected only when EXPO_PUBLIC_PCC=1 — a managed entitlement breaks signing when the profile isn't provisioned for it.
  • On-device Apple model is DEV-only (EXPO_PUBLIC_APPLE_LLM_DEV); PCC is the user-facing Apple provider.
  • Token counts: Apple = real tokenCount; OpenAI = real usage.prompt_tokens via stream_options.include_usage + models.dev context limit; Rork = server usage if present, else a client-side js-tiktoken (o200k_base) estimate marked ~/estimated with a conservative 200K cap (underlying model is uncertain). The ring intentionally shows ~ rather than faking precision.
  • PCC quota exposes no numbers (only below/approaching/reached + resetDate), so it's a 3-state bar, not a gauge.

Known follow-ups (tracked, not in this PR):

  • Search tab renders as a plain tab on iOS 27 — react-native-screens uses the legacy UITabBarSystemItemSearch rather than the modern UISearchTab (upstream #3999 closed not-planned).
  • Chat inline QUOTA_EXCEEDED message (Apple's inline pattern) and an image-analysis Vision tool.

Summary by CodeRabbit

  • New Features
    • Added Apple Intelligence support via Private Cloud Compute on iOS 27+ (with a separate dev-only on-device mode option).
    • Chat now streams “reasoning” and displays it in a collapsible block; completion reports context usage.
    • Added a context usage ring in chat and a PCC daily quota indicator (with upgrade callout) in settings, plus an optional OpenAI context-window override.
  • Bug Fixes
    • Improved tab ordering and keyboard-aware layout spacing; refined search empty-state alignment/scrolling.
    • More consistent usage reporting across providers.
  • Documentation
    • Updated the Apple Foundation Models plan and PCC/dev environment-variable guidance.

ddutchie added 11 commits July 8, 2026 16:40
… 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.
@coderabbitai

coderabbitai Bot commented Jul 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The 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.

Changes

Apple Private Cloud Compute Provider

Layer / File(s) Summary
Docs and env vars
docs/plans/apple-foundation-models-provider.md, mobile/.env.example
Updates the PCC plan/status notes and documents EXPO_PUBLIC_PCC alongside the Apple LLM dev flag.
Types and JS bridge
mobile/modules/apple-llm/AppleLlm.types.ts, mobile/modules/apple-llm/index.ts
Adds PCC routing options, quota status, new error codes, and JS helpers for server availability and quota actions.
Native module and podspec gating
mobile/modules/apple-llm/ios/AppleLlm.podspec, mobile/modules/apple-llm/ios/AppleLlmModule.swift
Adds compile-time PCC gating, server-mode session/stream handling, quota/network error mapping, and usage telemetry from native generation.
Provider selection and settings UI
mobile/src/chat/providers/apple.ts, mobile/src/chat/providers/index.ts, mobile/src/components/AiSettingsForm.tsx
Splits Apple provider handling between PCC and dev on-device modes and surfaces quota/context controls in settings.
Expo plugins and build wiring
mobile/plugins/withPrivateCloudCompute.js, mobile/plugins/withSceneLifecycle.js, mobile/plugins/withPodDeploymentTarget.js, mobile/app.json, mobile/package.json
Adds entitlement, scene lifecycle, and Podfile deployment-target plugins, then wires them into app config with required dependencies.

Context-Window Usage Tracking

Layer / File(s) Summary
Usage types and agent events
mobile/src/chat/providers/types.ts, mobile/src/chat/agent.ts
Adds usage to finish events and propagates it through agent final events and error handling.
Provider usage reporting
mobile/src/chat/providers/openai.ts, mobile/src/chat/providers/rork.ts
Captures or estimates prompt tokens and emits usage on stream completion.
Context-limit lookup and token counting
mobile/src/chat/ai-config.ts, mobile/src/chat/models-dev.ts, mobile/src/chat/tokens.ts
Stores manual context limits, resolves model limits from models.dev, and adds local token counting.
Usage display UI
mobile/src/components/ContextRing.tsx, mobile/app/(tabs)/chat/index.tsx
Adds the usage ring and shows usage in the chat header while clearing it with the conversation.
Settings form context override
mobile/src/components/AiSettingsForm.tsx
Adds the OpenAI context-window input, detected-context hint, and PCC quota display.

Tab, Chat, and Search Layout Adjustments

Layer / File(s) Summary
Shared layout constants
mobile/src/theme.ts
Adds shared tab-bar and keyboard spacing helpers plus an iOS tab-bar search-field flag.
Tab ordering
mobile/app/(tabs)/_layout.tsx
Moves the chat tab ahead of search.
Search empty-state and keyboard spacing
mobile/app/(tabs)/search/index.tsx, mobile/src/components/EmptyState.tsx
Updates search empty-state anchoring, scroll behavior, and keyboard offsets using the new layout helpers.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related PRs

  • ddutchie/cairn#76: Both PRs touch the mobile chat agent/event streaming layer; #76 initially adds runAgent/AgentEvent in mobile/src/chat/agent.ts, and the main PR further extends that same module to emit streamed reasoning and include usage on "final" events.
  • ddutchie/cairn#77: The retrieved PR’s dev-gated on-device Apple Intelligence provider and apple-llm native module are the same codebase that the main PR later refactors/extends into a PCC/server-mode implementation (splitting provider logic, adding PCC quota/reasoning/usage to the same Apple provider + module).
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main mobile changes: Apple Intelligence/PCC support, iOS 27 enablement, and the cross-provider usage ring.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ddutchie/ios27

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
mobile/src/chat/models-dev.ts (1)

95-109: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider allowing retry after fetch failure instead of caching an empty map.

On fetch failure, memoryCache = {} is set (line 105). Since {} is truthy, all subsequent ensureMap() calls return the empty object via the if (memoryCache) return memoryCache guard, never retrying for the rest of the app session. A transient network failure on first launch means every model resolves to DEFAULT_CONTEXT_LIMIT until the app is restarted.

Removing the memoryCache = {} assignment (or replacing it with memoryCache = null) would let subsequent calls fall through to a new fetch attempt, while still being protected by inflight deduplication.

♻️ Proposed fix to allow retry after failure
     } catch {
-      memoryCache = {};
       return {};
     } finally {

This way, memoryCache stays null on failure, and the next ensureMap() call will attempt loadCache() (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 value

Redundant PrivateCloudComputeLanguageModel() instantiation in quotaStatusJson.

Lines 281 and 282 each create a separate PrivateCloudComputeLanguageModel() instance — the first for the availability check, the second for quotaUsage. 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.quotaUsage

Actually, the availability check on the if line 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

mapError network check doesn't distinguish PCC from on-device paths.

The NSURLErrorDomain check 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 passing useServer to mapError or 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

📥 Commits

Reviewing files that changed from the base of the PR and between 7f97369 and d1a507f.

⛔ Files ignored due to path filters (1)
  • mobile/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (27)
  • docs/plans/apple-foundation-models-provider.md
  • mobile/.env.example
  • mobile/app.json
  • mobile/app/(tabs)/_layout.tsx
  • mobile/app/(tabs)/chat/index.tsx
  • mobile/app/(tabs)/search/index.tsx
  • mobile/modules/apple-llm/AppleLlm.types.ts
  • mobile/modules/apple-llm/index.ts
  • mobile/modules/apple-llm/ios/AppleLlm.podspec
  • mobile/modules/apple-llm/ios/AppleLlmModule.swift
  • mobile/package.json
  • mobile/plugins/withPodDeploymentTarget.js
  • mobile/plugins/withPrivateCloudCompute.js
  • mobile/plugins/withSceneLifecycle.js
  • mobile/src/chat/agent.ts
  • mobile/src/chat/ai-config.ts
  • mobile/src/chat/models-dev.ts
  • mobile/src/chat/providers/apple.ts
  • mobile/src/chat/providers/index.ts
  • mobile/src/chat/providers/openai.ts
  • mobile/src/chat/providers/rork.ts
  • mobile/src/chat/providers/types.ts
  • mobile/src/chat/tokens.ts
  • mobile/src/components/AiSettingsForm.tsx
  • mobile/src/components/ContextRing.tsx
  • mobile/src/components/EmptyState.tsx
  • mobile/src/theme.ts

Comment thread mobile/app/(tabs)/chat/index.tsx Outdated
Comment thread mobile/src/chat/providers/apple.ts Outdated
Comment thread mobile/src/chat/providers/openai.ts
Comment thread mobile/src/components/AiSettingsForm.tsx
ddutchie added 3 commits July 9, 2026 07:08
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 681505a and 02ccf53.

📒 Files selected for processing (7)
  • mobile/app/(tabs)/chat/index.tsx
  • mobile/modules/apple-llm/AppleLlm.types.ts
  • mobile/modules/apple-llm/ios/AppleLlmModule.swift
  • mobile/src/chat/agent.ts
  • mobile/src/chat/ai-config.ts
  • mobile/src/chat/providers/apple.ts
  • mobile/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

Comment thread mobile/app/(tabs)/chat/index.tsx
ddutchie added 8 commits July 9, 2026 08:57
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Pass an Instructions value here

SystemLanguageModel.tokenCount(for:) takes Instructions, not String, so tokenCount(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

📥 Commits

Reviewing files that changed from the base of the PR and between 3f3fe35 and f951520.

📒 Files selected for processing (6)
  • mobile/app/card/new.tsx
  • mobile/app/sync.tsx
  • mobile/modules/apple-llm/AppleLlm.types.ts
  • mobile/modules/apple-llm/ios/AppleLlmModule.swift
  • mobile/src/chat/providers/apple.ts
  • mobile/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

ddutchie added 4 commits July 9, 2026 16:31
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.
ddutchie added 4 commits July 9, 2026 16:57
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant