Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5870ee4
build(mobile): iOS 27 SDK enablement — scene lifecycle, entitlement +…
ddutchie Jul 8, 2026
fb818f6
feat(mobile): Apple Intelligence via Private Cloud Compute (iOS 27)
ddutchie Jul 8, 2026
8b2274b
feat(mobile): tab bar UX for iOS 27 — search placement, keyboard offs…
ddutchie Jul 8, 2026
72b7688
fix(mobile): match search empty state position to chat on iOS 27
ddutchie Jul 8, 2026
fea3ba9
Merge branch 'main' into ddutchie/ios27
ddutchie Jul 8, 2026
3223173
Merge branch 'main' into ddutchie/ios27
ddutchie Jul 8, 2026
1f1efc7
feat(mobile): context-window usage ring in chat (Apple provider)
ddutchie Jul 9, 2026
444320b
feat(mobile): 3-state PCC usage bar in AI settings
ddutchie Jul 9, 2026
32074b6
feat(mobile): context ring for all providers + header-left placement
ddutchie Jul 9, 2026
d432e9f
feat(mobile): show context-window size in Apple + Rork provider notes
ddutchie Jul 9, 2026
d1a507f
Merge branch 'main' into ddutchie/ios27
ddutchie Jul 9, 2026
681505a
PR Review
ddutchie Jul 9, 2026
5b3e92e
feat(mobile): user-selectable PCC reasoning effort
ddutchie Jul 9, 2026
02ccf53
feat(mobile): stream + render PCC reasoning
ddutchie Jul 9, 2026
c3c720a
Merge branch 'main' into ddutchie/ios27
ddutchie Jul 9, 2026
6ae4732
accessibilityState fix
ddutchie Jul 9, 2026
3f3fe35
feat(mobile): stream reasoning from OpenAI-compatible endpoints
ddutchie Jul 9, 2026
fabd9d5
Merge branch 'main' into ddutchie/ios27
ddutchie Jul 9, 2026
e9e3265
fix(mobile): drop Apple PCC reasoning extraction (redacted, not usable)
ddutchie Jul 9, 2026
a6206af
feat(mobile): present Sync + AI settings as native formSheet
ddutchie Jul 9, 2026
b60116e
fix(mobile): revert Sync/AI settings to modal; unify modal backgrounds
ddutchie Jul 9, 2026
f951520
style(mobile): unify New Task modal surfaces with Sync/AI settings
ddutchie Jul 9, 2026
5d76d2a
feat(mobile): context ring opens a native menu instead of an Alert
ddutchie Jul 9, 2026
5540c09
feat(mobile): reusable GlassMenu; use it for context ring + image picker
ddutchie Jul 9, 2026
36b3e75
style(mobile): drop chat composer border when Liquid Glass is active
ddutchie Jul 9, 2026
6723e13
feat(mobile): long-press context menu on note rows
ddutchie Jul 9, 2026
a556704
Revert "feat(mobile): long-press context menu on note rows"
ddutchie Jul 9, 2026
f8b500e
fix(mobile): don't render redacted reasoning as a thinking block
ddutchie Jul 9, 2026
dcdc6a7
feat(sync): workspace-scoped oplogs + multi-source mobile
ddutchie Jul 10, 2026
e902752
Merge branch 'main' into ddutchie/ios27
ddutchie Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 56 additions & 4 deletions docs/plans/apple-foundation-models-provider.md
Original file line number Diff line number Diff line change
@@ -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.

---

Expand Down
34 changes: 24 additions & 10 deletions mobile/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
11 changes: 11 additions & 0 deletions mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
],
"./plugins/withAppleSigning",
"./plugins/withICloudContainer",
"./plugins/withPrivateCloudCompute",
"./plugins/withSceneLifecycle",
"./plugins/withPodDeploymentTarget",
"./plugins/withStatusBarStyle",
"@react-native-community/datetimepicker",
"expo-secure-store",
Expand All @@ -52,6 +55,14 @@
"backgroundColor": "#0d0d0d"
}
}
],
[
"expo-build-properties",
{
"ios": {
"deploymentTarget": "16.4"
}
}
]
],
"experiments": {
Expand Down
12 changes: 7 additions & 5 deletions mobile/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,17 @@ export default function TabsLayout() {
<NativeTabs.Trigger.Label>Graph</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>

<NativeTabs.Trigger name="search" role="search">
<NativeTabs.Trigger.Icon sf="magnifyingglass" md="search" />
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>

<NativeTabs.Trigger name="chat">
<NativeTabs.Trigger.Icon sf="bubble.left.and.bubble.right.fill" md="chat" />
<NativeTabs.Trigger.Label>Chat</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>

{/* Search last so it sits on the far right — matches the iOS search-tab
convention and keeps it out of the primary nav flow. */}
<NativeTabs.Trigger name="search" role="search">
<NativeTabs.Trigger.Icon sf="magnifyingglass" md="search" />
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
</ThemeProvider>
);
Expand Down
52 changes: 34 additions & 18 deletions mobile/app/(tabs)/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,17 @@ 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";
import { loadChatHistory, saveChatMessage, clearChatHistory } from "@/db/chat-store";
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";

/** 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;

Expand Down Expand Up @@ -80,6 +79,8 @@ export default function ChatScreen() {
const [attachments, setAttachments] = useState<Attachment[]>([]);
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<ChatUsage | null>(null);
const scrollRef = useRef<ScrollView>(null);
const router = useRouter();
// Persistent agent conversation (UIMessage parts format) across turns. Seeded
Expand Down Expand Up @@ -115,24 +116,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 () => {
Expand Down Expand Up @@ -180,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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 20);
};
Expand Down Expand Up @@ -243,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();
},
Expand All @@ -252,7 +251,23 @@ export default function ChatScreen() {

return (
<TabScreen>
<Stack.Screen options={{ title: "Chat" }} />
<Stack.Screen
options={{
title: "Chat",
// Context-window usage ring in the header-left when the active provider
// reports usage (Apple real, OpenAI real, Rork estimated).
headerLeft:
usage && messages.length > 0
? () => (
<ContextRing
promptTokens={usage.promptTokens}
contextLimit={usage.contextLimit}
estimated={usage.estimated}
/>
)
: undefined,
}}
/>
<Stack.Toolbar placement="right">
<Stack.Toolbar.Button
icon={ICON_DELETE}
Expand Down Expand Up @@ -302,9 +317,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. */}
<KeyboardStickyView
offset={{ closed: -closedLift, opened: 0 }}
offset={{ closed: -closedLift, opened: -KEYBOARD_OPEN_GAP }}
style={styles.composerOverlay}
>
<View onLayout={(e) => setComposerH(e.nativeEvent.layout.height)}>
Expand Down
Loading