diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1be048a3d9..bdd3e4cd4d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,12 +6,17 @@ on:
push:
branches: [main, rc]
+permissions:
+ contents: read
+
jobs:
lint-and-format:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
- - uses: actions/setup-node@v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
cache: 'npm'
@@ -23,10 +28,57 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
- - uses: actions/setup-node@v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run test
+
+ # Detect if mobile/shared code changed to conditionally run mobile-checks
+ changes:
+ runs-on: ubuntu-latest
+ outputs:
+ mobile: ${{ steps.filter.outputs.mobile }}
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+ - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3
+ id: filter
+ with:
+ filters: |
+ mobile:
+ - 'apps/mobile/**'
+ - 'src/shared/**'
+ - 'src/web/hooks/**'
+
+ # Mobile checks job - runs only when mobile or shared code changes
+ mobile-checks:
+ needs: changes
+ if: needs.changes.outputs.mobile == 'true'
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: apps/mobile
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: '22'
+ cache: 'npm'
+ cache-dependency-path: apps/mobile/package-lock.json
+ - run: npm ci
+ - name: TypeScript type check
+ run: npx tsc --noEmit
+ - name: ESLint
+ run: npx eslint .
+ - name: Jest tests
+ run: npm test
+ - name: Expo doctor
+ run: npx expo-doctor
diff --git a/.prettierignore b/.prettierignore
index aeff1b9af1..83cdf73528 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -11,3 +11,10 @@ AGENTS.md
# Auto-generated by release tooling — see CLAUDE.md "Do Not Edit: docs/releases.md"
docs/releases.md
+
+# Mobile (Expo) generated/native build artifacts
+apps/mobile/.expo/
+apps/mobile/ios/
+apps/mobile/android/
+apps/mobile/expo-env.d.ts
+apps/mobile/uniwind-types.d.ts
diff --git a/apps/mobile/.agents/skills/building-native-ui/SKILL.md b/apps/mobile/.agents/skills/building-native-ui/SKILL.md
new file mode 100644
index 0000000000..a1e22afd38
--- /dev/null
+++ b/apps/mobile/.agents/skills/building-native-ui/SKILL.md
@@ -0,0 +1,298 @@
+---
+name: building-native-ui
+description: Complete guide for building beautiful apps with Expo Router. Covers fundamentals, styling, components, navigation, animations, patterns, and native tabs.
+version: 1.0.1
+license: MIT
+---
+
+# Expo UI Guidelines
+
+## References
+
+Consult these resources as needed:
+
+```text
+references/
+ animations.md Reanimated: entering, exiting, layout, scroll-driven, gestures
+ controls.md Native iOS: Switch, Slider, SegmentedControl, DateTimePicker, Picker
+ form-sheet.md Form sheets in expo-router: configuration, footers and background interaction.
+ gradients.md CSS gradients via experimental_backgroundImage (New Arch only)
+ icons.md SF Symbols via expo-image (sf: source), names, animations, weights
+ media.md Camera, audio, video, and file saving
+ route-structure.md Route conventions, dynamic routes, groups, folder organization
+ search.md Search bar with headers, useSearch hook, filtering patterns
+ storage.md SQLite, AsyncStorage, SecureStore
+ tabs.md NativeTabs, migration from JS tabs, iOS 26 features
+ toolbar-and-headers.md Stack headers and toolbar buttons, menus, search (iOS only)
+ visual-effects.md Blur (expo-blur) and liquid glass (expo-glass-effect)
+ webgpu-three.md 3D graphics, games, GPU visualizations with WebGPU and Three.js
+ zoom-transitions.md Apple Zoom: fluid zoom transitions with Link.AppleZoom (iOS 18+)
+```
+
+## Running the App
+
+**CRITICAL: This app requires a custom Expo development build and will not work in Expo Go.**
+
+Do not attempt to run this app with the Expo Go client - it relies on native modules and configuration that Expo Go does not ship with. Use a custom dev build instead:
+
+1. **iOS**: `npx expo run:ios` (or `npx serve-sim` for the simulator verification flow used in this repo)
+2. **Android**: `npx expo run:android`
+3. **Web**: `npx agent-browser`
+
+### Why a Custom Build Is Required
+
+This project pulls in capabilities Expo Go does not support, including:
+
+- **Local Expo modules** (custom native code in `modules/`)
+- **Apple targets** (widgets, app clips, extensions via `@bacons/apple-targets`)
+- **Third-party native modules** not bundled with Expo Go
+- **Custom native configuration** that can't be expressed in `app.json` alone
+
+If a build fails, fix the native config or run `npx expo prebuild` - do not fall back to Expo Go.
+
+## Code Style
+
+- Be cautious of unterminated strings. Ensure nested backticks are escaped; never forget to escape quotes correctly.
+- Always use import statements at the top of the file.
+- Always use kebab-case for file names, e.g. `comment-card.tsx`
+- Always remove old route files when moving or restructuring navigation
+- Never use special characters in file names
+- Configure tsconfig.json with path aliases, and prefer aliases over relative imports for refactors.
+
+## Routes
+
+See `./references/route-structure.md` for detailed route conventions.
+
+- Routes belong in the `app` directory.
+- Never co-locate components, types, or utilities in the app directory. This is an anti-pattern.
+- Ensure the app always has a route that matches "/", it may be inside a group route.
+
+## Library Preferences
+
+- Never use modules removed from React Native such as Picker, WebView, SafeAreaView, or AsyncStorage
+- Never use legacy expo-permissions
+- `expo-audio` not `expo-av`
+- `expo-video` not `expo-av`
+- `expo-image` with `source="sf:name"` for SF Symbols, not `expo-symbols` or `@expo/vector-icons`
+- `react-native-safe-area-context` not react-native SafeAreaView
+- `process.env.EXPO_OS` not `Platform.OS`
+- `React.use` not `React.useContext`
+- `expo-image` Image component instead of intrinsic element `img`
+- `expo-glass-effect` for liquid glass backdrops
+
+## Responsiveness
+
+- Always wrap root component in a scroll view for responsiveness
+- Use `` instead of `` for smarter safe area insets
+- `contentInsetAdjustmentBehavior="automatic"` should be applied to FlatList and SectionList as well
+- Use flexbox instead of Dimensions API
+- ALWAYS prefer `useWindowDimensions` over `Dimensions.get()` to measure screen size
+
+## Behavior
+
+- Use expo-haptics conditionally on iOS to make more delightful experiences
+- Use views with built-in haptics like `` from React Native and `@react-native-community/datetimepicker`
+- When a route belongs to a Stack, its first child should almost always be a ScrollView with `contentInsetAdjustmentBehavior="automatic"` set
+- When adding a `ScrollView` to the page it should almost always be the first component inside the route component
+- Prefer `headerSearchBarOptions` in Stack.Screen options to add a search bar
+- Use the `` prop on text containing data that could be copied
+- Consider formatting large numbers like 1.4M or 38k
+- Never use intrinsic elements like 'img' or 'div' unless in a webview or Expo DOM component
+
+# Styling
+
+Follow Apple Human Interface Guidelines.
+
+## General Styling Rules
+
+- Prefer flex gap over margin and padding styles
+- Prefer padding over margin where possible
+- Always account for safe area, either with stack headers, tabs, or ScrollView/FlatList `contentInsetAdjustmentBehavior="automatic"`
+- Ensure both top and bottom safe area insets are accounted for
+- Inline styles not StyleSheet.create unless reusing styles is faster
+- Add entering and exiting animations for state changes
+- Use `{ borderCurve: 'continuous' }` for rounded corners unless creating a capsule shape
+- ALWAYS use a navigation stack title instead of a custom text element on the page
+- When padding a ScrollView, use `contentContainerStyle` padding and gap instead of padding on the ScrollView itself (reduces clipping)
+- CSS and Tailwind are not supported - use inline styles
+
+## Text Styling
+
+- Add the `selectable` prop to every `` element displaying important data or error messages
+- Counters should use `{ fontVariant: 'tabular-nums' }` for alignment
+
+## Shadows
+
+Use CSS `boxShadow` style prop. NEVER use legacy React Native shadow or elevation styles.
+
+```tsx
+
+```
+
+'inset' shadows are supported.
+
+# Navigation
+
+## Link
+
+Use `` from 'expo-router' for navigation between routes.
+
+```tsx
+import { Link } from 'expo-router';
+
+// Basic link
+
+
+// Wrapping custom components
+
+ ...
+
+```
+
+Whenever possible, include a `` to follow iOS conventions. Add context menus and previews frequently to enhance navigation.
+
+## Stack
+
+- ALWAYS use `_layout.tsx` files to define stacks
+- Use Stack from 'expo-router/stack' for native navigation stacks
+
+### Page Title
+
+Set the page title in Stack.Screen options:
+
+```tsx
+
+```
+
+## Context Menus
+
+Add long press context menus to Link components:
+
+```tsx
+import { Link } from 'expo-router';
+
+
+
+
+
+
+
+
+
+
+
+ {}} />
+ {}} />
+
+
+;
+```
+
+## Link Previews
+
+Use link previews frequently to enhance navigation:
+
+```tsx
+
+
+
+
+
+
+
+
+```
+
+Link preview can be used with context menus.
+
+## Modal
+
+Present a screen as a modal:
+
+```tsx
+
+```
+
+Prefer this to building a custom modal component.
+
+## Sheet
+
+Present a screen as a dynamic form sheet:
+
+```tsx
+
+```
+
+- Using `contentStyle: { backgroundColor: "transparent" }` makes the background liquid glass on iOS 26+.
+
+## Common route structure
+
+A standard app layout with tabs and stacks inside each tab:
+
+```text
+app/
+ _layout.tsx -
+ (index,search)/
+ _layout.tsx -
+ index.tsx - Main list
+ search.tsx - Search view
+```
+
+```tsx
+// app/_layout.tsx
+import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
+import { Theme } from '../components/theme';
+
+export default function Layout() {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+```
+
+Create a shared group route so both tabs can push common screens:
+
+```tsx
+// app/(index,search)/_layout.tsx
+import { Stack } from 'expo-router/stack';
+import { PlatformColor } from 'react-native';
+
+export default function Layout({ segment }) {
+ const screen = segment.match(/\((.*)\)/)?.[1]!;
+ const titles: Record = { index: 'Items', search: 'Search' };
+
+ return (
+
+
+
+
+ );
+}
+```
diff --git a/apps/mobile/.agents/skills/building-native-ui/references/animations.md b/apps/mobile/.agents/skills/building-native-ui/references/animations.md
new file mode 100644
index 0000000000..6e7f192199
--- /dev/null
+++ b/apps/mobile/.agents/skills/building-native-ui/references/animations.md
@@ -0,0 +1,189 @@
+# Animations
+
+Use Reanimated v4. Avoid React Native's built-in Animated API.
+
+## Entering and Exiting Animations
+
+Use Animated.View with entering and exiting animations. Layout animations can animate state changes.
+
+```tsx
+import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
+
+function App() {
+ return ;
+}
+```
+
+## On-Scroll Animations
+
+Create high-performance scroll animations using Reanimated's hooks:
+
+```tsx
+import Animated, {
+ useAnimatedRef,
+ useScrollViewOffset,
+ useAnimatedStyle,
+ interpolate,
+} from 'react-native-reanimated';
+
+function Page() {
+ const ref = useAnimatedRef();
+ const scroll = useScrollViewOffset(ref);
+
+ const style = useAnimatedStyle(() => ({
+ opacity: interpolate(scroll.value, [0, 30], [0, 1], 'clamp'),
+ }));
+
+ return (
+
+
+
+ );
+}
+```
+
+## Common Animation Presets
+
+### Entering Animations
+
+- `FadeIn`, `FadeInUp`, `FadeInDown`, `FadeInLeft`, `FadeInRight`
+- `SlideInUp`, `SlideInDown`, `SlideInLeft`, `SlideInRight`
+- `ZoomIn`, `ZoomInUp`, `ZoomInDown`
+- `BounceIn`, `BounceInUp`, `BounceInDown`
+
+### Exiting Animations
+
+- `FadeOut`, `FadeOutUp`, `FadeOutDown`, `FadeOutLeft`, `FadeOutRight`
+- `SlideOutUp`, `SlideOutDown`, `SlideOutLeft`, `SlideOutRight`
+- `ZoomOut`, `ZoomOutUp`, `ZoomOutDown`
+- `BounceOut`, `BounceOutUp`, `BounceOutDown`
+
+### Layout Animations
+
+- `LinearTransition` — Smooth linear interpolation
+- `SequencedTransition` — Sequenced property changes
+- `FadingTransition` — Fade between states
+
+## Customizing Animations
+
+```tsx
+
+```
+
+### Modifiers
+
+```tsx
+// Duration in milliseconds
+FadeIn.duration(300);
+
+// Delay before starting
+FadeIn.delay(100);
+
+// Spring physics
+FadeIn.springify();
+FadeIn.springify().damping(15).stiffness(100);
+
+// Easing curves
+FadeIn.easing(Easing.bezier(0.25, 0.1, 0.25, 1));
+
+// Chaining
+FadeInDown.duration(400).delay(200).springify();
+```
+
+## Shared Value Animations
+
+For imperative control over animations:
+
+```tsx
+import { useSharedValue, withSpring, withTiming } from 'react-native-reanimated';
+
+const offset = useSharedValue(0);
+
+// Spring animation
+offset.value = withSpring(100);
+
+// Timing animation
+offset.value = withTiming(100, { duration: 300 });
+
+// Use in styles
+const style = useAnimatedStyle(() => ({
+ transform: [{ translateX: offset.value }],
+}));
+```
+
+## Gesture Animations
+
+Combine with React Native Gesture Handler:
+
+```tsx
+import { Gesture, GestureDetector } from 'react-native-gesture-handler';
+import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
+
+function DraggableBox() {
+ const translateX = useSharedValue(0);
+ const translateY = useSharedValue(0);
+
+ const gesture = Gesture.Pan()
+ .onUpdate((e) => {
+ translateX.value = e.translationX;
+ translateY.value = e.translationY;
+ })
+ .onEnd(() => {
+ translateX.value = withSpring(0);
+ translateY.value = withSpring(0);
+ });
+
+ const style = useAnimatedStyle(() => ({
+ transform: [{ translateX: translateX.value }, { translateY: translateY.value }],
+ }));
+
+ return (
+
+
+
+ );
+}
+```
+
+## Keyboard Animations
+
+Animate with keyboard height changes:
+
+```tsx
+import Animated, { useAnimatedKeyboard, useAnimatedStyle } from 'react-native-reanimated';
+
+function KeyboardAwareView() {
+ const keyboard = useAnimatedKeyboard();
+
+ const style = useAnimatedStyle(() => ({
+ paddingBottom: keyboard.height.value,
+ }));
+
+ return {/* content */};
+}
+```
+
+## Staggered List Animations
+
+Animate list items with delays:
+
+```tsx
+{
+ items.map((item, index) => (
+
+
+
+ ));
+}
+```
+
+## Best Practices
+
+- Add entering and exiting animations for state changes
+- Use layout animations when items are added/removed from lists
+- Use `useAnimatedStyle` for scroll-driven animations
+- Prefer `interpolate` with "clamp" for bounded values
+- You can't pass PlatformColors to reanimated views or styles; use static colors instead
+- Keep animations under 300ms for responsive feel
+- Use spring animations for natural movement
+- Avoid animating layout properties (width, height) when possible — prefer transforms
diff --git a/apps/mobile/.agents/skills/building-native-ui/references/controls.md b/apps/mobile/.agents/skills/building-native-ui/references/controls.md
new file mode 100644
index 0000000000..6d6b23ff19
--- /dev/null
+++ b/apps/mobile/.agents/skills/building-native-ui/references/controls.md
@@ -0,0 +1,245 @@
+# Native Controls
+
+Native iOS controls provide built-in haptics, accessibility, and platform-appropriate styling.
+
+## Switch
+
+Use for binary on/off settings. Has built-in haptics.
+
+```tsx
+import { Switch } from 'react-native';
+import { useState } from 'react';
+
+const [enabled, setEnabled] = useState(false);
+
+;
+```
+
+### Customization
+
+```tsx
+
+```
+
+## Segmented Control
+
+Use for non-navigational tabs or mode selection. Avoid changing default colors.
+
+```tsx
+import SegmentedControl from '@react-native-segmented-control/segmented-control';
+import { useState } from 'react';
+
+const [index, setIndex] = useState(0);
+
+ setIndex(nativeEvent.selectedSegmentIndex)}
+/>;
+```
+
+### Rules
+
+- Maximum 4 options — use a picker for more
+- Keep labels short (1-2 words)
+- Avoid custom colors — native styling adapts to dark mode
+
+### With Icons (iOS 14+)
+
+```tsx
+ setIndex(nativeEvent.selectedSegmentIndex)}
+/>
+```
+
+## Slider
+
+Continuous value selection.
+
+```tsx
+import Slider from '@react-native-community/slider';
+import { useState } from 'react';
+
+const [value, setValue] = useState(0.5);
+
+;
+```
+
+### Customization
+
+```tsx
+
+```
+
+### Discrete Steps
+
+```tsx
+
+```
+
+## Date/Time Picker
+
+Compact pickers with popovers. Has built-in haptics.
+
+```tsx
+import DateTimePicker from '@react-native-community/datetimepicker';
+import { useState } from 'react';
+
+const [date, setDate] = useState(new Date());
+
+ {
+ if (selectedDate) setDate(selectedDate);
+ }}
+ mode="datetime"
+/>;
+```
+
+### Modes
+
+- `date` — Date only
+- `time` — Time only
+- `datetime` — Date and time
+
+### Display Styles
+
+```tsx
+// Compact inline (default)
+
+
+// Spinner wheel
+
+
+// Full calendar
+
+```
+
+### Time Intervals
+
+```tsx
+
+```
+
+### Min/Max Dates
+
+```tsx
+
+```
+
+## Stepper
+
+Increment/decrement numeric values.
+
+```tsx
+import { Stepper } from 'react-native';
+import { useState } from 'react';
+
+const [count, setCount] = useState(0);
+
+;
+```
+
+## TextInput
+
+Native text input with various keyboard types.
+
+```tsx
+import { TextInput } from 'react-native';
+
+;
+```
+
+### Keyboard Types
+
+```tsx
+// Email
+
+
+// Phone
+
+
+// Number
+
+
+// Password
+
+
+// Search
+
+```
+
+### Multiline
+
+```tsx
+
+```
+
+## Picker (Wheel)
+
+For selection from many options (5+ items).
+
+```tsx
+import { Picker } from '@react-native-picker/picker';
+import { useState } from 'react';
+
+const [selected, setSelected] = useState('js');
+
+
+
+
+
+
+;
+```
+
+## Best Practices
+
+- **Haptics**: Switch and DateTimePicker have built-in haptics — don't add extra
+- **Accessibility**: Native controls have proper accessibility labels by default
+- **Dark Mode**: Avoid custom colors — native styling adapts automatically
+- **Spacing**: Use consistent padding around controls (12-16pt)
+- **Labels**: Place labels above or to the left of controls
+- **Grouping**: Group related controls in sections with headers
diff --git a/apps/mobile/.agents/skills/building-native-ui/references/form-sheet.md b/apps/mobile/.agents/skills/building-native-ui/references/form-sheet.md
new file mode 100644
index 0000000000..88a4144917
--- /dev/null
+++ b/apps/mobile/.agents/skills/building-native-ui/references/form-sheet.md
@@ -0,0 +1,251 @@
+# Form Sheets in Expo Router
+
+This skill covers implementing form sheets with footers using Expo Router's Stack navigator and react-native-screens.
+
+## Overview
+
+Form sheets are modal presentations that appear as a card sliding up from the bottom of the screen. They're ideal for:
+
+- Quick actions and confirmations
+- Settings panels
+- Login/signup flows
+- Action sheets with custom content
+
+**Requirements:**
+
+- Expo Router Stack navigator
+
+## Basic Usage
+
+### Form Sheet with Footer
+
+Configure the Stack.Screen with transparent backgrounds and sheet presentation:
+
+```tsx
+// app/_layout.tsx
+import { Stack } from 'expo-router';
+
+export default function Layout() {
+ return (
+
+
+
+
+
+
+ );
+}
+```
+
+### Form Sheet Screen Content
+
+> Requires Expo SDK 55 or later.
+
+Use `flex: 1` to allow the content to fill available space, enabling footer positioning:
+
+```tsx
+// app/about.tsx
+import { View, Text, StyleSheet } from 'react-native';
+
+export default function AboutSheet() {
+ return (
+
+ {/* Main content */}
+
+ Sheet Content
+
+
+ {/* Footer - stays at bottom */}
+
+ Footer Content
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ content: {
+ flex: 1,
+ padding: 16,
+ },
+ footer: {
+ padding: 16,
+ },
+});
+```
+
+### Formsheet with interactive content below
+
+Use `sheetLargestUndimmedDetentIndex` (zero-indexed) to keep content behind the form sheet interactive — e.g. letting users pan a map beneath it. Setting it to `1` allows interaction at the first two detents but dims on the third.
+
+```tsx
+// app/_layout.tsx
+import { Stack } from 'expo-router';
+
+export default function Layout() {
+ return (
+
+
+
+
+ );
+}
+```
+
+## Key Options
+
+| Option | Type | Description |
+| --------------------- | ---------- | ----------------------------------------------------------- |
+| `presentation` | `string` | Set to `'formSheet'` for sheet presentation |
+| `sheetGrabberVisible` | `boolean` | Shows the drag handle at the top of the sheet |
+| `sheetAllowedDetents` | `number[]` | Array of detent heights (0-1 range, e.g., `[0.25]` for 25%) |
+| `headerTransparent` | `boolean` | Makes header background transparent |
+| `contentStyle` | `object` | Style object for the screen content container |
+| `title` | `string` | Screen title (set to `''` for no title) |
+
+## Common Detent Values
+
+- `[0.25]` - Quarter sheet (compact actions)
+- `[0.5]` - Half sheet (medium content)
+- `[0.75]` - Three-quarter sheet (detailed forms)
+- `[0.25, 0.5, 1]` - Multiple stops (expandable sheet)
+
+## Complete Example
+
+```tsx
+// _layout.tsx
+import { Stack } from 'expo-router';
+
+export default function Layout() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+```
+
+```tsx
+// app/confirm.tsx
+import { View, Text, Pressable, StyleSheet } from 'react-native';
+import { router } from 'expo-router';
+
+export default function ConfirmSheet() {
+ return (
+
+
+ Confirm Action
+ Are you sure you want to proceed?
+
+
+
+ router.back()}>
+ Cancel
+
+ router.back()}>
+ Confirm
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ content: {
+ flex: 1,
+ padding: 20,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ title: {
+ fontSize: 18,
+ fontWeight: '600',
+ marginBottom: 8,
+ },
+ description: {
+ fontSize: 14,
+ color: '#666',
+ textAlign: 'center',
+ },
+ footer: {
+ flexDirection: 'row',
+ padding: 16,
+ gap: 12,
+ },
+ cancelButton: {
+ flex: 1,
+ padding: 14,
+ borderRadius: 10,
+ backgroundColor: '#f0f0f0',
+ alignItems: 'center',
+ },
+ cancelText: {
+ fontSize: 16,
+ fontWeight: '500',
+ },
+ confirmButton: {
+ flex: 1,
+ padding: 14,
+ borderRadius: 10,
+ backgroundColor: '#007AFF',
+ alignItems: 'center',
+ },
+ confirmText: {
+ fontSize: 16,
+ fontWeight: '500',
+ color: 'white',
+ },
+});
+```
+
+## Troubleshooting
+
+### Content not filling sheet
+
+Make sure the root View uses `flex: 1`:
+
+```tsx
+{/* content */}
+```
+
+### Sheet background showing through
+
+Set `contentStyle: { backgroundColor: 'transparent' }` in options and style your content container with the desired background color instead.
diff --git a/apps/mobile/.agents/skills/building-native-ui/references/gradients.md b/apps/mobile/.agents/skills/building-native-ui/references/gradients.md
new file mode 100644
index 0000000000..8b3dba9568
--- /dev/null
+++ b/apps/mobile/.agents/skills/building-native-ui/references/gradients.md
@@ -0,0 +1,116 @@
+# CSS Gradients
+
+> **New Architecture Only**: CSS gradients require React Native's New Architecture (Fabric). They are not available in the old architecture or Expo Go.
+
+Use CSS gradients with the `experimental_backgroundImage` style property.
+
+## Linear Gradients
+
+```tsx
+// Top to bottom
+
+
+// Left to right
+
+
+// Diagonal
+
+
+// Using degrees
+
+```
+
+## Radial Gradients
+
+```tsx
+// Circle at center
+
+
+// Ellipse
+
+
+// Positioned
+
+```
+
+## Multiple Gradients
+
+Stack multiple gradients by comma-separating them:
+
+```tsx
+
+```
+
+## Common Patterns
+
+### Overlay on Image
+
+```tsx
+
+
+
+
+```
+
+### Frosted Glass Effect
+
+```tsx
+
+```
+
+### Button Gradient
+
+```tsx
+
+ Submit
+
+```
+
+## Important Notes
+
+- Do NOT use `expo-linear-gradient` — use CSS gradients instead
+- Gradients are strings, not objects
+- Use `rgba()` for transparency, or `transparent` keyword
+- Color stops use percentages (0%, 50%, 100%)
+- Direction keywords: `to top`, `to bottom`, `to left`, `to right`, `to top left`, etc.
+- Degree values: `45deg`, `90deg`, `135deg`, etc.
diff --git a/apps/mobile/.agents/skills/building-native-ui/references/icons.md b/apps/mobile/.agents/skills/building-native-ui/references/icons.md
new file mode 100644
index 0000000000..95bc764d8f
--- /dev/null
+++ b/apps/mobile/.agents/skills/building-native-ui/references/icons.md
@@ -0,0 +1,218 @@
+# Icons (SF Symbols)
+
+Use SF Symbols for native feel. Never use FontAwesome or Ionicons.
+
+## Basic Usage
+
+```tsx
+import { SymbolView } from 'expo-symbols';
+import { PlatformColor } from 'react-native';
+
+;
+```
+
+## Props
+
+```tsx
+
+```
+
+## Common Icons
+
+### Navigation & Actions
+
+- `house.fill` - home
+- `gear` - settings
+- `magnifyingglass` - search
+- `plus` - add
+- `xmark` - close
+- `chevron.left` - back
+- `chevron.right` - forward
+- `arrow.left` - back arrow
+- `arrow.right` - forward arrow
+
+### Media
+
+- `play.fill` - play
+- `pause.fill` - pause
+- `stop.fill` - stop
+- `backward.fill` - rewind
+- `forward.fill` - fast forward
+- `speaker.wave.2.fill` - volume
+- `speaker.slash.fill` - mute
+
+### Camera
+
+- `camera` - camera
+- `camera.fill` - camera filled
+- `arrow.triangle.2.circlepath` - flip camera
+- `photo` - gallery/photos
+- `bolt` - flash
+- `bolt.slash` - flash off
+
+### Communication
+
+- `message` - message
+- `message.fill` - message filled
+- `envelope` - email
+- `envelope.fill` - email filled
+- `phone` - phone
+- `phone.fill` - phone filled
+- `video` - video call
+- `video.fill` - video call filled
+
+### Social
+
+- `heart` - like
+- `heart.fill` - liked
+- `star` - favorite
+- `star.fill` - favorited
+- `hand.thumbsup` - thumbs up
+- `hand.thumbsdown` - thumbs down
+- `person` - profile
+- `person.fill` - profile filled
+- `person.2` - people
+- `person.2.fill` - people filled
+
+### Content Actions
+
+- `square.and.arrow.up` - share
+- `square.and.arrow.down` - download
+- `doc.on.doc` - copy
+- `trash` - delete
+- `pencil` - edit
+- `folder` - folder
+- `folder.fill` - folder filled
+- `bookmark` - bookmark
+- `bookmark.fill` - bookmarked
+
+### Status & Feedback
+
+- `checkmark` - success/done
+- `checkmark.circle.fill` - completed
+- `xmark.circle.fill` - error/failed
+- `exclamationmark.triangle` - warning
+- `info.circle` - info
+- `questionmark.circle` - help
+- `bell` - notification
+- `bell.fill` - notification filled
+
+### Misc
+
+- `ellipsis` - more options
+- `ellipsis.circle` - more in circle
+- `line.3.horizontal` - menu/hamburger
+- `slider.horizontal.3` - filters
+- `arrow.clockwise` - refresh
+- `location` - location
+- `location.fill` - location filled
+- `map` - map
+- `mappin` - pin
+- `clock` - time
+- `calendar` - calendar
+- `link` - link
+- `nosign` - block/prohibited
+
+## Animated Symbols
+
+```tsx
+
+```
+
+### Animation Effects
+
+- `bounce` - Bouncy animation
+- `pulse` - Pulsing effect
+- `variableColor` - Color cycling
+- `scale` - Scale animation
+
+```tsx
+// Bounce with direction
+animationSpec={{
+ effect: { type: "bounce", direction: "up" } // up | down
+}}
+
+// Pulse
+animationSpec={{
+ effect: { type: "pulse" }
+}}
+
+// Variable color (multicolor symbols)
+animationSpec={{
+ effect: {
+ type: "variableColor",
+ cumulative: true,
+ reversing: true
+ }
+}}
+```
+
+## Symbol Weights
+
+```tsx
+// Lighter weights
+
+
+
+
+// Default
+
+
+// Heavier weights
+
+
+
+
+
+```
+
+## Symbol Scales
+
+```tsx
+
+ // default
+
+```
+
+## Multicolor Symbols
+
+Some symbols support multiple colors:
+
+```tsx
+
+```
+
+## Finding Symbol Names
+
+1. Use the SF Symbols app on macOS (free from Apple)
+2. Search at https://developer.apple.com/sf-symbols/
+3. Symbol names use dot notation: `square.and.arrow.up`
+
+## Best Practices
+
+- Always use SF Symbols over vector icon libraries
+- Match symbol weight to nearby text weight
+- Use `.fill` variants for selected/active states
+- Use PlatformColor for tint to support dark mode
+- Keep icons at consistent sizes (16, 20, 24, 32)
diff --git a/apps/mobile/.agents/skills/building-native-ui/references/media.md b/apps/mobile/.agents/skills/building-native-ui/references/media.md
new file mode 100644
index 0000000000..212d648d7a
--- /dev/null
+++ b/apps/mobile/.agents/skills/building-native-ui/references/media.md
@@ -0,0 +1,229 @@
+# Media
+
+## Camera
+
+- Hide navigation headers when there's a full screen camera
+- Ensure to flip the camera with `mirror` to emulate social apps
+- Use liquid glass buttons on cameras
+- Icons: `arrow.triangle.2.circlepath` (flip), `photo` (gallery), `bolt` (flash)
+- Eagerly request camera permission
+- Lazily request media library permission
+
+```tsx
+import React, { useRef, useState } from 'react';
+import { View, TouchableOpacity, Text, Alert } from 'react-native';
+import { CameraView, CameraType, useCameraPermissions } from 'expo-camera';
+import * as MediaLibrary from 'expo-media-library';
+import * as ImagePicker from 'expo-image-picker';
+import * as Haptics from 'expo-haptics';
+import { SymbolView } from 'expo-symbols';
+import { PlatformColor } from 'react-native';
+import { GlassView } from 'expo-glass-effect';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+function Camera({ onPicture }: { onPicture: (uri: string) => Promise }) {
+ const [permission, requestPermission] = useCameraPermissions();
+ const cameraRef = useRef(null);
+ const [type, setType] = useState('back');
+ const { bottom } = useSafeAreaInsets();
+
+ if (!permission?.granted) {
+ return (
+
+
+ Camera access is required
+
+
+
+ Grant Permission
+
+
+
+ );
+ }
+
+ const takePhoto = async () => {
+ await Haptics.selectionAsync();
+ if (!cameraRef.current) return;
+ const photo = await cameraRef.current.takePictureAsync({ quality: 0.8 });
+ await onPicture(photo.uri);
+ };
+
+ const selectPhoto = async () => {
+ await Haptics.selectionAsync();
+ const result = await ImagePicker.launchImageLibraryAsync({
+ mediaTypes: 'images',
+ allowsEditing: false,
+ quality: 0.8,
+ });
+ if (!result.canceled && result.assets?.[0]) {
+ await onPicture(result.assets[0].uri);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ setType((t) => (t === 'back' ? 'front' : 'back'))}
+ icon="arrow.triangle.2.circlepath"
+ />
+
+
+
+ );
+}
+```
+
+## Audio Playback
+
+Use `expo-audio` not `expo-av`:
+
+```tsx
+import { useAudioPlayer } from 'expo-audio';
+
+const player = useAudioPlayer({ uri: 'https://stream.nightride.fm/rektory.mp3' });
+
+
+ );
+}
diff --git a/apps/mobile/src/components/drawer-layout.tsx b/apps/mobile/src/components/drawer-layout.tsx
new file mode 100644
index 0000000000..b4dd876b98
--- /dev/null
+++ b/apps/mobile/src/components/drawer-layout.tsx
@@ -0,0 +1,340 @@
+/**
+ * Simplified drawer layout forked from react-native-drawer-layout.
+ * Only supports "back" type (drawer behind content), left-side, LTR.
+ */
+
+import * as Haptics from 'expo-haptics';
+import * as React from 'react';
+import { InteractionManager, Keyboard, Pressable, useWindowDimensions, View } from 'react-native';
+import {
+ Gesture,
+ GestureDetector,
+ State as GestureState,
+ GestureHandlerRootView as XGestureHandlerRootView,
+} from 'react-native-gesture-handler';
+import Animated, {
+ interpolate,
+ ReduceMotion,
+ runOnJS,
+ useAnimatedProps,
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated';
+import { withUniwind } from 'uniwind';
+
+const GestureHandlerRootView = withUniwind(XGestureHandlerRootView);
+
+const APPROX_APP_BAR_HEIGHT = 56;
+const DEFAULT_DRAWER_WIDTH = 360;
+const SWIPE_EDGE_WIDTH = 32;
+const SWIPE_MIN_OFFSET = 5;
+const SWIPE_MIN_DISTANCE = 60;
+const SWIPE_MIN_VELOCITY = 500;
+const PROGRESS_EPSILON = 0.05;
+
+function getDrawerWidth(layoutWidth: number, drawerWidth?: number): number {
+ if (drawerWidth != null) return drawerWidth;
+ return layoutWidth - APPROX_APP_BAR_HEIGHT <= DEFAULT_DRAWER_WIDTH
+ ? layoutWidth - APPROX_APP_BAR_HEIGHT
+ : DEFAULT_DRAWER_WIDTH;
+}
+
+const minmax = (value: number, start: number, end: number) => {
+ 'worklet';
+ return Math.min(Math.max(value, start), end);
+};
+
+type DrawerLayoutProps = {
+ open: boolean;
+ onOpen: () => void;
+ onClose: () => void;
+ drawerContent: React.ReactNode;
+ drawerWidth?: number;
+ swipeEnabled?: boolean;
+ children: React.ReactNode;
+};
+
+export function DrawerLayout({
+ open,
+ onOpen,
+ onClose,
+ drawerContent,
+ drawerWidth: drawerWidthProp,
+ swipeEnabled = true,
+ children,
+}: DrawerLayoutProps) {
+ const { width: layoutWidth } = useWindowDimensions();
+ const drawerWidth = getDrawerWidth(layoutWidth, drawerWidthProp);
+
+ // Use refs for callbacks to keep toggleDrawer stable
+ const onOpenRef = React.useRef(onOpen);
+ const onCloseRef = React.useRef(onClose);
+ React.useEffect(() => {
+ onOpenRef.current = onOpen;
+ onCloseRef.current = onClose;
+ });
+
+ const callOnOpen = React.useCallback(() => onOpenRef.current(), []);
+ const callOnClose = React.useCallback(() => onCloseRef.current(), []);
+
+ const interactionHandleRef = React.useRef(null);
+
+ const startInteraction = React.useCallback(() => {
+ interactionHandleRef.current = InteractionManager.createInteractionHandle();
+ }, []);
+
+ const endInteraction = React.useCallback(() => {
+ if (interactionHandleRef.current != null) {
+ InteractionManager.clearInteractionHandle(interactionHandleRef.current);
+ interactionHandleRef.current = null;
+ }
+ }, []);
+
+ const touchStartX = useSharedValue(0);
+ const touchX = useSharedValue(0);
+ const translationX = useSharedValue(open ? 0 : -drawerWidth);
+ const gestureState = useSharedValue(GestureState.UNDETERMINED);
+ const startX = useSharedValue(0);
+ // Track the current `open` prop on the UI thread
+ const openValue = useSharedValue(open);
+
+ const toggleDrawer = React.useCallback(
+ (isOpen: boolean, velocity?: number) => {
+ 'worklet';
+
+ const target = isOpen ? 0 : -drawerWidth;
+
+ touchStartX.value = 0;
+ touchX.value = 0;
+ translationX.value = withSpring(target, {
+ velocity,
+ stiffness: 1000,
+ damping: 500,
+ mass: 3,
+ overshootClamping: true,
+ reduceMotion: ReduceMotion.Never,
+ });
+
+ if (isOpen) {
+ runOnJS(callOnOpen)();
+ } else {
+ runOnJS(callOnClose)();
+ }
+ },
+ [drawerWidth, callOnOpen, callOnClose, touchStartX, touchX, translationX]
+ );
+
+ // Animate to match `open` prop
+ React.useEffect(() => {
+ openValue.value = open;
+ toggleDrawer(open);
+ if (open) {
+ Keyboard.dismiss();
+ }
+ }, [open, toggleDrawer, openValue]);
+
+ const onGestureBegin = React.useCallback(() => {
+ startInteraction();
+ Keyboard.dismiss();
+ }, [startInteraction]);
+
+ const onGestureFinish = React.useCallback(
+ (nextOpen: boolean) => {
+ endInteraction();
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ if (nextOpen) {
+ Keyboard.dismiss();
+ }
+ },
+ [endInteraction]
+ );
+
+ const pan = React.useMemo(() => {
+ const gesture = Gesture.Pan()
+ .onBegin((event) => {
+ 'worklet';
+ startX.value = translationX.value;
+ gestureState.value = event.state;
+ touchStartX.value = event.x;
+ })
+ .onStart(() => {
+ 'worklet';
+ runOnJS(onGestureBegin)();
+ })
+ .onChange((event) => {
+ 'worklet';
+ touchX.value = event.x;
+ // Clamp so content can't go past fully-open or fully-closed
+ translationX.value = minmax(startX.value + event.translationX, -drawerWidth, 0);
+ gestureState.value = event.state;
+ })
+ .onEnd((event) => {
+ 'worklet';
+ gestureState.value = event.state;
+
+ const nextOpen =
+ (Math.abs(event.translationX) > SWIPE_MIN_OFFSET &&
+ Math.abs(event.velocityX) > SWIPE_MIN_VELOCITY) ||
+ Math.abs(event.translationX) > SWIPE_MIN_DISTANCE
+ ? // If swiped right, open; if swiped left, close
+ (event.velocityX === 0 ? event.translationX : event.velocityX) > 0
+ : openValue.value;
+
+ toggleDrawer(nextOpen, event.velocityX);
+ runOnJS(onGestureFinish)(nextOpen);
+ })
+ .activeOffsetX([-SWIPE_MIN_OFFSET, SWIPE_MIN_OFFSET])
+ .failOffsetY([-SWIPE_MIN_OFFSET, SWIPE_MIN_OFFSET])
+ .enabled(swipeEnabled);
+
+ // When closed, only activate from the left edge.
+ // When open, activate from anywhere.
+ if (!open) {
+ gesture.hitSlop({ left: 0, width: SWIPE_EDGE_WIDTH });
+ }
+
+ return gesture;
+ }, [
+ drawerWidth,
+ gestureState,
+ onGestureBegin,
+ onGestureFinish,
+ open,
+ openValue,
+ startX,
+ swipeEnabled,
+ toggleDrawer,
+ touchStartX,
+ touchX,
+ translationX,
+ ]);
+
+ // Clamped translation for styles
+ const translateX = useDerivedValue(() => minmax(translationX.value, -drawerWidth, 0));
+
+ const CORNERS = process.env.EXPO_OS === 'ios' ? 53 : undefined;
+ const contentAnimatedStyle = useAnimatedStyle(
+ () => ({
+ zIndex: translateX.value === -drawerWidth ? 0 : 2,
+ transform: [
+ {
+ translateX: translateX.value + drawerWidth,
+ },
+ ],
+ }),
+ [drawerWidth, translateX]
+ );
+
+ const drawerAnimatedStyle = useAnimatedStyle(
+ () => ({
+ // Force commit to shadow tree for pressables
+ zIndex: translateX.value === -drawerWidth ? -1 : 0,
+ transform: [
+ {
+ scale: interpolate(
+ drawerWidth === 0 ? 0 : (translateX.value + drawerWidth) / drawerWidth,
+ [0, 1],
+ [0.95, 1]
+ ),
+ },
+ ],
+ }),
+ [drawerWidth, translateX]
+ );
+
+ const progress = useDerivedValue(() =>
+ drawerWidth === 0 ? 0 : interpolate(translateX.value, [-drawerWidth, 0], [0, 1])
+ );
+
+ return (
+
+
+
+
+
+ {children}
+
+ toggleDrawer(false)} />
+
+
+ {drawerContent}
+
+
+
+
+
+ );
+}
+
+function Overlay({
+ progress,
+ onPress,
+}: {
+ progress: ReturnType>;
+ onPress: () => void;
+}) {
+ const animatedStyle = useAnimatedStyle(
+ () => ({
+ opacity: progress.value,
+ }),
+ [progress]
+ );
+
+ const animatedProps = useAnimatedProps(() => {
+ const active = progress.value > PROGRESS_EPSILON;
+ return {
+ pointerEvents: active ? 'auto' : 'none',
+ 'aria-hidden': !active,
+ } as const;
+ }, [progress]);
+
+ return (
+
+
+
+ );
+}
+
+function DrawerDim({ progress }: { progress: ReturnType> }) {
+ const animatedStyle = useAnimatedStyle(() => {
+ // Counter-scale to fill the full area when parent is scaled down
+ const parentScale = interpolate(progress.value, [0, 1], [0.95, 1]);
+ const counterScale = 1 / parentScale;
+ return {
+ opacity: interpolate(progress.value, [0, 1], [0.5, 0]),
+ transform: [{ scale: counterScale }],
+ };
+ }, [progress]);
+
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/components/grabber.android.tsx b/apps/mobile/src/components/grabber.android.tsx
new file mode 100644
index 0000000000..b3870d3ccf
--- /dev/null
+++ b/apps/mobile/src/components/grabber.android.tsx
@@ -0,0 +1,11 @@
+import { Icon } from '@/components/icon';
+import { Minus } from 'lucide-react-native';
+import { View } from 'react-native';
+
+export function AndroidGrabber() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/grabber.tsx b/apps/mobile/src/components/grabber.tsx
new file mode 100644
index 0000000000..8524a9fc74
--- /dev/null
+++ b/apps/mobile/src/components/grabber.tsx
@@ -0,0 +1,3 @@
+export function AndroidGrabber() {
+ return null;
+}
diff --git a/apps/mobile/src/components/icon.tsx b/apps/mobile/src/components/icon.tsx
new file mode 100644
index 0000000000..9bb8d8d756
--- /dev/null
+++ b/apps/mobile/src/components/icon.tsx
@@ -0,0 +1,21 @@
+import { StyleSheet } from 'react-native';
+import { withUniwind } from 'uniwind';
+import type { LucideIcon } from 'lucide-react-native';
+
+function IconBase({
+ icon: Icon,
+ style,
+ strokeWidth,
+}: {
+ icon: LucideIcon;
+ style?: any;
+ strokeWidth?: number;
+ className?: string;
+}) {
+ const flat = StyleSheet.flatten(style) || {};
+ const size = (flat.width as number) ?? (flat.height as number) ?? 24;
+ const color = (flat.color as string) ?? 'currentColor';
+ return ;
+}
+
+export const Icon = withUniwind(IconBase);
diff --git a/apps/mobile/src/components/main-header.android.tsx b/apps/mobile/src/components/main-header.android.tsx
new file mode 100644
index 0000000000..9e3651bb44
--- /dev/null
+++ b/apps/mobile/src/components/main-header.android.tsx
@@ -0,0 +1 @@
+export { MainHeader } from './main-header.fallback';
diff --git a/apps/mobile/src/components/main-header.fallback.tsx b/apps/mobile/src/components/main-header.fallback.tsx
new file mode 100644
index 0000000000..e2c798e4c0
--- /dev/null
+++ b/apps/mobile/src/components/main-header.fallback.tsx
@@ -0,0 +1,87 @@
+import { Icon } from '@/components/icon';
+import { useModel } from '@/components/model-context';
+import { useSessions } from '@/lib/SessionsContext';
+import { Link, Stack } from 'expo-router';
+import { ChevronDown, Menu, Plus } from 'lucide-react-native';
+import { useCallback } from 'react';
+import { Pressable, Text, View } from 'react-native';
+import { useDrawer } from './drawer-content';
+
+function HeaderTitleMenu() {
+ const { models, selectedModel, extendedThinking } = useModel();
+ const selected = models.find((m) => m.id === selectedModel);
+ const subtitle = extendedThinking ? 'Extended' : undefined;
+
+ return (
+
+
+
+
+ {selected?.label ?? 'Model'}
+
+
+
+ {subtitle && {subtitle}}
+
+
+ );
+}
+
+export function MainHeader() {
+ const { openDrawer } = useDrawer();
+ const { newTab, activeSessionId } = useSessions();
+
+ const handleNewTab = useCallback(() => {
+ if (activeSessionId) {
+ newTab(activeSessionId);
+ }
+ }, [activeSessionId, newTab]);
+
+ return (
+ <>
+ {process.env.EXPO_OS === 'ios' ? (
+
+
+
+ ) : (
+ // TODO: Migrate to unified Toolbar support for Android in SDK 56
+
+
+
+
+
+ )}
+
+
+
+
+
+ {process.env.EXPO_OS === 'ios' ? (
+
+
+
+ ) : (
+ // TODO: Migrate to unified Toolbar support for Android in SDK 56
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/mobile/src/components/main-header.ios.tsx b/apps/mobile/src/components/main-header.ios.tsx
new file mode 100644
index 0000000000..d5f39abe06
--- /dev/null
+++ b/apps/mobile/src/components/main-header.ios.tsx
@@ -0,0 +1 @@
+export { MainHeader } from './main-header.swiftui';
diff --git a/apps/mobile/src/components/main-header.swiftui.tsx b/apps/mobile/src/components/main-header.swiftui.tsx
new file mode 100644
index 0000000000..952eb9df7d
--- /dev/null
+++ b/apps/mobile/src/components/main-header.swiftui.tsx
@@ -0,0 +1,98 @@
+import { useModel } from '@/components/model-context';
+import {
+ Button,
+ Host,
+ HStack,
+ Menu,
+ Section,
+ Image as SUIImage,
+ Text as SUIText,
+ Toggle,
+ VStack,
+} from '@expo/ui/swift-ui';
+import { controlSize, font, foregroundStyle } from '@expo/ui/swift-ui/modifiers';
+import { Stack } from 'expo-router';
+import { useCallback } from 'react';
+import { useColorScheme } from 'react-native';
+import { useSessions } from '@/lib/SessionsContext';
+import { useDrawer } from './drawer-content';
+
+function HeaderTitleMenu() {
+ const { models, selectedModel, extendedThinking, setExtendedThinking } = useModel();
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === 'dark';
+ const headerFg = isDark ? '#fff' : '#000';
+ const headerFgMuted = isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.5)';
+
+ const selected = models.find((m) => m.id === selectedModel);
+ const subtitle = extendedThinking ? 'Extended' : undefined;
+ return (
+
+
+
+ );
+}
+
+export function MainHeader() {
+ const { openDrawer } = useDrawer();
+ const { newTab, activeSessionId } = useSessions();
+
+ const handleNewTab = useCallback(() => {
+ if (activeSessionId) {
+ newTab(activeSessionId);
+ }
+ }, [activeSessionId, newTab]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ New Tab
+
+
+
+ >
+ );
+}
diff --git a/apps/mobile/src/components/main-header.tsx b/apps/mobile/src/components/main-header.tsx
new file mode 100644
index 0000000000..07878077f8
--- /dev/null
+++ b/apps/mobile/src/components/main-header.tsx
@@ -0,0 +1,12 @@
+/**
+ * MainHeader - Header component for the chat screen
+ *
+ * Renders the AITabStrip for switching between AI tabs within the active session.
+ * The tab strip is positioned at the top of the chat screen, above the conversation.
+ */
+
+import { AITabStrip } from './AITabStrip';
+
+export function MainHeader() {
+ return ;
+}
diff --git a/apps/mobile/src/components/markdown/ast-renderer.ts b/apps/mobile/src/components/markdown/ast-renderer.ts
new file mode 100644
index 0000000000..b8d865e75c
--- /dev/null
+++ b/apps/mobile/src/components/markdown/ast-renderer.ts
@@ -0,0 +1,159 @@
+import type { Node, Root } from 'mdast';
+import defaultRenderRules from './render-rules';
+import { getMergedStyles } from './utils';
+import type { ReactElement } from 'react';
+import type {
+ StyleMap,
+ ASTRendererOptions,
+ ListBulletStyle,
+ NodeTypeMap,
+ RenderFunction,
+ RenderRules,
+ ValidNodeKey,
+} from './types';
+
+export default class ASTRenderer {
+ private _renderRules: RenderRules;
+ private _styles: StyleMap;
+ private _debug: boolean;
+ private _listBulletStyle: ListBulletStyle;
+ private _customBulletElement: ReactElement | null;
+ private _onLinkPress?: (url: string) => void;
+
+ constructor({
+ renderRules,
+ styles = null,
+ mergeStyle = true,
+ debug = false,
+ listBulletStyle = 'disc',
+ customBulletElement = null,
+ onLinkPress,
+ }: ASTRendererOptions) {
+ this._renderRules = {
+ ...defaultRenderRules,
+ ...(renderRules || {}),
+ };
+ this._styles = getMergedStyles(styles, mergeStyle);
+ this._listBulletStyle = listBulletStyle;
+ this._debug = debug;
+ this._onLinkPress = onLinkPress;
+ this._customBulletElement = customBulletElement;
+ }
+
+ private get getListBulletCharacter() {
+ switch (this._listBulletStyle) {
+ case 'disc':
+ return '\u2022';
+ case 'dash':
+ return '-';
+ }
+ }
+
+ private debugLog(length: number, type: string) {
+ if (this._debug) {
+ console.log(`${' '.repeat(length)}${type}`);
+ }
+ }
+
+ private getRenderFunction(type: keyof RenderRules): RenderFunction {
+ const fn = this._renderRules[type];
+ if (!fn) {
+ console.warn(`Missing render rule for node type: ${type}`);
+ return (this._renderRules.unknown ?? (() => null)) as RenderFunction;
+ }
+ return fn as RenderFunction;
+ }
+
+ private renderNode = (
+ node: Node,
+ parentStack: Node[] = [],
+ extras?: Record
+ ): any => {
+ const children: any[] = [];
+ let type = node.type as ValidNodeKey;
+
+ if (type === 'link' && this._onLinkPress) {
+ extras = {
+ ...extras,
+ onPress: this._onLinkPress,
+ };
+ }
+
+ if ('children' in node && Array.isArray(node.children)) {
+ if (type === 'list') {
+ const listNode = node as import('mdast').List;
+ const start = listNode.start ?? 1;
+ const ordered = listNode.ordered ?? false;
+
+ for (let i = 0; i < listNode.children.length; i++) {
+ const listItemNode = listNode.children[i];
+ const listStyleType = ordered ? `${start + i}.` : this.getListBulletCharacter;
+
+ const customListStyleType = !ordered && this._customBulletElement;
+
+ if (!listItemNode) {
+ console.warn(`Skipping empty list item at index: ${i}`);
+ continue;
+ }
+
+ const renderedChild = this.renderNode(listItemNode, [node, ...parentStack], {
+ listStyleType,
+ index: i,
+ ordered,
+ start,
+ customListStyleType,
+ });
+
+ children.push(renderedChild);
+ }
+ } else if (type === 'table') {
+ // Handle table with header row detection
+ const tableNode = node as import('mdast').Table;
+ for (let i = 0; i < tableNode.children.length; i++) {
+ const rowNode = tableNode.children[i];
+ if (rowNode) {
+ children.push(
+ this.renderNode(rowNode, [node, ...parentStack], {
+ isHeader: i === 0,
+ rowIndex: i,
+ })
+ );
+ }
+ }
+ } else if (type === 'tableRow') {
+ // Handle table row with cell rendering
+ const tableRowNode = node as import('mdast').TableRow;
+ for (let i = 0; i < tableRowNode.children.length; i++) {
+ const cellNode = tableRowNode.children[i];
+ if (cellNode) {
+ children.push(
+ this.renderNode(cellNode, [node, ...parentStack], {
+ ...extras,
+ cellIndex: i,
+ })
+ );
+ }
+ }
+ } else {
+ for (const child of node.children) {
+ children.push(this.renderNode(child, [node, ...parentStack]));
+ }
+ }
+ }
+
+ const renderFunction = this.getRenderFunction(type);
+ this.debugLog(parentStack.length, type);
+
+ return renderFunction({
+ node: node as NodeTypeMap[typeof type],
+ styles: this._styles,
+ children,
+ parentStack,
+ extras,
+ });
+ };
+
+ public render = (tree: Root): any => {
+ return this.renderNode(tree);
+ };
+}
diff --git a/apps/mobile/src/components/markdown/chat-markdown.tsx b/apps/mobile/src/components/markdown/chat-markdown.tsx
new file mode 100644
index 0000000000..b36aba46f1
--- /dev/null
+++ b/apps/mobile/src/components/markdown/chat-markdown.tsx
@@ -0,0 +1,237 @@
+import * as WebBrowser from 'expo-web-browser';
+import React, { useCallback, useMemo } from 'react';
+import { Linking, Platform, StyleSheet, Text, View } from 'react-native';
+import { FileText } from 'lucide-react-native';
+import { useCSSVariable } from 'uniwind';
+import { useToast } from '@/lib/ToastContext';
+import { useAccent } from '@/theme/AccentContext';
+import Markdown from './markdown';
+
+const VAR_NAMES = [
+ '--app-foreground',
+ '--app-muted-foreground',
+ '--app-border',
+ '--app-secondary',
+ '--app-muted',
+ '--app-accent',
+ // Tailwind blue
+ '--color-blue-400',
+] as const;
+
+/**
+ * Wiki-link pre-pass per decision 12C.
+ * Matches [[some-file.md]] and rewrites to [some-file.md](maestro://file/some-file.md)
+ * so the standard mdast link node picks it up. Runs BEFORE mdast parsing.
+ */
+function preprocessWikiLinks(md: string): string {
+ // Match [[anything except ]]] - capture the inner text
+ return md.replace(/\[\[([^\]]+)\]\]/g, (_, text) => {
+ const encoded = encodeURIComponent(text);
+ return `[${text}](maestro://file/${encoded})`;
+ });
+}
+
+/**
+ * Convert single newlines to hard breaks (two trailing spaces) so they render
+ * the same way they appear during streaming. Skips fenced code blocks.
+ */
+function preserveNewlines(md: string): string {
+ return md.replace(/(```[\s\S]*?```)|(\n)/g, (match, codeBlock) => (codeBlock ? match : ' \n'));
+}
+
+export function ChatMarkdown({ children }: { children: string }) {
+ const { showToast } = useToast();
+ const { accentColor } = useAccent();
+ const [text, text2, border, bg2, bg3, fill3, _link] = useCSSVariable(
+ VAR_NAMES as unknown as string[]
+ ) as string[];
+
+ // Use accent color from Maestro theme for links (per decision 5C)
+ const linkColor = accentColor;
+ // Wiki-link color matches the accent
+ const wikiLinkColor = accentColor;
+
+ const isWeb = process.env.EXPO_OS === 'web';
+ const baseFontSize = isWeb ? 13 : 16;
+ const baseLineHeight = isWeb ? 21.5 : 22;
+
+ // Only overrides — defaults from utils.ts are merged automatically
+ // Heading sizes: scaled for mobile readability (base 16pt)
+ const markdownStyles = {
+ heading1: {
+ fontSize: 24,
+ lineHeight: 32,
+ fontWeight: 'bold' as const,
+ color: text,
+ marginVertical: 12,
+ },
+ heading2: {
+ fontSize: 20,
+ lineHeight: 28,
+ fontWeight: '600' as const,
+ color: text,
+ marginVertical: 10,
+ },
+ heading3: {
+ fontSize: 18,
+ lineHeight: 26,
+ fontWeight: '600' as const,
+ color: text,
+ marginVertical: 8,
+ },
+ heading4: {
+ fontSize: 16,
+ lineHeight: 24,
+ fontWeight: '600' as const,
+ color: text,
+ marginVertical: 6,
+ },
+ heading5: {
+ fontSize: 14,
+ lineHeight: 22,
+ fontWeight: '600' as const,
+ color: text,
+ marginVertical: 4,
+ },
+ heading6: {
+ fontSize: 12,
+ lineHeight: 20,
+ fontWeight: '600' as const,
+ color: text,
+ marginVertical: 4,
+ },
+ paragraph: { fontSize: baseFontSize, lineHeight: baseLineHeight, marginVertical: 8 },
+ text: { color: text, fontSize: baseFontSize, lineHeight: baseLineHeight },
+ thematicBreak: { backgroundColor: border },
+ blockquote: { backgroundColor: bg3, borderColor: border, paddingHorizontal: 8 },
+ codeContainer: { backgroundColor: fill3, padding: 12, borderRadius: 8 },
+ codeText: {
+ fontSize: isWeb ? 12 : 14,
+ color: text,
+ fontFamily: Platform.select({ ios: 'ui-monospace', default: 'monospace' }),
+ },
+ inlineCode: {
+ fontFamily: Platform.select({ ios: 'ui-monospace', default: 'monospace' }),
+ paddingHorizontal: 4,
+ fontSize: isWeb ? 12 : 15,
+ color: text,
+ overflow: 'hidden' as const,
+ borderRadius: 4,
+ backgroundColor: fill3,
+ },
+ link: { fontSize: baseFontSize, color: linkColor },
+ image: { height: 200, aspectRatio: 16 / 9, backgroundColor: fill3, borderRadius: 8 },
+ listBullet: { color: text2, fontVariant: ['tabular-nums' as const], marginRight: 8 },
+ table: { borderColor: border, borderRadius: 8 },
+ tableRow: { borderBottomColor: border },
+ tableHeaderRow: { backgroundColor: bg2 },
+ tableCell: { padding: 10, borderRightColor: border },
+ tableHeaderCell: { backgroundColor: bg2 },
+ tableCellText: { color: text },
+ tableHeaderCellText: { color: text },
+ };
+
+ // Handle link press with wiki-link detection
+ const handleLinkPress = useCallback(
+ (url: string) => {
+ // Wiki-link: maestro://file/
+ if (url.startsWith('maestro://file/')) {
+ showToast({
+ message: 'File preview not available in mobile app',
+ color: 'theme',
+ duration: 2500,
+ });
+ return;
+ }
+
+ // Regular link handling
+ if (process.env.EXPO_OS === 'web') {
+ Linking.openURL(url);
+ } else {
+ WebBrowser.openBrowserAsync(url, {
+ presentationStyle: WebBrowser.WebBrowserPresentationStyle.AUTOMATIC,
+ });
+ }
+ },
+ [showToast]
+ );
+
+ // Apply wiki-link pre-pass before other preprocessing
+ const processedMarkdown = preserveNewlines(preprocessWikiLinks(children));
+
+ return (
+ (
+
+ {extras?.customListStyleType ? (
+ extras.customListStyleType
+ ) : (
+
+ {extras?.listStyleType}
+
+ )}
+ {children}
+
+ ),
+ // Wiki-link renderer: detect maestro://file/ URLs and style distinctly
+ link: ({ node, styles, children, extras }) => {
+ const isWikiLink = node.url?.startsWith('maestro://file/');
+ const onPress = () => extras?.onPress?.(node.url) ?? Linking.openURL(node.url);
+
+ if (isWikiLink) {
+ // Wiki-links render with file icon and accent color
+ // Using View wrapper for icon + text since RN Text doesn't support icon children well
+ return (
+
+
+
+ {children}
+
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+ },
+ }}
+ markdown={processedMarkdown}
+ />
+ );
+}
+
+const fullStyles = StyleSheet.create({
+ orderedBullet: {
+ fontFamily: Platform.select({ ios: 'ui-monospace', default: 'monospace' }),
+ fontWeight: 'normal',
+ },
+ unorderedBullet: {
+ fontSize: 18,
+ fontWeight: '900',
+ },
+});
+
+// Wiki-link specific styles per decision 12C
+const wikiLinkStyles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+});
diff --git a/apps/mobile/src/components/markdown/code-block.tsx b/apps/mobile/src/components/markdown/code-block.tsx
new file mode 100644
index 0000000000..0188496419
--- /dev/null
+++ b/apps/mobile/src/components/markdown/code-block.tsx
@@ -0,0 +1,174 @@
+import transform, { type StyleTuple } from 'css-to-react-native';
+import React, { memo, useCallback, useMemo, type CSSProperties, type ReactNode } from 'react';
+import {
+ Platform,
+ ScrollView,
+ StyleSheet,
+ Text,
+ useColorScheme,
+ View,
+ type TextStyle,
+} from 'react-native';
+import SyntaxHighlighter from 'react-syntax-highlighter';
+import { githubGist, irBlack } from 'react-syntax-highlighter/dist/esm/styles/hljs';
+
+type HighlighterStyleSheet = { [key: string]: TextStyle };
+type ReactStyle = { [key: string]: CSSProperties };
+
+interface RendererNode {
+ children?: RendererNode[];
+ properties?: {
+ className?: string[];
+ };
+ tagName?: string;
+ value?: string;
+}
+
+const ALLOWED_STYLE_PROPERTIES: Record = {
+ color: true,
+ background: true,
+ backgroundColor: true,
+ fontWeight: true,
+ fontStyle: true,
+};
+
+const cleanStyle = (style: CSSProperties) => {
+ const styles = Object.entries(style)
+ .filter(([key]) => ALLOWED_STYLE_PROPERTIES[key])
+ .map(([key, value]) => [key, String(value)]);
+ return transform(styles);
+};
+
+const getRNStylesFromHljsStyle = (hljsStyle: ReactStyle): HighlighterStyleSheet => {
+ return Object.fromEntries(
+ Object.entries(hljsStyle).map(([className, style]) => [className, cleanStyle(style)])
+ );
+};
+
+function trimNewlines(string: string): string {
+ let start = 0;
+ let end = string.length;
+ while (start < end && (string[start] === '\r' || string[start] === '\n')) {
+ start++;
+ }
+ while (end > start && (string[end - 1] === '\r' || string[end - 1] === '\n')) {
+ end--;
+ }
+ return start > 0 || end < string.length ? string.slice(start, end) : string;
+}
+
+// Pre-compute stylesheets for both themes
+const darkStylesheet = getRNStylesFromHljsStyle(irBlack as ReactStyle);
+const lightStylesheet = getRNStylesFromHljsStyle(githubGist as ReactStyle);
+
+interface CodeBlockProps {
+ code: string;
+ language?: string;
+}
+
+export const CodeBlock = memo(function CodeBlock({ code, language }: CodeBlockProps) {
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === 'dark';
+
+ const stylesheet = isDark ? darkStylesheet : lightStylesheet;
+
+ const baseStyle = useMemo(
+ () =>
+ StyleSheet.flatten([
+ styles.text,
+ { color: stylesheet.hljs?.color || (isDark ? '#f8f8f2' : '#333') },
+ ]),
+ [stylesheet, isDark]
+ );
+
+ const containerStyle = useMemo(
+ () => [styles.container, { backgroundColor: isDark ? '#1a1a1a' : '#f6f8fa' }],
+ [isDark]
+ );
+
+ const getStylesForNode = useCallback(
+ (node: RendererNode): TextStyle[] => {
+ const classes: string[] = node.properties?.className ?? [];
+ return classes.map((c: string) => stylesheet[c]).filter((c) => !!c) as TextStyle[];
+ },
+ [stylesheet]
+ );
+
+ const renderNodeChildren = useCallback(
+ (nodes: RendererNode[], keyPrefix = 'row'): ReactNode[] => {
+ return nodes.reduce((acc, node, index) => {
+ const keyPrefixWithIndex = `${keyPrefix}_${index}`;
+ if (node.children) {
+ const nodeStyles = getStylesForNode(node);
+ const textStyles = nodeStyles.length > 0 ? nodeStyles : undefined;
+ acc.push(
+
+ {renderNodeChildren(node.children, `${keyPrefixWithIndex}_child`)}
+
+ );
+ }
+ if (node.value) {
+ acc.push(trimNewlines(String(node.value)));
+ }
+ return acc;
+ }, []);
+ },
+ [getStylesForNode]
+ );
+
+ const renderer = useCallback(
+ (props: any) => {
+ const { rows } = props;
+ return (
+
+
+ {rows.map((row: RendererNode, index: number) => (
+
+ {renderNodeChildren(row.children || [], `row_${index}`)}
+
+ ))}
+
+
+ );
+ },
+ [renderNodeChildren, baseStyle]
+ );
+
+ return (
+
+
+ {code}
+
+
+ );
+});
+
+const styles = StyleSheet.create({
+ container: {
+ borderRadius: 8,
+ marginVertical: 4,
+ overflow: 'hidden',
+ },
+ scrollContent: {
+ minWidth: '100%',
+ },
+ codeContent: {
+ padding: 12,
+ },
+ text: {
+ fontSize: 14,
+ lineHeight: 20,
+ fontFamily: Platform.select({ ios: 'monospace-ui', default: 'monospace' }),
+ },
+});
diff --git a/apps/mobile/src/components/markdown/index.ts b/apps/mobile/src/components/markdown/index.ts
new file mode 100644
index 0000000000..b41f723e2c
--- /dev/null
+++ b/apps/mobile/src/components/markdown/index.ts
@@ -0,0 +1,3 @@
+export { default as Markdown } from './markdown';
+export { ChatMarkdown } from './chat-markdown';
+export type { MarkdownProps, RenderRules, StyleMap } from './types';
diff --git a/apps/mobile/src/components/markdown/markdown.tsx b/apps/mobile/src/components/markdown/markdown.tsx
new file mode 100644
index 0000000000..949df467f7
--- /dev/null
+++ b/apps/mobile/src/components/markdown/markdown.tsx
@@ -0,0 +1,46 @@
+import { memo } from 'react';
+import { fromMarkdown } from 'mdast-util-from-markdown';
+import { gfmTable } from 'micromark-extension-gfm-table';
+import { gfmTableFromMarkdown } from 'mdast-util-gfm-table';
+import ASTRenderer from './ast-renderer';
+import type { MarkdownProps } from './types';
+import { getKeyFromMarkdown, resolveReference } from './utils';
+
+const Markdown = memo(
+ ({
+ markdown,
+ debug,
+ renderRules,
+ listBulletStyle,
+ styles,
+ mergeStyle,
+ customBulletElement,
+ onLinkPress,
+ extensions = [],
+ }: MarkdownProps) => {
+ const tree = fromMarkdown(markdown, {
+ extensions: [gfmTable()],
+ mdastExtensions: [
+ gfmTableFromMarkdown(),
+ resolveReference(),
+ getKeyFromMarkdown(),
+ ...extensions,
+ ],
+ });
+
+ const renderer = new ASTRenderer({
+ renderRules,
+ debug,
+ styles,
+ mergeStyle,
+ listBulletStyle,
+ customBulletElement,
+ onLinkPress,
+ });
+
+ return renderer.render(tree);
+ }
+);
+
+Markdown.displayName = 'Markdown';
+export default Markdown;
diff --git a/apps/mobile/src/components/markdown/render-rules.tsx b/apps/mobile/src/components/markdown/render-rules.tsx
new file mode 100644
index 0000000000..812b6b5192
--- /dev/null
+++ b/apps/mobile/src/components/markdown/render-rules.tsx
@@ -0,0 +1,239 @@
+import { useState, useCallback, memo } from 'react';
+import {
+ View,
+ Text,
+ Linking,
+ type ViewStyle,
+ type TextStyle,
+ type ImageStyle,
+ StyleSheet,
+} from 'react-native';
+import { Image as ExpoImage, type ImageErrorEventData } from 'expo-image';
+import { ImageOff } from 'lucide-react-native';
+import type { RenderRules, StyleMap } from './types';
+import { CodeBlock } from './code-block';
+
+// Helper to safely get styles with proper casting
+const getViewStyle = (styles: StyleMap, key: string): ViewStyle | undefined =>
+ styles[key] as ViewStyle | undefined;
+const getTextStyle = (styles: StyleMap, key: string): TextStyle | undefined =>
+ styles[key] as TextStyle | undefined;
+
+// Image component with error placeholder using expo-image
+interface MarkdownImageProps {
+ url: string;
+ alt?: string;
+ style?: ImageStyle;
+}
+
+const MarkdownImage = memo(function MarkdownImage({ url, alt, style }: MarkdownImageProps) {
+ const [hasError, setHasError] = useState(false);
+
+ const handleError = useCallback(
+ (event: ImageErrorEventData) => {
+ console.warn('Markdown image failed to load:', url, event.error);
+ setHasError(true);
+ },
+ [url]
+ );
+
+ if (hasError) {
+ return (
+
+
+ {alt && {alt}}
+
+ );
+ }
+
+ return (
+
+ );
+});
+
+const imageStyles = StyleSheet.create({
+ placeholder: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: '#f3f4f6',
+ borderRadius: 8,
+ },
+ placeholderText: {
+ marginTop: 8,
+ fontSize: 12,
+ color: '#9ca3af',
+ textAlign: 'center',
+ },
+});
+
+const renderRules: RenderRules = {
+ root: ({ node, styles, children }) => (
+
+ {children}
+
+ ),
+ paragraph: ({ node, styles, children, parentStack }) => {
+ const inListItem = parentStack.some((p) => p.type === 'listItem');
+ return (
+
+ {children}
+
+ );
+ },
+ strong: ({ node, styles, children }) => (
+
+ {children}
+
+ ),
+ emphasis: ({ node, styles, children }) => (
+
+ {children}
+
+ ),
+ delete: ({ node, styles, children }) => (
+
+ {children}
+
+ ),
+ text: ({ node, styles }) => (
+
+ {node.value}
+
+ ),
+ blockquote: ({ node, styles, children }) => (
+
+ {children}
+
+ ),
+ break: ({ node, styles }) => (
+
+ {'\n'}
+
+ ),
+ thematicBreak: ({ node, styles }) => (
+
+ ),
+ code: ({ node }) => (
+
+ ),
+ inlineCode: ({ node, styles }) => (
+
+ {node.value}
+
+ ),
+ image: ({ node, styles }) => (
+
+ ),
+ link: ({ node, styles, children, extras }) => {
+ const onPress = () => extras?.onPress(node.url) || (() => Linking.openURL(node.url));
+ return (
+
+ {children}
+
+ );
+ },
+ list: ({ node, styles, children }) => (
+
+ {children}
+
+ ),
+ listItem: ({ node, styles, children, extras }) => (
+
+ {extras?.customListStyleType ? (
+ extras.customListStyleType
+ ) : (
+ {extras?.listStyleType}
+ )}
+ {children}
+
+ ),
+ table: ({ node, styles, children }) => (
+
+ {children}
+
+ ),
+ tableRow: ({ node, styles, children, extras }) => (
+
+ {children}
+
+ ),
+ tableCell: ({ node, styles, children, extras }) => (
+
+
+ {children}
+
+
+ ),
+ heading: ({ node, styles, children }) => (
+
+ {children}
+
+ ),
+ // Math nodes (inlineMath, displayMath) fall through as raw text per v1 scope
+ // These are not rendered with KaTeX/MathJax - just shown as the raw LaTeX string
+ // Cast to RenderRules since inlineMath/math are mdast extensions not in base types
+ ...({
+ inlineMath: ({ node, styles }: { node: any; styles: StyleMap }) => (
+
+ {node.value || ''}
+
+ ),
+ math: ({ node, styles }: { node: any; styles: StyleMap }) => (
+
+ {node.value || ''}
+
+ ),
+ } as RenderRules),
+ unknown: ({ node, styles }) => {
+ // For unknown nodes with a value (like custom extensions), render as text
+ // rather than dropping them silently
+ const value = (node as any).value;
+ if (typeof value === 'string' && value.length > 0) {
+ console.warn(`Unknown node type with value: ${node.type}`);
+ return (
+
+ {value}
+
+ );
+ }
+ console.warn('Unknown node type encountered', node.type);
+ return null;
+ },
+};
+
+export default renderRules;
diff --git a/apps/mobile/src/components/markdown/types.ts b/apps/mobile/src/components/markdown/types.ts
new file mode 100644
index 0000000000..85d38a4139
--- /dev/null
+++ b/apps/mobile/src/components/markdown/types.ts
@@ -0,0 +1,65 @@
+import type { ImageStyle, StyleProp, TextStyle, ViewStyle } from 'react-native';
+import type { Node, Root, RootContentMap } from 'mdast';
+import type { Extension } from 'mdast-util-from-markdown';
+import type { ReactElement } from 'react';
+
+// Style types
+type NamedStyle = StyleProp;
+export type StyleMap = Record;
+
+// Utility types
+type ExpandUnion = T extends infer U ? U : never;
+type Prettify = { [K in keyof T]: T[K] } & {};
+
+// Node types
+type BaseNodeKeys = {
+ [K in keyof RootContentMap]: RootContentMap[K] extends Node ? K : never;
+}[keyof RootContentMap];
+
+export type ValidNodeKey = ExpandUnion;
+
+export type NodeTypeMap = Prettify<
+ {
+ [K in BaseNodeKeys]: RootContentMap[K];
+ } & {
+ unknown: Node;
+ root: Root;
+ }
+>;
+
+// Render function types
+export type RenderFunction = (params: {
+ node: NodeTypeMap[K];
+ styles: StyleMap;
+ children: any[];
+ parentStack: Node[];
+ extras?: any;
+}) => any;
+
+export type RenderRules = {
+ [K in ValidNodeKey]?: RenderFunction;
+};
+
+export type ListBulletStyle = 'disc' | 'dash';
+
+export interface ASTRendererOptions {
+ renderRules?: RenderRules;
+ styles?: StyleMap | null;
+ mergeStyle?: boolean;
+ debug?: boolean;
+ listBulletStyle?: ListBulletStyle;
+ customBulletElement?: ReactElement | null;
+ onLinkPress?: (url: string) => void;
+}
+
+export interface MarkdownProps extends ASTRendererOptions {
+ markdown: Uint8Array | string;
+ extensions?: Extension[];
+}
+
+// Extend mdast Node to include key property
+declare module 'mdast' {
+ interface Node {
+ key?: string;
+ }
+}
diff --git a/apps/mobile/src/components/markdown/utils.ts b/apps/mobile/src/components/markdown/utils.ts
new file mode 100644
index 0000000000..ba878247dc
--- /dev/null
+++ b/apps/mobile/src/components/markdown/utils.ts
@@ -0,0 +1,294 @@
+import type { Node, Parent } from 'mdast';
+import type { Extension } from 'mdast-util-from-markdown';
+import { Platform, StyleSheet, type TextStyle, type ViewStyle } from 'react-native';
+import type { StyleMap } from './types';
+
+const defaultStyles: StyleMap = {
+ root: {},
+ heading1: {
+ fontSize: 48,
+ fontWeight: 'bold',
+ },
+ heading2: {
+ fontSize: 36,
+ fontWeight: '600',
+ },
+ heading3: {
+ fontSize: 32,
+ fontWeight: '600',
+ },
+ heading4: {
+ fontSize: 28,
+ fontWeight: '600',
+ },
+ heading5: {
+ fontSize: 24,
+ fontWeight: '600',
+ },
+ heading6: {
+ fontSize: 20,
+ fontWeight: '600',
+ },
+ paragraph: {
+ marginVertical: 8,
+ flexWrap: 'wrap',
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ justifyContent: 'flex-start',
+ width: '100%',
+ },
+ strong: {
+ fontWeight: 'bold',
+ },
+ emphasis: {
+ fontStyle: 'italic',
+ },
+ text: {},
+ thematicBreak: {
+ flex: 1,
+ height: 1,
+ backgroundColor: '#0000006c',
+ marginVertical: 8,
+ },
+ blockquote: {
+ backgroundColor: '#f5f5f5',
+ borderColor: '#3840ba',
+ borderLeftWidth: 4,
+ paddingHorizontal: 5,
+ marginVertical: 8,
+ },
+ codeContainer: {
+ backgroundColor: '#f0f0f0',
+ padding: 8,
+ borderRadius: 4,
+ marginVertical: 4,
+ },
+ codeText: {
+ color: '#1c1c1c',
+ fontSize: 14,
+ ...Platform.select({
+ ios: {
+ fontFamily: 'ui-monospace',
+ },
+ android: {
+ fontFamily: 'monospace',
+ },
+ }),
+ },
+ inlineCode: {
+ ...Platform.select({
+ ios: {
+ fontFamily: 'ui-monospace',
+ },
+ android: {
+ fontFamily: 'monospace',
+ },
+ }),
+ backgroundColor: '#f0f0f0',
+ },
+ link: {
+ transform: [{ translateY: 2 }],
+ fontSize: 16,
+ color: '#1e90ff',
+ textDecorationLine: 'underline',
+ },
+ image: {
+ width: '100%',
+ height: 300,
+ aspectRatio: 1,
+ resizeMode: 'cover',
+ backgroundColor: '#f0f0f0',
+ overflow: 'hidden',
+ },
+ list: {
+ flex: 1,
+ },
+ listItem: {
+ flexDirection: 'row',
+ alignItems: 'baseline',
+ justifyContent: 'flex-start',
+ gap: 8,
+ },
+ listBullet: {
+ fontSize: 16,
+ fontWeight: '900',
+ },
+ listItemContent: {
+ flex: 1,
+ flexWrap: 'wrap',
+ },
+ delete: {
+ textDecorationLine: 'line-through',
+ },
+ // Table styles
+ table: {
+ marginVertical: 8,
+ borderWidth: 1,
+ borderColor: '#ddd',
+ borderRadius: 4,
+ overflow: 'hidden',
+ },
+ tableRow: {
+ flexDirection: 'row',
+ borderBottomWidth: 1,
+ borderBottomColor: '#ddd',
+ },
+ tableHeaderRow: {
+ backgroundColor: '#f5f5f5',
+ },
+ tableCell: {
+ flex: 1,
+ padding: 8,
+ borderRightWidth: 1,
+ borderRightColor: '#ddd',
+ },
+ tableHeaderCell: {
+ backgroundColor: '#f5f5f5',
+ },
+ tableCellText: {
+ fontSize: 14,
+ },
+ tableHeaderCellText: {
+ fontWeight: '600',
+ },
+};
+
+// Remove text-only style props for View-safe styles
+type TextOnlyProps = Omit;
+
+function removeTextStyleProps(style: T): ViewStyle {
+ const textOnlyKeys: (keyof TextOnlyProps)[] = [
+ 'color',
+ 'fontFamily',
+ 'fontSize',
+ 'fontStyle',
+ 'fontWeight',
+ 'letterSpacing',
+ 'lineHeight',
+ 'textAlign',
+ 'textDecorationLine',
+ 'textDecorationStyle',
+ 'textDecorationColor',
+ 'textShadowColor',
+ 'textShadowOffset',
+ 'textShadowRadius',
+ 'textTransform',
+ 'includeFontPadding',
+ 'textAlignVertical',
+ 'fontVariant',
+ 'writingDirection',
+ ];
+ const result = { ...style };
+ textOnlyKeys.forEach((key) => {
+ delete (result as any)[key];
+ });
+ return result as ViewStyle;
+}
+
+export function getMergedStyles(styles: StyleMap | null = null, merge = false): StyleMap {
+ const output: Record = {};
+
+ const allKeys = new Set([...Object.keys(defaultStyles), ...(styles ? Object.keys(styles) : [])]);
+
+ for (const key of allKeys) {
+ const base = StyleSheet.flatten(defaultStyles[key] as any) ?? {};
+ const custom = StyleSheet.flatten(styles?.[key] as any) ?? {};
+
+ const final = merge ? { ...base, ...custom } : styles?.[key] ? custom : base;
+
+ output[key] = final;
+ output[`_VIEW_SAFE_${key}`] = removeTextStyleProps(final as any);
+ }
+
+ return StyleSheet.create(output);
+}
+
+// Generate unique keys for nodes based on position
+function getKey(node: Node): string {
+ const { start, end } = node.position ?? {};
+ if (start && end) {
+ return `${node.type}-${start.line}:${start.column}-${end.line}:${end.column}`;
+ }
+ return `${node.type}-${Math.random().toString(16).slice(2, 8)}`;
+}
+
+function addKeysRecursively(node: Node): void {
+ if (node.position) {
+ node.key = getKey(node);
+ }
+ if ('children' in node && Array.isArray((node as Parent).children)) {
+ for (const child of (node as Parent).children) {
+ addKeysRecursively(child);
+ }
+ }
+}
+
+export function getKeyFromMarkdown(): Extension {
+ return {
+ transforms: [
+ (tree) => {
+ addKeysRecursively(tree);
+ },
+ ],
+ };
+}
+
+// Resolve link and image references
+import type { Definition, Root, RootContent } from 'mdast';
+
+function normalizeIdentifier(id: string): string {
+ return id.trim().toLowerCase();
+}
+
+const transform = (node: RootContent | Root, definitions: Map): void => {
+ if (node.type === 'linkReference' || node.type === 'imageReference') {
+ const def = definitions.get(normalizeIdentifier(node.identifier));
+ if (!def) return;
+
+ if (node.type === 'linkReference') {
+ const linkNode: any = node;
+ linkNode.type = 'link';
+ linkNode.url = def.url;
+ linkNode.title = def.title ?? null;
+ }
+
+ if (node.type === 'imageReference') {
+ const imageNode: any = node;
+ imageNode.type = 'image';
+ imageNode.url = def.url;
+ imageNode.title = def.title ?? null;
+ }
+ }
+
+ if ('children' in node && Array.isArray(node.children)) {
+ for (const child of node.children) {
+ transform(child, definitions);
+ }
+ }
+};
+
+export function resolveReference(): Extension {
+ return {
+ transforms: [
+ (tree) => {
+ const definitions = new Map();
+ const definitionIndices: number[] = [];
+
+ for (const node of tree.children) {
+ if (node.type === 'definition') {
+ definitions.set(normalizeIdentifier(node.identifier), node);
+ definitionIndices.push(tree.children.indexOf(node));
+ }
+ }
+
+ if (definitions.size === 0) return;
+
+ transform(tree, definitions);
+
+ for (const index of definitionIndices.reverse()) {
+ tree.children.splice(index, 1);
+ }
+ },
+ ],
+ };
+}
diff --git a/apps/mobile/src/components/model-context.tsx b/apps/mobile/src/components/model-context.tsx
new file mode 100644
index 0000000000..1d0b18f95b
--- /dev/null
+++ b/apps/mobile/src/components/model-context.tsx
@@ -0,0 +1,41 @@
+import React, { createContext, use, useState } from 'react';
+
+export type Model = {
+ id: string;
+ label: string;
+ subtitle?: string;
+};
+
+type ModelContextValue = {
+ models: readonly Model[];
+ selectedModel: string;
+ extendedThinking: boolean;
+ setExtendedThinking: (value: boolean) => void;
+};
+
+const ModelContext = createContext(null);
+
+export function ModelProvider({
+ children,
+ models,
+}: {
+ children: React.ReactNode;
+ models: readonly Model[];
+}) {
+ const [extendedThinking, setExtendedThinking] = useState(true);
+ const selectedModel = 'sonnet-4.6';
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useModel() {
+ const context = use(ModelContext);
+ if (!context) {
+ throw new Error('useModel must be used within a ModelProvider');
+ }
+ return context;
+}
diff --git a/apps/mobile/src/components/sidebar.tsx b/apps/mobile/src/components/sidebar.tsx
new file mode 100644
index 0000000000..30cb0ac1b4
--- /dev/null
+++ b/apps/mobile/src/components/sidebar.tsx
@@ -0,0 +1,15 @@
+// Native fallback — the sidebar is only used on web.
+// On native, the Drawer navigator in _layout.tsx handles navigation.
+
+export function Sidebar(_props: {
+ isOpen: boolean;
+ onToggle: () => void;
+ isCollapsed: boolean;
+ onCollapse: () => void;
+}) {
+ return null;
+}
+
+export function SidebarToggle(_props: { onPress: () => void }) {
+ return null;
+}
diff --git a/apps/mobile/src/components/sidebar.web.tsx b/apps/mobile/src/components/sidebar.web.tsx
new file mode 100644
index 0000000000..aae6ca096d
--- /dev/null
+++ b/apps/mobile/src/components/sidebar.web.tsx
@@ -0,0 +1,312 @@
+import { MOCK_CHATS } from '@/utils/mock-chats';
+import * as ContextMenu from '@radix-ui/react-context-menu';
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
+import * as Tooltip from '@radix-ui/react-tooltip';
+import { Link, usePathname } from 'expo-router';
+import {
+ Archive,
+ Edit3,
+ LogOut,
+ MessageSquarePlus,
+ PanelLeft,
+ PanelLeftOpen,
+ Pin,
+ Settings,
+ Share,
+ SquarePen,
+ Trash2,
+ User,
+} from 'lucide-react';
+import type { ReactNode } from 'react';
+import { Pressable, ScrollView, Text, View } from 'react-native';
+
+const MENU_CONTENT_CLASS =
+ 'z-[100] min-w-[180px] rounded-xl bg-card p-1.5 shadow-float border border-border/40 animate-fade-up';
+
+const MENU_ITEM_CLASS =
+ 'flex cursor-default select-none items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13px] text-foreground outline-none data-[highlighted]:bg-accent';
+
+const MENU_SEPARATOR_CLASS = 'my-1 h-px bg-border/40';
+
+const MENU_DESTRUCTIVE_CLASS =
+ 'flex cursor-default select-none items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13px] text-red-500 outline-none data-[highlighted]:bg-red-500/10';
+
+function SidebarTooltip({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+
+ {label}
+
+
+
+
+ );
+}
+
+const NAV_ITEMS = [
+ { href: '/', label: 'Chats' },
+ { href: '/settings', label: 'Settings' },
+] as const;
+
+/**
+ * Sidebar matching the native drawer content layout:
+ * - Bold "Chat" title
+ * - Nav items (Chats, Settings)
+ * - Scrollable "Recents" section with mock chat history
+ * - Footer with user avatar + new chat button
+ *
+ * Collapses on desktop, slides on mobile.
+ */
+export function Sidebar({
+ isOpen,
+ onToggle,
+ isCollapsed,
+ onCollapse,
+}: {
+ isOpen: boolean;
+ onToggle: () => void;
+ isCollapsed: boolean;
+ onCollapse: () => void;
+}) {
+ const pathname = usePathname();
+
+ return (
+ <>
+ {/* Mobile overlay */}
+
+
+ {/* Sidebar */}
+
+ {/* Header */}
+ {!isCollapsed && (
+
+
+ Chat
+
+ {/* Close button on mobile */}
+
+ ✕
+
+ {/* Collapse button on desktop */}
+
+
+
+
+
+
+ )}
+
+ {/* Nav + Chat history */}
+ {!isCollapsed && (
+
+ {/* Nav items */}
+ {NAV_ITEMS.map((item) => {
+ const isActive = pathname === item.href;
+ return (
+
+
+
+ {item.label}
+
+
+
+ );
+ })}
+
+ {/* Recents */}
+
+ Recents
+
+ {MOCK_CHATS.map((chat) => {
+ const isActive = chat.id === '1';
+ return (
+
+
+
+
+
+ {chat.title}
+
+
+
+
+
+
+
+
+ Pin chat
+
+
+
+ Rename
+
+
+
+ Share
+
+
+
+ Archive
+
+
+
+
+ Delete
+
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* Collapsed icon rail */}
+ {isCollapsed && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Spacer when collapsed */}
+ {isCollapsed && }
+
+ {/* Footer */}
+ {!isCollapsed && (
+
+
+
+
+
+
+ EB
+
+ Evan Bacon
+
+
+
+
+
+
+ Profile
+
+
+
+ Settings
+
+
+
+
+ Sign out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ >
+ );
+}
+
+export function SidebarToggle({ onPress }: { onPress: () => void }) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/symbol-image.tsx b/apps/mobile/src/components/symbol-image.tsx
new file mode 100644
index 0000000000..ca0e54b8aa
--- /dev/null
+++ b/apps/mobile/src/components/symbol-image.tsx
@@ -0,0 +1,60 @@
+import { Image as ExpoImage, ImageProps, type ImageStyle } from 'expo-image';
+import {
+ ArrowUp,
+ ChevronDown,
+ HelpCircle,
+ MessageSquare,
+ Plus,
+ type LucideIcon,
+} from 'lucide-react-native';
+
+import { withUniwind } from 'uniwind';
+
+const Image = withUniwind(ExpoImage);
+
+/**
+ * Map of SF Symbol names to Lucide icons for Android/web fallback.
+ */
+const LUCIDE_FALLBACKS: Record = {
+ 'arrow.up': ArrowUp,
+ 'chevron.down': ChevronDown,
+ 'bubble.left.and.bubble.right': MessageSquare,
+ plus: Plus,
+};
+
+type SymbolImageProps = {
+ /** SF Symbol name (e.g. "arrow.up", "chevron.down") */
+ name: string;
+ size?: number;
+ tintColor?: string;
+ style?: ImageStyle;
+ className?: string;
+ sfEffect?: ImageProps['sfEffect'];
+ transition?: ImageProps['transition'];
+};
+
+export function SymbolImage({
+ name,
+ size = 24,
+ tintColor,
+ style,
+ className,
+ sfEffect,
+ transition,
+}: SymbolImageProps) {
+ if (process.env.EXPO_OS === 'ios') {
+ return (
+
+ );
+ }
+
+ const Icon = LUCIDE_FALLBACKS[name] ?? HelpCircle;
+ return ;
+}
diff --git a/apps/mobile/src/components/touchable-glass.tsx b/apps/mobile/src/components/touchable-glass.tsx
new file mode 100644
index 0000000000..842024e787
--- /dev/null
+++ b/apps/mobile/src/components/touchable-glass.tsx
@@ -0,0 +1,122 @@
+import { isLiquidGlassAvailable } from 'expo-glass-effect';
+import React, { useState } from 'react';
+import { TouchableWithoutFeedback, type ViewProps } from 'react-native';
+import { Gesture, GestureDetector } from 'react-native-gesture-handler';
+import Animated from 'react-native-reanimated';
+import { scheduleOnRN } from 'react-native-worklets';
+import { BlurViewRawBackdrop } from './blur-raw';
+import { AppleGlassView } from './tw';
+
+type GlassViewProps = React.ComponentProps;
+
+type TouchableGlassProps = GlassViewProps & {
+ onPress?: () => void;
+ onPressIn?: () => void;
+ onPressOut?: () => void;
+ disabled?: boolean;
+};
+
+type ViewOnlyProps = ViewProps & { className?: string };
+
+function TouchableGlassNative({
+ onPress,
+ onPressIn,
+ onPressOut,
+ disabled,
+ ref,
+ ...rest
+}: TouchableGlassProps) {
+ const tap = Gesture.Tap()
+ .enabled(!disabled)
+ .onBegin(() => {
+ if (onPressIn) scheduleOnRN(onPressIn);
+ })
+ .onEnd((_e, success) => {
+ if (success && onPress) scheduleOnRN(onPress);
+ })
+ .onFinalize(() => {
+ if (onPressOut) scheduleOnRN(onPressOut);
+ });
+
+ // TODO: Add iOS 18 bounce effect on blur.
+ return (
+
+
+
+ );
+}
+
+function TouchableGlassFallback({
+ onPress,
+ onPressIn,
+ onPressOut,
+ ref,
+ disabled,
+
+ children,
+ style,
+ className,
+ ...rest
+}: TouchableGlassProps) {
+ // Pick only View-compatible props, stripping glass-specific ones
+ const {
+ fallbackTint,
+ fallbackIntensity,
+ glassEffectStyle,
+ tintColor,
+ isInteractive,
+ colorScheme,
+ animatedProps,
+ ...viewProps
+ } = rest as Record;
+ const safeViewProps = viewProps as ViewOnlyProps;
+ const [pressed, setPressed] = useState(false);
+ const onTouchBegin = () => {
+ setPressed(true);
+ onPressIn?.();
+ };
+ const onTouchEnd = () => {
+ setPressed(false);
+ onPressOut?.();
+ };
+ const onTouchEndSuccess = () => {
+ setPressed(false);
+ onPress?.();
+ onPressOut?.();
+ };
+
+ // TODO: Add iOS 18 bounce effect on blur.
+ return (
+
+
+
+ {children as React.ReactNode}
+
+
+ );
+}
+
+export const TouchableGlass = isLiquidGlassAvailable()
+ ? TouchableGlassNative
+ : TouchableGlassFallback;
diff --git a/apps/mobile/src/components/tw.tsx b/apps/mobile/src/components/tw.tsx
new file mode 100644
index 0000000000..a37f22805e
--- /dev/null
+++ b/apps/mobile/src/components/tw.tsx
@@ -0,0 +1,79 @@
+import { BlurView as EXBlurView } from 'expo-blur';
+import { GlassView as XGlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
+import { Image as XImage } from 'expo-image';
+import { StyleSheet, type ViewStyle } from 'react-native';
+import { withUniwind } from 'uniwind';
+
+import { KeyboardGestureArea as XKeyboardGestureArea } from 'react-native-keyboard-controller';
+import Animated from 'react-native-reanimated';
+import { SafeAreaView as XSafeAreaView } from 'react-native-safe-area-context';
+
+export const SafeAreaView = withUniwind(XSafeAreaView);
+
+export const Image = withUniwind(XImage);
+export const KeyboardGestureArea = withUniwind(XKeyboardGestureArea);
+
+const AnimatedEXGlassView = Animated.createAnimatedComponent(XGlassView);
+
+const BlurView = withUniwind(EXBlurView);
+
+export const InnerAppleGlassView = withUniwind(BetterGlassView);
+const GLASS_ENABLED = isLiquidGlassAvailable();
+
+type FallbackAppleGlassViewProps = React.ComponentProps & {
+ className?: string;
+ fallbackTint?: React.ComponentProps['tint'];
+ fallbackIntensity?: React.ComponentProps['intensity'];
+};
+
+const FallbackAppleGlassView = ({
+ fallbackTint,
+ fallbackIntensity,
+ children,
+ style,
+ className,
+ ...rest
+}: FallbackAppleGlassViewProps) => {
+ return (
+
+ {children as React.ReactNode}
+
+ );
+};
+
+export const AppleGlassView = GLASS_ENABLED ? InnerAppleGlassView : FallbackAppleGlassView;
+
+function BetterGlassView(props: React.ComponentProps) {
+ const { style, props: converted } = convertStylesToProps(props.style, {
+ backgroundColor: 'tintColor',
+ });
+
+ return ;
+}
+
+export const GlassView = withUniwind(XGlassView);
+
+function convertStylesToProps(
+ style: React.ComponentProps['style'],
+ move: Record
+) {
+ if (!style) {
+ return { style, props: {} as Record };
+ }
+ const flatStyle = (StyleSheet.flatten(style) || {}) as Record;
+ const props: Record = {};
+
+ for (const [styleKey, propKey] of Object.entries(move)) {
+ if (styleKey in flatStyle) {
+ props[propKey] = flatStyle[styleKey];
+ delete flatStyle[styleKey];
+ }
+ }
+
+ return { style: flatStyle, props };
+}
diff --git a/apps/mobile/src/global.css b/apps/mobile/src/global.css
new file mode 100644
index 0000000000..851fd1e760
--- /dev/null
+++ b/apps/mobile/src/global.css
@@ -0,0 +1,114 @@
+@import 'tailwindcss/theme.css' layer(theme);
+@import 'tailwindcss/preflight.css' layer(base);
+@import 'tailwindcss/utilities.css';
+
+@import 'uniwind';
+@import './sf.css';
+
+/* ─── Design Tokens ─── */
+
+:root {
+ @variant light {
+ --app-background: oklch(0.985 0 0);
+ --app-foreground: oklch(0.12 0 0);
+ --app-card: oklch(1 0 0);
+ --app-user-bubble: oklch(0.95 0.003 100);
+ --app-muted: oklch(0.955 0 0);
+ --app-muted-foreground: oklch(0.55 0 0);
+ --app-border: oklch(0.88 0 0);
+ --app-secondary: oklch(0.96 0 0);
+ --app-accent: oklch(0.94 0 0);
+ --app-sidebar: oklch(0.97 0 0);
+ --app-shadow-card: 0 1px 3px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.04);
+ --app-shadow-float: 0 4px 16px oklch(0 0 0 / 0.08), 0 1px 4px oklch(0 0 0 / 0.04);
+ --app-shadow-composer: 0 2px 8px oklch(0 0 0 / 0.06), 0 0 0 1px oklch(0 0 0 / 0.03);
+ --app-shadow-composer-focus: 0 4px 16px oklch(0 0 0 / 0.1), 0 0 0 1px oklch(0 0 0 / 0.06);
+ }
+
+ @variant dark {
+ --app-background: oklch(0.195 0 0);
+ --app-foreground: oklch(0.94 0 0);
+ --app-card: oklch(0.225 0 0);
+ --app-user-bubble: oklch(0.14 0 0);
+ --app-muted: oklch(0.27 0 0);
+ --app-muted-foreground: oklch(0.6 0 0);
+ --app-border: oklch(0.32 0 0);
+ --app-secondary: oklch(0.25 0 0);
+ --app-accent: oklch(0.28 0 0);
+ --app-sidebar: oklch(0.175 0 0);
+ --app-shadow-card: 0 1px 3px oklch(0 0 0 / 0.2), 0 1px 2px oklch(0 0 0 / 0.15);
+ --app-shadow-float: 0 4px 16px oklch(0 0 0 / 0.3), 0 1px 4px oklch(0 0 0 / 0.2);
+ --app-shadow-composer: 0 2px 8px oklch(0 0 0 / 0.2), 0 0 0 1px oklch(0 0 0 / 0.1);
+ --app-shadow-composer-focus: 0 4px 16px oklch(0 0 0 / 0.3), 0 0 0 1px oklch(0 0 0 / 0.15);
+ }
+}
+
+@theme {
+ --color-background: var(--app-background);
+ --color-foreground: var(--app-foreground);
+ --color-card: var(--app-card);
+ --color-user-bubble: var(--app-user-bubble);
+ --color-muted: var(--app-muted);
+ --color-muted-foreground: var(--app-muted-foreground);
+ --color-border: var(--app-border);
+ --color-secondary: var(--app-secondary);
+ --color-accent: var(--app-accent);
+ --color-sidebar: var(--app-sidebar);
+
+ --shadow-card: var(--app-shadow-card);
+ --shadow-float: var(--app-shadow-float);
+ --shadow-composer: var(--app-shadow-composer);
+ --shadow-composer-focus: var(--app-shadow-composer-focus);
+
+ --animate-fade-up: fade-up 0.25s cubic-bezier(0.22, 1, 0.36, 1);
+ --animate-shimmer: shimmer 2s infinite linear;
+}
+
+@keyframes fade-up {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes shimmer {
+ 0% {
+ background-position: -200% 0;
+ }
+ 100% {
+ background-position: 200% 0;
+ }
+}
+
+/* Sidebar toggle icon swap on hover */
+.sidebar-toggle-hover {
+ display: none;
+}
+.sidebar-toggle-btn:hover .sidebar-toggle-default {
+ display: none;
+}
+.sidebar-toggle-btn:hover .sidebar-toggle-hover {
+ display: flex;
+}
+
+@media android {
+ :root {
+ --font-mono: monospace;
+ --font-rounded: normal;
+ --font-serif: serif;
+ --font-sans: normal;
+ }
+}
+
+@media ios {
+ :root {
+ --font-mono: ui-monospace;
+ --font-serif: ui-serif;
+ --font-sans: system-ui;
+ --font-rounded: ui-rounded;
+ }
+}
diff --git a/apps/mobile/src/hooks/__tests__/useMaestroConnection.test.ts b/apps/mobile/src/hooks/__tests__/useMaestroConnection.test.ts
new file mode 100644
index 0000000000..0633c389f1
--- /dev/null
+++ b/apps/mobile/src/hooks/__tests__/useMaestroConnection.test.ts
@@ -0,0 +1,149 @@
+/**
+ * Tests for useMaestroConnection lifecycle state machine
+ *
+ * These tests verify the state derivation logic without requiring
+ * full React Native / Expo module resolution.
+ */
+
+// Test the state derivation logic directly
+describe('MaestroConnectionState derivation', () => {
+ // Helper to mimic the state derivation from useMaestroConnection
+ function deriveConnectionState(
+ isReconnecting: boolean,
+ wsState: 'disconnected' | 'connecting' | 'connected' | 'authenticated'
+ ): 'disconnected' | 'connecting' | 'reconnecting' | 'connected' {
+ if (isReconnecting) return 'reconnecting';
+ if (wsState === 'authenticated' || wsState === 'connected') return 'connected';
+ if (wsState === 'connecting') return 'connecting';
+ return 'disconnected';
+ }
+
+ describe('state derivation', () => {
+ it('returns "reconnecting" when isReconnecting is true', () => {
+ expect(deriveConnectionState(true, 'disconnected')).toBe('reconnecting');
+ expect(deriveConnectionState(true, 'connecting')).toBe('reconnecting');
+ expect(deriveConnectionState(true, 'authenticated')).toBe('reconnecting');
+ });
+
+ it('returns "connected" when authenticated', () => {
+ expect(deriveConnectionState(false, 'authenticated')).toBe('connected');
+ });
+
+ it('returns "connected" when connected (pre-auth)', () => {
+ expect(deriveConnectionState(false, 'connected')).toBe('connected');
+ });
+
+ it('returns "connecting" when in connecting state', () => {
+ expect(deriveConnectionState(false, 'connecting')).toBe('connecting');
+ });
+
+ it('returns "disconnected" when disconnected', () => {
+ expect(deriveConnectionState(false, 'disconnected')).toBe('disconnected');
+ });
+ });
+
+ describe('staleness threshold', () => {
+ const BACKGROUND_STALENESS_MS = 10_000;
+
+ it('marks buffer as stale after 10+ seconds in background', () => {
+ const backgroundedAt = Date.now() - 15_000;
+ const elapsed = Date.now() - backgroundedAt;
+ expect(elapsed > BACKGROUND_STALENESS_MS).toBe(true);
+ });
+
+ it('does not mark buffer as stale for short background', () => {
+ const backgroundedAt = Date.now() - 5_000;
+ const elapsed = Date.now() - backgroundedAt;
+ expect(elapsed > BACKGROUND_STALENESS_MS).toBe(false);
+ });
+
+ it('edge case: exactly at threshold is not stale', () => {
+ const backgroundedAt = Date.now() - 10_000;
+ const elapsed = Date.now() - backgroundedAt;
+ // 10s == threshold, but > means strictly greater
+ expect(elapsed > BACKGROUND_STALENESS_MS).toBe(false);
+ });
+
+ it('edge case: just over threshold is stale', () => {
+ const backgroundedAt = Date.now() - 10_001;
+ const elapsed = Date.now() - backgroundedAt;
+ expect(elapsed > BACKGROUND_STALENESS_MS).toBe(true);
+ });
+ });
+});
+
+describe('AppState transition logic', () => {
+ // Mimic the shouldBeConnected logic
+ function shouldConnect(
+ isNowForeground: boolean,
+ wasBackground: boolean,
+ wsState: 'disconnected' | 'connecting' | 'connected' | 'authenticated'
+ ): boolean {
+ // Returning to foreground and need to reconnect
+ if (isNowForeground && wasBackground && wsState === 'disconnected') {
+ return true;
+ }
+ return false;
+ }
+
+ function shouldDisconnect(isNowForeground: boolean, wasBackground: boolean): boolean {
+ // Going to background
+ return !isNowForeground && !wasBackground;
+ }
+
+ it('should connect when returning to foreground from background while disconnected', () => {
+ expect(shouldConnect(true, true, 'disconnected')).toBe(true);
+ });
+
+ it('should not connect when returning to foreground but already connected', () => {
+ expect(shouldConnect(true, true, 'authenticated')).toBe(false);
+ });
+
+ it('should not connect when staying in foreground', () => {
+ expect(shouldConnect(true, false, 'disconnected')).toBe(false);
+ });
+
+ it('should disconnect when going to background from foreground', () => {
+ expect(shouldDisconnect(false, false)).toBe(true);
+ });
+
+ it('should not disconnect when staying in background', () => {
+ expect(shouldDisconnect(false, true)).toBe(false);
+ });
+
+ it('should not disconnect when staying in foreground', () => {
+ expect(shouldDisconnect(true, false)).toBe(false);
+ });
+});
+
+describe('Network change logic', () => {
+ function shouldReconnectOnNetwork(
+ isForeground: boolean,
+ shouldBeConnected: boolean,
+ networkAvailable: boolean,
+ wsState: 'disconnected' | 'connecting' | 'connected' | 'authenticated'
+ ): boolean {
+ // Only reconnect if foregrounded, should be connected, network is available, and disconnected
+ return isForeground && shouldBeConnected && networkAvailable && wsState === 'disconnected';
+ }
+
+ it('should reconnect when foregrounded with network and disconnected', () => {
+ expect(shouldReconnectOnNetwork(true, true, true, 'disconnected')).toBe(true);
+ });
+
+ it('should not reconnect when backgrounded', () => {
+ expect(shouldReconnectOnNetwork(false, true, true, 'disconnected')).toBe(false);
+ });
+
+ it('should not reconnect when already connected', () => {
+ expect(shouldReconnectOnNetwork(true, true, true, 'authenticated')).toBe(false);
+ });
+
+ it('should not reconnect when network is unavailable', () => {
+ expect(shouldReconnectOnNetwork(true, true, false, 'disconnected')).toBe(false);
+ });
+
+ it('should not reconnect when shouldBeConnected is false', () => {
+ expect(shouldReconnectOnNetwork(true, false, true, 'disconnected')).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/hooks/__tests__/useSessionChat.test.ts b/apps/mobile/src/hooks/__tests__/useSessionChat.test.ts
new file mode 100644
index 0000000000..19bd63a68c
--- /dev/null
+++ b/apps/mobile/src/hooks/__tests__/useSessionChat.test.ts
@@ -0,0 +1,477 @@
+/**
+ * Tests for useSessionChat history-load behavior.
+ *
+ * Regression context: the Expo mobile chat screen used to start empty whenever
+ * a session was opened — the hook was purely event-driven and never asked the
+ * desktop for the existing conversation backlog. Fixing it added a
+ * `get_session_history` request on session/tab change and a small dedupe-merge
+ * step so the late-arriving response doesn't double-insert streaming events
+ * that landed first.
+ *
+ * Per the codebase convention (see `messageRouting.test.ts`), these tests
+ * mirror the production helpers rather than importing the hook directly —
+ * pulling in `useSessionChat.ts` would drag in expo-haptics, React Context,
+ * and the rest of the RN module graph. The helpers below are KEPT IN SYNC
+ * INTENTIONALLY with `src/hooks/useSessionChat.ts` and
+ * `src/lib/useMaestroWebSocket.ts`; changes to either must update both.
+ */
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+// ---------------------------------------------------------------------------
+// Mirrored types (kept in sync with src/lib/useMaestroWebSocket.ts)
+// ---------------------------------------------------------------------------
+
+type SessionHistoryMessage = {
+ id: string;
+ role: 'user' | 'assistant' | 'system' | 'tool' | 'thinking' | 'error' | 'unknown';
+ source: string;
+ content: string;
+ timestamp: string;
+};
+
+type ChatMessage = {
+ id: string;
+ role: 'user' | 'assistant';
+ content: string;
+};
+
+// ---------------------------------------------------------------------------
+// Mirrored helper: historyToChatMessages
+// (mirror of the function in src/hooks/useSessionChat.ts)
+// ---------------------------------------------------------------------------
+
+function historyToChatMessages(history: SessionHistoryMessage[]): ChatMessage[] {
+ const result: ChatMessage[] = [];
+ for (const entry of history) {
+ if (!entry || typeof entry.content !== 'string' || entry.content.length === 0) continue;
+ if (entry.role === 'user') {
+ result.push({ id: entry.id, role: 'user', content: entry.content });
+ } else if (entry.role === 'assistant') {
+ result.push({ id: entry.id, role: 'assistant', content: entry.content });
+ } else if (entry.role === 'tool') {
+ const toolId = entry.id.startsWith('tool-') ? entry.id : `tool-${entry.id}`;
+ result.push({ id: toolId, role: 'assistant', content: entry.content });
+ }
+ }
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// Mirrored merge: dedupe-prepend used after history arrives
+// (mirror of the setMessages callback in the load-history effect)
+// ---------------------------------------------------------------------------
+
+function mergeHistoryIntoMessages(prev: ChatMessage[], initial: ChatMessage[]): ChatMessage[] {
+ const seen = new Set(prev.map((m) => m.id));
+ const deduped = initial.filter((m) => !seen.has(m.id));
+ return deduped.length === 0 ? prev : [...deduped, ...prev];
+}
+
+// ---------------------------------------------------------------------------
+// Tests: historyToChatMessages role mapping
+// ---------------------------------------------------------------------------
+
+describe('historyToChatMessages', () => {
+ const baseEntry = { source: 'log', timestamp: '2026-06-20T10:00:00.000Z' } as const;
+
+ it('maps user entries straight through', () => {
+ const out = historyToChatMessages([{ ...baseEntry, id: 'u1', role: 'user', content: 'hello' }]);
+ expect(out).toEqual([{ id: 'u1', role: 'user', content: 'hello' }]);
+ });
+
+ it('maps assistant entries straight through', () => {
+ const out = historyToChatMessages([
+ { ...baseEntry, id: 'a1', role: 'assistant', content: 'hi back' },
+ ]);
+ expect(out).toEqual([{ id: 'a1', role: 'assistant', content: 'hi back' }]);
+ });
+
+ it('prefixes tool ids with "tool-" so the renderer recognizes them', () => {
+ const out = historyToChatMessages([
+ { ...baseEntry, id: 'abc123', role: 'tool', content: 'Running: Read' },
+ ]);
+ expect(out).toEqual([{ id: 'tool-abc123', role: 'assistant', content: 'Running: Read' }]);
+ });
+
+ it('does not double-prefix tool ids that already start with "tool-"', () => {
+ const out = historyToChatMessages([
+ { ...baseEntry, id: 'tool-xyz', role: 'tool', content: 'Completed: Edit' },
+ ]);
+ expect(out).toEqual([{ id: 'tool-xyz', role: 'assistant', content: 'Completed: Edit' }]);
+ });
+
+ it('drops system, thinking, error, and unknown roles for parity with streaming', () => {
+ const out = historyToChatMessages([
+ { ...baseEntry, id: 's1', role: 'system', content: 'system note' },
+ { ...baseEntry, id: 't1', role: 'thinking', content: 'thinking out loud' },
+ { ...baseEntry, id: 'e1', role: 'error', content: 'something broke' },
+ { ...baseEntry, id: 'x1', role: 'unknown', content: 'mystery' },
+ ]);
+ expect(out).toEqual([]);
+ });
+
+ it('skips entries with empty content', () => {
+ const out = historyToChatMessages([
+ { ...baseEntry, id: 'u1', role: 'user', content: '' },
+ { ...baseEntry, id: 'a1', role: 'assistant', content: 'kept' },
+ ]);
+ expect(out).toEqual([{ id: 'a1', role: 'assistant', content: 'kept' }]);
+ });
+
+ it('skips entries with non-string content (defensive against malformed wire data)', () => {
+ const out = historyToChatMessages([
+ { ...baseEntry, id: 'u1', role: 'user', content: null as any },
+ { ...baseEntry, id: 'u2', role: 'user', content: undefined as any },
+ { ...baseEntry, id: 'u3', role: 'user', content: 42 as any },
+ { ...baseEntry, id: 'a1', role: 'assistant', content: 'kept' },
+ ]);
+ expect(out).toEqual([{ id: 'a1', role: 'assistant', content: 'kept' }]);
+ });
+
+ it('preserves order across mixed roles', () => {
+ const out = historyToChatMessages([
+ { ...baseEntry, id: 'u1', role: 'user', content: 'q1' },
+ { ...baseEntry, id: 'a1', role: 'assistant', content: 'r1' },
+ { ...baseEntry, id: 'tool-1', role: 'tool', content: 'Running: X' },
+ { ...baseEntry, id: 'u2', role: 'user', content: 'q2' },
+ ]);
+ expect(out.map((m) => m.id)).toEqual(['u1', 'a1', 'tool-1', 'u2']);
+ });
+
+ it('returns an empty array for an empty input', () => {
+ expect(historyToChatMessages([])).toEqual([]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tests: dedupe-prepend merge
+// ---------------------------------------------------------------------------
+
+describe('mergeHistoryIntoMessages', () => {
+ it('prepends history before any messages that arrived during the fetch', () => {
+ const prev: ChatMessage[] = [{ id: 'streamed-1', role: 'assistant', content: 'live' }];
+ const initial: ChatMessage[] = [
+ { id: 'h1', role: 'user', content: 'old q' },
+ { id: 'h2', role: 'assistant', content: 'old a' },
+ ];
+ const merged = mergeHistoryIntoMessages(prev, initial);
+ expect(merged.map((m) => m.id)).toEqual(['h1', 'h2', 'streamed-1']);
+ });
+
+ it('drops duplicates so a re-fetch does not double-insert', () => {
+ const prev: ChatMessage[] = [
+ { id: 'h1', role: 'user', content: 'old q' },
+ { id: 'h2', role: 'assistant', content: 'old a' },
+ ];
+ const initial: ChatMessage[] = [
+ { id: 'h1', role: 'user', content: 'old q' },
+ { id: 'h2', role: 'assistant', content: 'old a' },
+ ];
+ const merged = mergeHistoryIntoMessages(prev, initial);
+ expect(merged).toBe(prev); // identity preserved when nothing new to add
+ expect(merged.map((m) => m.id)).toEqual(['h1', 'h2']);
+ });
+
+ it('keeps the existing tail when only some history entries are new', () => {
+ const prev: ChatMessage[] = [{ id: 'h2', role: 'assistant', content: 'old a' }];
+ const initial: ChatMessage[] = [
+ { id: 'h1', role: 'user', content: 'old q' },
+ { id: 'h2', role: 'assistant', content: 'old a' },
+ ];
+ const merged = mergeHistoryIntoMessages(prev, initial);
+ expect(merged.map((m) => m.id)).toEqual(['h1', 'h2']);
+ });
+
+ it('is a no-op when history is empty', () => {
+ const prev: ChatMessage[] = [{ id: 'live-1', role: 'assistant', content: 'x' }];
+ const merged = mergeHistoryIntoMessages(prev, []);
+ expect(merged).toBe(prev);
+ });
+
+ it('handles an empty prev by returning the history as-is', () => {
+ const initial: ChatMessage[] = [{ id: 'h1', role: 'user', content: 'old q' }];
+ const merged = mergeHistoryIntoMessages([], initial);
+ expect(merged).toEqual(initial);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tests: late-response session guard
+// (mirror of `if (sessionIdRef.current !== targetSessionId) return;`)
+// ---------------------------------------------------------------------------
+
+describe('late-response session guard', () => {
+ function shouldApplyHistory(currentSessionId: string | null, requestedFor: string): boolean {
+ return currentSessionId === requestedFor;
+ }
+
+ it('applies history when the active session still matches the request', () => {
+ expect(shouldApplyHistory('s1', 's1')).toBe(true);
+ });
+
+ it('drops history when the user switched to a different session before it arrived', () => {
+ expect(shouldApplyHistory('s2', 's1')).toBe(false);
+ });
+
+ it('drops history when no session is active any more', () => {
+ expect(shouldApplyHistory(null, 's1')).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tests: requestSessionHistory wire format
+// (mirror of the message body built in useMaestroWebSocket.requestSessionHistory)
+// ---------------------------------------------------------------------------
+
+describe('requestSessionHistory wire format', () => {
+ function buildRequest(
+ tabId: string,
+ requestId: string,
+ options?: { sinceMs?: number; tail?: number }
+ ): Record {
+ const message: Record = {
+ type: 'get_session_history',
+ tabId,
+ requestId,
+ };
+ if (options?.sinceMs !== undefined) message.sinceMs = options.sinceMs;
+ if (options?.tail !== undefined) message.tail = options.tail;
+ return message;
+ }
+
+ it('always includes type, tabId, and requestId', () => {
+ expect(buildRequest('tab-1', 'req-1')).toEqual({
+ type: 'get_session_history',
+ tabId: 'tab-1',
+ requestId: 'req-1',
+ });
+ });
+
+ it('includes sinceMs when provided', () => {
+ expect(buildRequest('tab-1', 'req-1', { sinceMs: 1700000000000 })).toMatchObject({
+ sinceMs: 1700000000000,
+ });
+ });
+
+ it('includes tail when provided', () => {
+ expect(buildRequest('tab-1', 'req-1', { tail: 50 })).toMatchObject({ tail: 50 });
+ });
+
+ it('omits sinceMs and tail when not provided (avoids sending undefined over the wire)', () => {
+ const msg = buildRequest('tab-1', 'req-1');
+ expect('sinceMs' in msg).toBe(false);
+ expect('tail' in msg).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tests: session_history_result dispatch / pending-request correlation
+// (mirror of the handler in useMaestroWebSocket.handleMessage + the
+// pendingHistoryRequestsRef map)
+// ---------------------------------------------------------------------------
+
+describe('session_history_result dispatch', () => {
+ type Pending = {
+ resolve: (result: unknown) => void;
+ reject: (err: Error) => void;
+ };
+
+ function createDispatcher() {
+ const pending = new Map();
+ function dispatch(message: any): void {
+ if (message.type !== 'session_history_result') return;
+ const requestId = typeof message.requestId === 'string' ? message.requestId : null;
+ if (!requestId) return;
+ const p = pending.get(requestId);
+ if (!p) return;
+ pending.delete(requestId);
+ if (message.success === false) {
+ p.reject(new Error(message.error || 'Failed to fetch session history'));
+ } else {
+ p.resolve({
+ tabId: message.tabId,
+ sessionId: message.sessionId,
+ agentId: message.agentId,
+ agentSessionId: message.agentSessionId ?? null,
+ messages: Array.isArray(message.messages) ? message.messages : [],
+ });
+ }
+ }
+ return { pending, dispatch };
+ }
+
+ it('resolves the pending promise whose requestId matches', async () => {
+ const { pending, dispatch } = createDispatcher();
+ const promise = new Promise((resolve, reject) => pending.set('req-1', { resolve, reject }));
+
+ dispatch({
+ type: 'session_history_result',
+ requestId: 'req-1',
+ success: true,
+ tabId: 'tab-1',
+ sessionId: 's-1',
+ agentId: 'claude-code',
+ agentSessionId: 'agent-1',
+ messages: [{ id: 'm1', role: 'user', source: 'log', content: 'hi', timestamp: 't' }],
+ });
+
+ await expect(promise).resolves.toMatchObject({
+ tabId: 'tab-1',
+ sessionId: 's-1',
+ agentId: 'claude-code',
+ agentSessionId: 'agent-1',
+ messages: [{ id: 'm1' }],
+ });
+ expect(pending.has('req-1')).toBe(false);
+ });
+
+ it('rejects when success: false with the desktop-provided error', async () => {
+ const { pending, dispatch } = createDispatcher();
+ const promise = new Promise((resolve, reject) => pending.set('req-1', { resolve, reject }));
+
+ dispatch({
+ type: 'session_history_result',
+ requestId: 'req-1',
+ success: false,
+ error: 'Tab not found: tab-1',
+ code: 'TAB_NOT_FOUND',
+ });
+
+ await expect(promise).rejects.toThrow('Tab not found: tab-1');
+ });
+
+ it('falls back to a generic message when success:false has no error string', async () => {
+ const { pending, dispatch } = createDispatcher();
+ const promise = new Promise((resolve, reject) => pending.set('req-1', { resolve, reject }));
+
+ dispatch({ type: 'session_history_result', requestId: 'req-1', success: false });
+
+ await expect(promise).rejects.toThrow('Failed to fetch session history');
+ });
+
+ it('ignores results with an unknown requestId so unrelated promises do not resolve', () => {
+ const { pending, dispatch } = createDispatcher();
+ const resolve = jest.fn();
+ const reject = jest.fn();
+ pending.set('req-real', { resolve, reject });
+
+ dispatch({
+ type: 'session_history_result',
+ requestId: 'req-other',
+ success: true,
+ messages: [],
+ });
+
+ expect(resolve).not.toHaveBeenCalled();
+ expect(reject).not.toHaveBeenCalled();
+ expect(pending.has('req-real')).toBe(true);
+ });
+
+ it('ignores results with a missing requestId entirely', () => {
+ const { pending, dispatch } = createDispatcher();
+ const resolve = jest.fn();
+ const reject = jest.fn();
+ pending.set('req-1', { resolve, reject });
+
+ dispatch({ type: 'session_history_result', success: true, messages: [] });
+
+ expect(resolve).not.toHaveBeenCalled();
+ expect(reject).not.toHaveBeenCalled();
+ });
+
+ it('coerces a non-array messages payload to [] rather than crashing', async () => {
+ const { pending, dispatch } = createDispatcher();
+ const promise = new Promise((resolve, reject) =>
+ pending.set('req-1', { resolve, reject })
+ );
+
+ dispatch({
+ type: 'session_history_result',
+ requestId: 'req-1',
+ success: true,
+ tabId: 'tab-1',
+ sessionId: 's-1',
+ agentId: 'claude-code',
+ messages: 'not an array',
+ });
+
+ const result = await promise;
+ expect(result.messages).toEqual([]);
+ });
+
+ it('defaults agentSessionId to null when the desktop omits it', async () => {
+ const { pending, dispatch } = createDispatcher();
+ const promise = new Promise((resolve, reject) =>
+ pending.set('req-1', { resolve, reject })
+ );
+
+ dispatch({
+ type: 'session_history_result',
+ requestId: 'req-1',
+ success: true,
+ tabId: 'tab-1',
+ sessionId: 's-1',
+ agentId: 'claude-code',
+ messages: [],
+ });
+
+ const result = await promise;
+ expect(result.agentSessionId).toBeNull();
+ });
+
+ it('does nothing for non-history message types', () => {
+ const { pending, dispatch } = createDispatcher();
+ const resolve = jest.fn();
+ const reject = jest.fn();
+ pending.set('req-1', { resolve, reject });
+
+ dispatch({ type: 'session_output', requestId: 'req-1' });
+
+ expect(resolve).not.toHaveBeenCalled();
+ expect(reject).not.toHaveBeenCalled();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tests: pending-request cleanup on disconnect / unmount
+// (mirror of rejectPendingHistoryRequests in useMaestroWebSocket)
+// ---------------------------------------------------------------------------
+
+describe('rejectPendingHistoryRequests', () => {
+ type Pending = {
+ resolve: (result: unknown) => void;
+ reject: (err: Error) => void;
+ timeoutId: ReturnType;
+ };
+
+ function rejectAll(map: Map, reason: string): void {
+ if (map.size === 0) return;
+ for (const entry of map.values()) {
+ clearTimeout(entry.timeoutId);
+ entry.reject(new Error(reason));
+ }
+ map.clear();
+ }
+
+ it('rejects every pending request and clears the map on disconnect', async () => {
+ const map = new Map();
+ const p1 = new Promise((resolve, reject) =>
+ map.set('a', { resolve, reject, timeoutId: setTimeout(() => {}, 1_000_000) })
+ );
+ const p2 = new Promise((resolve, reject) =>
+ map.set('b', { resolve, reject, timeoutId: setTimeout(() => {}, 1_000_000) })
+ );
+
+ rejectAll(map, 'WebSocket disconnected');
+
+ await expect(p1).rejects.toThrow('WebSocket disconnected');
+ await expect(p2).rejects.toThrow('WebSocket disconnected');
+ expect(map.size).toBe(0);
+ });
+
+ it('is a no-op when no requests are pending (no throws)', () => {
+ const map = new Map();
+ expect(() => rejectAll(map, 'unused')).not.toThrow();
+ });
+});
diff --git a/apps/mobile/src/hooks/useMaestroConnection.ts b/apps/mobile/src/hooks/useMaestroConnection.ts
new file mode 100644
index 0000000000..4e5e7defe4
--- /dev/null
+++ b/apps/mobile/src/hooks/useMaestroConnection.ts
@@ -0,0 +1,197 @@
+/**
+ * useMaestroConnection - Lifecycle-aware WebSocket wrapper for Maestro mobile
+ *
+ * VERIFICATION STEPS (M2 task) - VERIFIED 2026-06-19:
+ * 1. Boot simulator (iPhone 16 Pro), connect to Maestro desktop via dev-pairing.local.json
+ * 2. Open drawer, verify sessions list populated from WebSocket (14 sessions displayed)
+ * 3. Tap session row (MonoRepo), verify navigation to /session/[sessionId] route
+ * 4. Send a prompt message, verify user message bubble appears
+ * 5. Press home button to background the app
+ * 6. Wait 16 seconds (exceeds BACKGROUND_STALENESS_MS = 10s threshold)
+ * 7. Tap app icon to return to foreground
+ * 8. VERIFIED: Socket reconnected (green dot visible), no partial streaming bubble
+ * persisted (stale buffer discarded), connection status shows "Session: MonoRepo"
+ *
+ * Note: NetInfo native module required pod install + native rebuild after adding
+ * @react-native-community/netinfo dependency. Error "NativeModule.RNCNetInfo is null"
+ * indicates missing native linking - run `npx expo run:ios` to rebuild.
+ *
+ * Implementation per decision 13A:
+ * - AppState listener: disconnect() immediately on background, connect() on foreground
+ * - NetInfo listener: reconnect() on network changes when foregrounded
+ * - shouldBeConnected gate for auto-reconnect (only when foregrounded)
+ * - Background staleness: marks streaming buffer stale after 10s, discards on reconnect
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { AppState, type AppStateStatus } from 'react-native';
+import NetInfo, { type NetInfoState } from '@react-native-community/netinfo';
+import {
+ useMaestroWebSocket,
+ type UseMaestroWebSocketOptions,
+ type UseMaestroWebSocketReturn,
+ type WebSocketState,
+} from '@/lib/useMaestroWebSocket';
+
+// How long in background before streaming buffer is considered stale
+const BACKGROUND_STALENESS_MS = 10_000;
+
+export type MaestroConnectionState = 'disconnected' | 'connecting' | 'reconnecting' | 'connected';
+
+export interface UseMaestroConnectionOptions extends UseMaestroWebSocketOptions {
+ /** Called when streaming buffer is discarded due to staleness */
+ onStaleBufferDiscarded?: () => void;
+}
+
+export interface UseMaestroConnectionReturn extends Omit {
+ /** High-level connection state for UI (includes reconnecting) */
+ connectionState: MaestroConnectionState;
+ /** Raw WebSocket state */
+ wsState: WebSocketState;
+ /** Whether the app is in foreground */
+ isForeground: boolean;
+}
+
+export function useMaestroConnection(
+ options: UseMaestroConnectionOptions = {}
+): UseMaestroConnectionReturn {
+ const { onStaleBufferDiscarded, handlers, ...wsOptions } = options;
+
+ // Track foreground state
+ const [isForeground, setIsForeground] = useState(true);
+ const [isReconnecting, setIsReconnecting] = useState(false);
+
+ // Track when we went to background (for staleness check)
+ const backgroundedAtRef = useRef(null);
+ const hasStaleBufferRef = useRef(false);
+
+ // Gate for auto-reconnect
+ const shouldBeConnectedRef = useRef(true);
+
+ // Wrap handlers to inject staleness logic
+ const wrappedHandlers = {
+ ...handlers,
+ onConnectionChange: (state: WebSocketState) => {
+ if (state === 'authenticated') {
+ setIsReconnecting(false);
+ // Check for stale buffer on reconnect
+ if (hasStaleBufferRef.current) {
+ hasStaleBufferRef.current = false;
+ onStaleBufferDiscarded?.();
+ }
+ }
+ handlers?.onConnectionChange?.(state);
+ },
+ };
+
+ // Inner WebSocket hook
+ const ws = useMaestroWebSocket({
+ ...wsOptions,
+ autoReconnect: shouldBeConnectedRef.current,
+ handlers: wrappedHandlers,
+ });
+
+ const {
+ state: wsState,
+ connect,
+ disconnect,
+ send,
+ isAuthenticated,
+ error,
+ requestSessionHistory,
+ } = ws;
+
+ // Derive high-level connection state
+ const connectionState: MaestroConnectionState = (() => {
+ if (isReconnecting) return 'reconnecting';
+ if (wsState === 'authenticated' || wsState === 'connected') return 'connected';
+ if (wsState === 'connecting') return 'connecting';
+ return 'disconnected';
+ })();
+
+ // Handle AppState changes
+ const handleAppStateChange = useCallback(
+ (nextState: AppStateStatus) => {
+ const wasBackground = !isForeground;
+ const isNowForeground = nextState === 'active';
+
+ setIsForeground(isNowForeground);
+
+ if (isNowForeground && wasBackground) {
+ // Returning to foreground
+ shouldBeConnectedRef.current = true;
+
+ // Check if buffer should be marked stale
+ if (backgroundedAtRef.current) {
+ const elapsed = Date.now() - backgroundedAtRef.current;
+ if (elapsed > BACKGROUND_STALENESS_MS) {
+ hasStaleBufferRef.current = true;
+ }
+ }
+ backgroundedAtRef.current = null;
+
+ // Reconnect if needed
+ if (wsState === 'disconnected') {
+ setIsReconnecting(true);
+ connect();
+ }
+ } else if (!isNowForeground && !wasBackground) {
+ // Going to background
+ backgroundedAtRef.current = Date.now();
+ shouldBeConnectedRef.current = false;
+ disconnect();
+ }
+ },
+ [isForeground, wsState, connect, disconnect]
+ );
+
+ // Handle network changes
+ const handleNetworkChange = useCallback(
+ (state: NetInfoState) => {
+ // Only reconnect if foregrounded and we should be connected
+ if (!isForeground || !shouldBeConnectedRef.current) return;
+
+ // Network became available
+ if (state.isConnected && wsState === 'disconnected') {
+ setIsReconnecting(true);
+ connect();
+ }
+ },
+ [isForeground, wsState, connect]
+ );
+
+ // Set up AppState listener
+ useEffect(() => {
+ const subscription = AppState.addEventListener('change', handleAppStateChange);
+ return () => subscription.remove();
+ }, [handleAppStateChange]);
+
+ // Set up NetInfo listener
+ useEffect(() => {
+ const unsubscribe = NetInfo.addEventListener(handleNetworkChange);
+ return () => unsubscribe();
+ }, [handleNetworkChange]);
+
+ // Auto-connect on mount if foregrounded
+ useEffect(() => {
+ if (isForeground && wsState === 'disconnected') {
+ connect();
+ }
+ // Only run on mount
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return {
+ connectionState,
+ wsState,
+ isForeground,
+ isAuthenticated,
+ error,
+ connect,
+ disconnect,
+ send,
+ requestSessionHistory,
+ };
+}
+
+export default useMaestroConnection;
diff --git a/apps/mobile/src/hooks/useMaestroOfflineQueue.ts b/apps/mobile/src/hooks/useMaestroOfflineQueue.ts
new file mode 100644
index 0000000000..9fd2540937
--- /dev/null
+++ b/apps/mobile/src/hooks/useMaestroOfflineQueue.ts
@@ -0,0 +1,56 @@
+/**
+ * useMaestroOfflineQueue - Offline queue wrapper for Maestro mobile
+ *
+ * Integrates useOfflineQueue with useMaestroConnection to provide automatic
+ * command queueing when disconnected. Commands typed while offline are persisted
+ * to AsyncStorage and automatically dispatched when the connection is restored.
+ *
+ * Usage:
+ * ```tsx
+ * const { queueCommand, queueLength, canQueue } = useMaestroOfflineQueue({
+ * isOnline: connectionState !== 'disconnected',
+ * isConnected: connectionState === 'ready',
+ * sendCommand: (sessionId, command) => {
+ * send({ type: 'send_command', sessionId, command, inputMode: 'ai' });
+ * return true;
+ * },
+ * });
+ * ```
+ */
+
+import { useOfflineQueue } from '@maestro/web-hooks/useOfflineQueue';
+import { asyncStorageAdapter } from '@/storage/asyncStorageAdapter';
+
+export interface UseMaestroOfflineQueueOptions {
+ /** Whether network connectivity is available */
+ isOnline: boolean;
+ /** Whether WebSocket is authenticated and ready */
+ isConnected: boolean;
+ /** Send function that dispatches to WebSocket */
+ sendCommand: (sessionId: string, command: string) => boolean;
+ /** Callback when a queued command is sent */
+ onCommandSent?: () => void;
+ /** Callback when a queued command fails */
+ onCommandFailed?: (error: string) => void;
+}
+
+/**
+ * Hook that provides offline queue functionality using AsyncStorage persistence.
+ *
+ * When disconnected, commands are queued and persisted to AsyncStorage.
+ * On reconnection, queued commands are automatically dispatched in order.
+ */
+export function useMaestroOfflineQueue(options: UseMaestroOfflineQueueOptions) {
+ const { isOnline, isConnected, sendCommand, onCommandSent, onCommandFailed } = options;
+
+ return useOfflineQueue({
+ isOnline,
+ isConnected,
+ sendCommand,
+ storage: asyncStorageAdapter,
+ onCommandSent: onCommandSent ? () => onCommandSent() : undefined,
+ onCommandFailed: onCommandFailed ? (cmd, error) => onCommandFailed(error) : undefined,
+ });
+}
+
+export default useMaestroOfflineQueue;
diff --git a/apps/mobile/src/hooks/usePairingCheck.ts b/apps/mobile/src/hooks/usePairingCheck.ts
new file mode 100644
index 0000000000..22390ce125
--- /dev/null
+++ b/apps/mobile/src/hooks/usePairingCheck.ts
@@ -0,0 +1,67 @@
+/**
+ * usePairingCheck - Hook to check for stored pairing credentials on app startup
+ *
+ * On cold start, if SecureStore has no 'maestro.pairing.active' credential,
+ * navigates immediately to /pair (the QR scanner). If credentials exist,
+ * allows the app to proceed normally.
+ *
+ * Part of M3 Mobile Expo App implementation (decision 6A QR pairing).
+ */
+
+import { useEffect, useState } from 'react';
+import { useRouter, useSegments } from 'expo-router';
+import { hasCredentials } from '@/lib/credentials';
+
+export type PairingState = 'checking' | 'paired' | 'unpaired';
+
+/**
+ * Hook that checks for pairing credentials on mount and redirects to /pair if missing.
+ * Returns the current pairing state so the UI can show a loading state while checking.
+ */
+export function usePairingCheck(): PairingState {
+ const [state, setState] = useState('checking');
+ const router = useRouter();
+ const segments = useSegments();
+
+ useEffect(() => {
+ let mounted = true;
+
+ async function checkPairing() {
+ try {
+ const hasCreds = await hasCredentials();
+
+ if (!mounted) return;
+
+ if (hasCreds) {
+ setState('paired');
+ } else {
+ setState('unpaired');
+ // Only redirect if we're not already on the pair screen
+ const isOnPairScreen = segments[0] === 'pair';
+ if (!isOnPairScreen) {
+ // Use replace to ensure pair screen can't be navigated back from
+ router.replace('/pair');
+ }
+ }
+ } catch (error) {
+ console.error('[usePairingCheck] Error checking credentials:', error);
+ // On error, assume unpaired and navigate to pair screen
+ if (mounted) {
+ setState('unpaired');
+ const isOnPairScreen = segments[0] === 'pair';
+ if (!isOnPairScreen) {
+ router.replace('/pair');
+ }
+ }
+ }
+ }
+
+ checkPairing();
+
+ return () => {
+ mounted = false;
+ };
+ }, [router, segments]);
+
+ return state;
+}
diff --git a/apps/mobile/src/hooks/useSessionChat.ts b/apps/mobile/src/hooks/useSessionChat.ts
new file mode 100644
index 0000000000..a604355c18
--- /dev/null
+++ b/apps/mobile/src/hooks/useSessionChat.ts
@@ -0,0 +1,339 @@
+/**
+ * useSessionChat - Chat functionality for a specific Maestro session
+ *
+ * Subscribes to events from the shared SessionsContext WebSocket, filters for
+ * the target session, streams assistant output, and commits messages when the
+ * session finishes a turn (either session_state_change to idle, or session_exit
+ * for agents that don't emit state transitions on completion).
+ *
+ * Shared by the session route (`/session/[sessionId]`) and the home chat screen
+ * (`/`), which targets the active session. Keep this the single source of truth
+ * for session chat behavior - do not fork a second copy.
+ */
+
+import { createStreamingStore, type ChatMessage } from '@/components/chat';
+import {
+ useSessions,
+ type SessionData,
+ type SessionHistoryMessage,
+ type ToolEventLog,
+} from '@/lib/SessionsContext';
+import { useMaestroOfflineQueue } from '@/hooks/useMaestroOfflineQueue';
+import * as Haptics from 'expo-haptics';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+// Throttle interval for streaming UI updates (~30fps)
+const STREAMING_THROTTLE_MS = 32;
+
+/**
+ * Convert the desktop's `SessionHistoryMessage[]` into the mobile `ChatMessage`
+ * shape the UI expects. The mobile UI only renders `user` / `assistant`, plus
+ * "tool" messages identified by a `tool-` id prefix (matches the existing
+ * convention in renderMessage). Other roles (system/thinking/error/unknown)
+ * are dropped here for parity with the streaming path — those don't show up
+ * mid-conversation either, so we'd be the only surface to render them.
+ */
+function historyToChatMessages(history: SessionHistoryMessage[]): ChatMessage[] {
+ const result: ChatMessage[] = [];
+ for (const entry of history) {
+ if (!entry || typeof entry.content !== 'string' || entry.content.length === 0) continue;
+ if (entry.role === 'user') {
+ result.push({ id: entry.id, role: 'user', content: entry.content });
+ } else if (entry.role === 'assistant') {
+ result.push({ id: entry.id, role: 'assistant', content: entry.content });
+ } else if (entry.role === 'tool') {
+ const toolId = entry.id.startsWith('tool-') ? entry.id : `tool-${entry.id}`;
+ result.push({ id: toolId, role: 'assistant', content: entry.content });
+ }
+ }
+ return result;
+}
+
+export type ChatConnectionState = 'disconnected' | 'connecting' | 'connected' | 'ready';
+
+export interface UseSessionChatReturn {
+ messages: ChatMessage[];
+ input: string;
+ setInput: (value: string) => void;
+ isGenerating: boolean;
+ onSend: () => void;
+ streamingStore: ReturnType;
+ connectionState: ChatConnectionState;
+ session: SessionData | null;
+ /** Whether the connection is active (connected or ready state) */
+ isConnected: boolean;
+ /** Number of commands queued for offline sending */
+ queueLength: number;
+}
+
+export function useSessionChat(targetSessionId: string): UseSessionChatReturn {
+ const {
+ connectionState: wsConnectionState,
+ sessions,
+ setActiveSessionId,
+ sendCommand,
+ requestSessionHistory,
+ subscribeSessionOutput,
+ subscribeSessionStateChange,
+ subscribeSessionExit,
+ subscribeToolEvent,
+ subscribeUserInput,
+ } = useSessions();
+
+ const session = useMemo(
+ () => sessions.find((s) => s.id === targetSessionId) || null,
+ [sessions, targetSessionId]
+ );
+
+ // Chat state
+ const [input, setInput] = useState('');
+ const [messages, setMessages] = useState([]);
+ const [isGenerating, setIsGenerating] = useState(false);
+
+ // Streaming state
+ const streamingStore = useMemo(() => createStreamingStore(), []);
+ const streamingRef = useRef('');
+ const throttleRef = useRef | null>(null);
+ const streamingMessageIdRef = useRef(null);
+
+ // Stable reference to targetSessionId for subscriber callbacks
+ const sessionIdRef = useRef(targetSessionId);
+ useEffect(() => {
+ sessionIdRef.current = targetSessionId;
+ }, [targetSessionId]);
+
+ // Commit the in-flight streaming buffer to the assistant message and clear
+ // streaming state. Used by both session_state_change=idle and session_exit.
+ const commitStreaming = useCallback(() => {
+ if (streamingMessageIdRef.current && streamingRef.current) {
+ const finalContent = streamingRef.current;
+ const msgId = streamingMessageIdRef.current;
+ setMessages((prev) => {
+ const updated = [...prev];
+ const lastIdx = updated.findIndex((m) => m.id === msgId);
+ if (lastIdx >= 0) {
+ updated[lastIdx] = { ...updated[lastIdx], content: finalContent };
+ }
+ return updated;
+ });
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ }
+ streamingRef.current = '';
+ streamingStore.set('');
+ streamingMessageIdRef.current = null;
+ setIsGenerating(false);
+ }, [streamingStore]);
+
+ // Derive screen-level connection state from the shared WS state plus whether
+ // the target session has actually shown up in the sessions list.
+ // Note: the desktop sends a bare `connected` message (no `authenticated: true`),
+ // so we treat both 'connected' and 'authenticated' as fully ready for I/O —
+ // the WS handshake itself already validated the mobile pairing token.
+ const connectionState = useMemo(() => {
+ if (wsConnectionState === 'disconnected') return 'disconnected';
+ if (wsConnectionState === 'connecting') return 'connecting';
+ if (session) return 'ready';
+ return 'connected';
+ }, [wsConnectionState, session]);
+
+ // If the session is already running when we land on the screen, reflect that.
+ useEffect(() => {
+ if (session && (session.state === 'running' || session.state === 'busy')) {
+ setIsGenerating(true);
+ }
+ }, [session]);
+
+ // Subscribe to session output (streamed assistant tokens).
+ useEffect(() => {
+ return subscribeSessionOutput((outputSessionId, data, source) => {
+ if (outputSessionId !== sessionIdRef.current) return;
+ if (source !== 'ai') return;
+
+ if (!streamingMessageIdRef.current) {
+ const messageId = `assistant-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ streamingMessageIdRef.current = messageId;
+ setMessages((prev) => [...prev, { id: messageId, role: 'assistant', content: '' }]);
+ setIsGenerating(true);
+ }
+
+ streamingRef.current += data;
+
+ if (!throttleRef.current) {
+ throttleRef.current = setTimeout(() => {
+ streamingStore.set(streamingRef.current);
+ throttleRef.current = null;
+ }, STREAMING_THROTTLE_MS);
+ }
+ });
+ }, [subscribeSessionOutput, streamingStore]);
+
+ // Subscribe to session_state_change (turn-complete signal for agents that
+ // emit explicit idle/ready transitions).
+ useEffect(() => {
+ return subscribeSessionStateChange((changedSessionId, state) => {
+ if (changedSessionId !== sessionIdRef.current) return;
+ if (state === 'idle' || state === 'ready') {
+ commitStreaming();
+ } else if (state === 'running' || state === 'busy') {
+ setIsGenerating(true);
+ }
+ });
+ }, [subscribeSessionStateChange, commitStreaming]);
+
+ // Subscribe to session_exit (turn-complete signal for agents like Claude Code
+ // that exit per turn instead of emitting a state change). Without this the
+ // spinner stuck forever and onSend early-returned because isGenerating stayed
+ // true.
+ useEffect(() => {
+ return subscribeSessionExit((exitSessionId) => {
+ if (exitSessionId !== sessionIdRef.current) return;
+ commitStreaming();
+ });
+ }, [subscribeSessionExit, commitStreaming]);
+
+ // Subscribe to tool events.
+ useEffect(() => {
+ return subscribeToolEvent((toolSessionId, _tabId, toolLog: ToolEventLog) => {
+ if (toolSessionId !== sessionIdRef.current) return;
+
+ const toolName = toolLog.metadata?.toolState?.name || 'tool';
+ const status = toolLog.metadata?.toolState?.status || 'running';
+ const toolMessageId = `tool-${toolLog.id}`;
+
+ setMessages((prev) => {
+ const existingIdx = prev.findIndex((m) => m.id === toolMessageId);
+ const toolContent =
+ status === 'running'
+ ? `Running: ${toolName}`
+ : status === 'completed'
+ ? `Completed: ${toolName}`
+ : `Error: ${toolName}`;
+
+ if (existingIdx >= 0) {
+ const updated = [...prev];
+ updated[existingIdx] = { ...updated[existingIdx], content: toolContent };
+ return updated;
+ }
+ return [...prev, { id: toolMessageId, role: 'assistant' as const, content: toolContent }];
+ });
+ });
+ }, [subscribeToolEvent]);
+
+ // Subscribe to user input echoed from desktop.
+ useEffect(() => {
+ return subscribeUserInput((inputSessionId, command, inputMode) => {
+ if (inputSessionId !== sessionIdRef.current) return;
+ if (inputMode !== 'ai') return;
+
+ const messageId = `user-desktop-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ setMessages((prev) => [...prev, { id: messageId, role: 'user', content: command }]);
+ });
+ }, [subscribeUserInput]);
+
+ // Sync active session in context so the drawer + tab strip stay in sync.
+ useEffect(() => {
+ if (targetSessionId) {
+ setActiveSessionId(targetSessionId);
+ }
+ }, [targetSessionId, setActiveSessionId]);
+
+ // Load history (and reset streaming state) when the session or active tab
+ // changes. Without this the screen would be empty until the user sends a
+ // new message — the desktop already has the conversation, we just never
+ // asked for it.
+ const activeTabId = session?.activeTabId ?? null;
+ useEffect(() => {
+ setMessages([]);
+ streamingRef.current = '';
+ streamingStore.set('');
+ streamingMessageIdRef.current = null;
+ setIsGenerating(false);
+
+ if (!targetSessionId || !activeTabId) return;
+ if (wsConnectionState !== 'connected' && wsConnectionState !== 'authenticated') return;
+
+ let cancelled = false;
+ requestSessionHistory(activeTabId)
+ .then((result) => {
+ if (cancelled) return;
+ if (sessionIdRef.current !== targetSessionId) return;
+ const initial = historyToChatMessages(result.messages);
+ if (initial.length === 0) return;
+ // Prepend so any streaming/user/tool events that landed while the
+ // request was in flight stay at the end of the conversation.
+ setMessages((prev) => {
+ const seen = new Set(prev.map((m) => m.id));
+ const deduped = initial.filter((m) => !seen.has(m.id));
+ return deduped.length === 0 ? prev : [...deduped, ...prev];
+ });
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ console.warn('[useSessionChat] Failed to load session history', err);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [targetSessionId, activeTabId, wsConnectionState, requestSessionHistory, streamingStore]);
+
+ const isConnected = connectionState === 'connected' || connectionState === 'ready';
+
+ // Offline queue for queueing commands when disconnected.
+ const queueSend = useCallback(
+ (sessionId: string, command: string) => {
+ return sendCommand(sessionId, command);
+ },
+ [sendCommand]
+ );
+
+ const { queueCommand, queueLength } = useMaestroOfflineQueue({
+ isOnline: connectionState !== 'disconnected',
+ isConnected: connectionState === 'ready',
+ sendCommand: queueSend,
+ onCommandSent: useCallback(() => {
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ }, []),
+ });
+
+ const onSend = useCallback(() => {
+ if (!input.trim() || isGenerating || !targetSessionId) return;
+
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+
+ const userMessageId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ setMessages((prev) => [...prev, { id: userMessageId, role: 'user', content: input.trim() }]);
+
+ if (!isConnected) {
+ queueCommand(targetSessionId, input.trim(), 'ai');
+ setInput('');
+ return;
+ }
+
+ sendCommand(targetSessionId, input.trim());
+ setInput('');
+ setIsGenerating(true);
+ }, [input, isGenerating, targetSessionId, sendCommand, isConnected, queueCommand]);
+
+ // Cleanup throttle timeout on unmount.
+ useEffect(() => {
+ return () => {
+ if (throttleRef.current) {
+ clearTimeout(throttleRef.current);
+ }
+ };
+ }, []);
+
+ return {
+ messages,
+ input,
+ setInput,
+ isGenerating,
+ onSend,
+ streamingStore,
+ connectionState,
+ session,
+ isConnected,
+ queueLength,
+ };
+}
diff --git a/apps/mobile/src/lib/SessionsContext.tsx b/apps/mobile/src/lib/SessionsContext.tsx
new file mode 100644
index 0000000000..791483401e
--- /dev/null
+++ b/apps/mobile/src/lib/SessionsContext.tsx
@@ -0,0 +1,494 @@
+/**
+ * SessionsContext - Shared context for Maestro sessions data
+ *
+ * This context provides sessions list and active session state to all components
+ * in the app, enabling the drawer to display sessions and the chat screen to
+ * use the selected session. The WebSocket connection is managed here and shared
+ * across components.
+ */
+
+import React, {
+ createContext,
+ useContext,
+ useCallback,
+ useState,
+ useRef,
+ useEffect,
+ useMemo,
+} from 'react';
+import {
+ useMaestroWebSocket,
+ type AITabData,
+ type SessionData,
+ type SessionHistoryResult,
+ type ToolEventLog,
+ type WebSocketState,
+} from './useMaestroWebSocket';
+import type { Theme } from '@maestro/shared/theme-types';
+
+// Re-export types for consumers
+export type {
+ SessionData,
+ AITabData,
+ ToolEventLog,
+ SessionHistoryMessage,
+ SessionHistoryResult,
+} from './useMaestroWebSocket';
+
+// ============================================================================
+// Subscriber callback signatures
+// ============================================================================
+
+export type SessionOutputHandler = (
+ sessionId: string,
+ data: string,
+ source: 'ai' | 'terminal',
+ tabId?: string
+) => void;
+
+export type SessionStateChangeHandler = (sessionId: string, state: string) => void;
+
+export type SessionExitHandler = (sessionId: string) => void;
+
+export type ToolEventHandler = (sessionId: string, tabId: string, toolLog: ToolEventLog) => void;
+
+export type UserInputHandler = (
+ sessionId: string,
+ command: string,
+ inputMode: 'ai' | 'terminal'
+) => void;
+
+export type Unsubscribe = () => void;
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface SessionsContextValue {
+ // Connection state
+ connectionState: WebSocketState;
+ isAuthenticated: boolean;
+ error: string | null;
+
+ // Sessions data
+ sessions: SessionData[];
+ activeSessionId: string | null;
+ activeSession: SessionData | null;
+
+ // Actions
+ setActiveSessionId: (sessionId: string) => void;
+ setActiveTab: (sessionId: string, tabId: string) => boolean;
+ /** Create a new AI tab within a session. Returns false if the socket is down. */
+ newTab: (sessionId: string) => boolean;
+ /** Close an AI tab within a session. Returns false if the socket is down. */
+ closeTab: (sessionId: string, tabId: string) => boolean;
+ connect: () => void;
+ disconnect: () => void;
+ /**
+ * Ask the desktop to resend the full sessions list (pull-to-refresh). Resolves
+ * once the refreshed `sessions_list` arrives, or after a short timeout / if the
+ * socket is down, so a spinner never hangs.
+ */
+ refreshSessions: () => Promise;
+ sendCommand: (sessionId: string, command: string) => boolean;
+ /**
+ * Fetch a tab's conversation backlog from the desktop. Resolves with the
+ * history payload (oldest first) or rejects if the desktop returns an error,
+ * the socket drops, or the request times out.
+ */
+ requestSessionHistory: (
+ tabId: string,
+ options?: { sinceMs?: number; tail?: number; timeoutMs?: number }
+ ) => Promise;
+
+ // Event subscriptions (single WebSocket fans out to all subscribers).
+ // Each subscriber MUST call the returned Unsubscribe in a useEffect cleanup
+ // to avoid leaking handlers across screen mounts.
+ subscribeSessionOutput: (handler: SessionOutputHandler) => Unsubscribe;
+ subscribeSessionStateChange: (handler: SessionStateChangeHandler) => Unsubscribe;
+ subscribeSessionExit: (handler: SessionExitHandler) => Unsubscribe;
+ subscribeToolEvent: (handler: ToolEventHandler) => Unsubscribe;
+ subscribeUserInput: (handler: UserInputHandler) => Unsubscribe;
+}
+
+// ============================================================================
+// Context
+// ============================================================================
+
+const SessionsContext = createContext(null);
+
+// ============================================================================
+// Provider
+// ============================================================================
+
+interface SessionsProviderProps {
+ children: React.ReactNode;
+ /** Optional callback for theme updates (used by AccentProvider) */
+ onThemeUpdate?: (theme: Theme) => void;
+}
+
+export function SessionsProvider({ children, onThemeUpdate }: SessionsProviderProps) {
+ // Sessions state
+ const [sessions, setSessions] = useState([]);
+ const [activeSessionId, setActiveSessionId] = useState(null);
+
+ // Track active session ID in ref for callback stability
+ const activeSessionIdRef = useRef(null);
+
+ // Track theme callback in ref for callback stability
+ const onThemeUpdateRef = useRef(onThemeUpdate);
+
+ // One-shot resolvers for in-flight refreshSessions() calls. Drained when the
+ // next `sessions_list` arrives so pull-to-refresh resolves on real data.
+ const pendingRefreshResolversRef = useRef<(() => void)[]>([]);
+
+ // Subscriber registries. Using Sets gives O(1) add/remove and natural dedupe,
+ // and a single WebSocket fans out to every screen that registered.
+ const sessionOutputSubs = useRef(new Set());
+ const sessionStateChangeSubs = useRef(new Set());
+ const sessionExitSubs = useRef(new Set());
+ const toolEventSubs = useRef(new Set());
+ const userInputSubs = useRef(new Set());
+
+ // Keep refs in sync with props/state (must be in useEffect per React 19 rules)
+ useEffect(() => {
+ activeSessionIdRef.current = activeSessionId;
+ }, [activeSessionId]);
+
+ useEffect(() => {
+ onThemeUpdateRef.current = onThemeUpdate;
+ }, [onThemeUpdate]);
+
+ // WebSocket connection
+ const {
+ state: connectionState,
+ isAuthenticated,
+ error,
+ connect,
+ disconnect,
+ send,
+ requestSessionHistory,
+ } = useMaestroWebSocket({
+ autoReconnect: true,
+ handlers: {
+ onSessionsUpdate: (newSessions: SessionData[]) => {
+ setSessions(newSessions);
+
+ // Auto-select first session if none selected
+ if (!activeSessionIdRef.current && newSessions.length > 0) {
+ setActiveSessionId(newSessions[0].id);
+ }
+
+ // Clear active session if it no longer exists
+ if (activeSessionIdRef.current) {
+ const stillExists = newSessions.some((s) => s.id === activeSessionIdRef.current);
+ if (!stillExists) {
+ setActiveSessionId(newSessions.length > 0 ? newSessions[0].id : null);
+ }
+ }
+
+ // Resolve any in-flight pull-to-refresh now that fresh data landed.
+ const resolvers = pendingRefreshResolversRef.current;
+ pendingRefreshResolversRef.current = [];
+ resolvers.forEach((resolve) => resolve());
+ },
+ onSessionAdded: (session: SessionData) => {
+ // The desktop sends an incremental add when an agent is created;
+ // merge it in (replace if it somehow already exists) so the sidebar
+ // updates live without a reconnect. Auto-select if nothing is active.
+ setSessions((prev) => {
+ if (prev.some((s) => s.id === session.id)) {
+ return prev.map((s) => (s.id === session.id ? { ...s, ...session } : s));
+ }
+ return [...prev, session];
+ });
+ if (!activeSessionIdRef.current) {
+ setActiveSessionId(session.id);
+ }
+ },
+ onSessionRemoved: (sessionId: string) => {
+ setSessions((prev) => {
+ const next = prev.filter((s) => s.id !== sessionId);
+ // If the removed session was active, fall back to the first
+ // remaining session (or none) to keep selection valid.
+ if (activeSessionIdRef.current === sessionId) {
+ setActiveSessionId(next.length > 0 ? next[0].id : null);
+ }
+ return next;
+ });
+ },
+ onSessionStateChange: (sessionId, state) => {
+ // Update session state in local list
+ setSessions((prev) => prev.map((s) => (s.id === sessionId ? { ...s, state } : s)));
+ // Fan out to screen subscribers
+ sessionStateChangeSubs.current.forEach((handler) => {
+ try {
+ handler(sessionId, state);
+ } catch (err) {
+ console.error('[SessionsContext] onSessionStateChange subscriber threw', err);
+ }
+ });
+ },
+ onSessionOutput: (sessionId, data, source, tabId) => {
+ sessionOutputSubs.current.forEach((handler) => {
+ try {
+ handler(sessionId, data, source, tabId);
+ } catch (err) {
+ console.error('[SessionsContext] onSessionOutput subscriber threw', err);
+ }
+ });
+ },
+ onSessionExit: (sessionId) => {
+ sessionExitSubs.current.forEach((handler) => {
+ try {
+ handler(sessionId);
+ } catch (err) {
+ console.error('[SessionsContext] onSessionExit subscriber threw', err);
+ }
+ });
+ },
+ onToolEvent: (sessionId, tabId, toolLog) => {
+ toolEventSubs.current.forEach((handler) => {
+ try {
+ handler(sessionId, tabId, toolLog);
+ } catch (err) {
+ console.error('[SessionsContext] onToolEvent subscriber threw', err);
+ }
+ });
+ },
+ onUserInput: (sessionId, command, inputMode) => {
+ userInputSubs.current.forEach((handler) => {
+ try {
+ handler(sessionId, command, inputMode);
+ } catch (err) {
+ console.error('[SessionsContext] onUserInput subscriber threw', err);
+ }
+ });
+ },
+ onTabsChanged: (sessionId, aiTabs, activeTabId) => {
+ // Update session's tabs in local list
+ setSessions((prev) =>
+ prev.map((s) => (s.id === sessionId ? { ...s, aiTabs, activeTabId } : s))
+ );
+ },
+ onTabCreated: (sessionId, tabId) => {
+ // Optimistically add and activate the freshly-created tab so the
+ // strip updates without waiting on the desktop's `tabs_changed`
+ // poll. If the tab already arrived via a broadcast, just activate
+ // it. The authoritative `tabs_changed`/reconnect later reconciles
+ // names, state, etc. (this replaces the whole aiTabs array).
+ setSessions((prev) =>
+ prev.map((s) => {
+ if (s.id !== sessionId) return s;
+ if (s.aiTabs?.some((t) => t.id === tabId)) {
+ return { ...s, activeTabId: tabId };
+ }
+ const newTab: AITabData = {
+ id: tabId,
+ agentSessionId: null,
+ name: null,
+ starred: false,
+ inputValue: '',
+ createdAt: Date.now(),
+ state: 'idle',
+ };
+ return { ...s, aiTabs: [...(s.aiTabs ?? []), newTab], activeTabId: tabId };
+ })
+ );
+ },
+ onTabClosed: (sessionId, tabId) => {
+ // Optimistically drop the closed tab. If it was the active tab,
+ // fall back to its neighbour (previous, else next) so the strip
+ // always has a valid selection. A later `tabs_changed`/reconnect
+ // reconciles authoritatively.
+ setSessions((prev) =>
+ prev.map((s) => {
+ if (s.id !== sessionId) return s;
+ const tabs = s.aiTabs ?? [];
+ const idx = tabs.findIndex((t) => t.id === tabId);
+ if (idx === -1) return s;
+ const remaining = tabs.filter((t) => t.id !== tabId);
+ let activeTabId = s.activeTabId;
+ if (activeTabId === tabId) {
+ const neighbour = remaining[idx - 1] ?? remaining[idx] ?? remaining[0];
+ activeTabId = neighbour?.id ?? '';
+ }
+ return { ...s, aiTabs: remaining, activeTabId };
+ })
+ );
+ },
+ onThemeUpdate: (theme) => {
+ // Forward theme updates to AccentProvider (per decision 5C)
+ onThemeUpdateRef.current?.(theme);
+ },
+ },
+ });
+
+ // Auto-connect on mount
+ useEffect(() => {
+ if (connectionState === 'disconnected') {
+ connect();
+ }
+ }, [connectionState, connect]);
+
+ // Derived active session
+ const activeSession = useMemo(
+ () => sessions.find((s) => s.id === activeSessionId) || null,
+ [sessions, activeSessionId]
+ );
+
+ // Ask the desktop to resend the full sessions list. Used by pull-to-refresh
+ // as a manual fallback to the live `session_added`/`session_removed` deltas.
+ const refreshSessions = useCallback((): Promise => {
+ return new Promise((resolve) => {
+ const sent = send({ type: 'get_sessions' });
+ if (!sent) {
+ // Socket down - nothing will arrive, so don't leave the spinner hanging.
+ resolve();
+ return;
+ }
+ let settled = false;
+ const settle = () => {
+ if (settled) return;
+ settled = true;
+ resolve();
+ };
+ pendingRefreshResolversRef.current.push(settle);
+ // Safety net: resolve even if no `sessions_list` comes back.
+ setTimeout(settle, 5000);
+ });
+ }, [send]);
+
+ // Send command to a session
+ const sendCommand = useCallback(
+ (sessionId: string, command: string): boolean => {
+ return send({
+ type: 'send_command',
+ sessionId,
+ command,
+ inputMode: 'ai',
+ });
+ },
+ [send]
+ );
+
+ // Set active tab within a session. The desktop web-server expects the
+ // `select_tab` message type; it has no `set_active_tab` case. The desktop
+ // broadcasts a `tabs_changed` event afterwards, which updates activeTabId
+ // for the strip's highlight.
+ const setActiveTab = useCallback(
+ (sessionId: string, tabId: string): boolean => {
+ return send({
+ type: 'select_tab',
+ sessionId,
+ tabId,
+ });
+ },
+ [send]
+ );
+
+ // Create a new AI tab within a session. The desktop creates the tab and
+ // replies with `new_tab_result`, which the client handles optimistically.
+ const newTab = useCallback(
+ (sessionId: string): boolean => {
+ return send({
+ type: 'new_tab',
+ sessionId,
+ });
+ },
+ [send]
+ );
+
+ // Close an AI tab within a session. The desktop replies with
+ // `close_tab_result`, which the client handles optimistically (the
+ // `tabs_changed` broadcast that would normally confirm this can be gated).
+ const closeTab = useCallback(
+ (sessionId: string, tabId: string): boolean => {
+ return send({
+ type: 'close_tab',
+ sessionId,
+ tabId,
+ });
+ },
+ [send]
+ );
+
+ // Subscribe helpers. Stable identity via useCallback so consumers can put
+ // them in useEffect deps without re-running the effect every render.
+ const subscribeSessionOutput = useCallback((handler: SessionOutputHandler): Unsubscribe => {
+ sessionOutputSubs.current.add(handler);
+ return () => {
+ sessionOutputSubs.current.delete(handler);
+ };
+ }, []);
+
+ const subscribeSessionStateChange = useCallback(
+ (handler: SessionStateChangeHandler): Unsubscribe => {
+ sessionStateChangeSubs.current.add(handler);
+ return () => {
+ sessionStateChangeSubs.current.delete(handler);
+ };
+ },
+ []
+ );
+
+ const subscribeSessionExit = useCallback((handler: SessionExitHandler): Unsubscribe => {
+ sessionExitSubs.current.add(handler);
+ return () => {
+ sessionExitSubs.current.delete(handler);
+ };
+ }, []);
+
+ const subscribeToolEvent = useCallback((handler: ToolEventHandler): Unsubscribe => {
+ toolEventSubs.current.add(handler);
+ return () => {
+ toolEventSubs.current.delete(handler);
+ };
+ }, []);
+
+ const subscribeUserInput = useCallback((handler: UserInputHandler): Unsubscribe => {
+ userInputSubs.current.add(handler);
+ return () => {
+ userInputSubs.current.delete(handler);
+ };
+ }, []);
+
+ const value: SessionsContextValue = {
+ connectionState,
+ isAuthenticated,
+ error,
+ sessions,
+ activeSessionId,
+ activeSession,
+ setActiveSessionId,
+ setActiveTab,
+ newTab,
+ closeTab,
+ connect,
+ disconnect,
+ refreshSessions,
+ sendCommand,
+ requestSessionHistory,
+ subscribeSessionOutput,
+ subscribeSessionStateChange,
+ subscribeSessionExit,
+ subscribeToolEvent,
+ subscribeUserInput,
+ };
+
+ return {children};
+}
+
+// ============================================================================
+// Hook
+// ============================================================================
+
+export function useSessions(): SessionsContextValue {
+ const context = useContext(SessionsContext);
+ if (!context) {
+ throw new Error('useSessions must be used within a SessionsProvider');
+ }
+ return context;
+}
+
+export default SessionsContext;
diff --git a/apps/mobile/src/lib/ToastContext.tsx b/apps/mobile/src/lib/ToastContext.tsx
new file mode 100644
index 0000000000..b88ebe26bf
--- /dev/null
+++ b/apps/mobile/src/lib/ToastContext.tsx
@@ -0,0 +1,134 @@
+import { createContext, useContext, useCallback, useState, useEffect, type ReactNode } from 'react';
+import { Text, StyleSheet } from 'react-native';
+import Animated, {
+ useSharedValue,
+ useAnimatedStyle,
+ withTiming,
+ withSequence,
+ runOnJS,
+} from 'react-native-reanimated';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+type ToastColor = 'green' | 'yellow' | 'orange' | 'red' | 'theme';
+
+interface ToastMessage {
+ message: string;
+ color?: ToastColor;
+ duration?: number;
+}
+
+interface ToastContextValue {
+ showToast: (toast: ToastMessage) => void;
+}
+
+const ToastContext = createContext(null);
+
+export function useToast(): ToastContextValue {
+ const context = useContext(ToastContext);
+ if (!context) {
+ throw new Error('useToast must be used within a ToastProvider');
+ }
+ return context;
+}
+
+// Color mapping for toast backgrounds
+const colorMap: Record = {
+ green: 'rgba(34, 197, 94, 0.9)', // green-500
+ yellow: 'rgba(234, 179, 8, 0.9)', // yellow-500
+ orange: 'rgba(249, 115, 22, 0.9)', // orange-500
+ red: 'rgba(239, 68, 68, 0.9)', // red-500
+ theme: 'rgba(99, 102, 241, 0.9)', // indigo-500 (accent placeholder)
+};
+
+const textColorMap: Record = {
+ green: '#ffffff',
+ yellow: '#000000',
+ orange: '#ffffff',
+ red: '#ffffff',
+ theme: '#ffffff',
+};
+
+interface ToastProps {
+ toast: ToastMessage | null;
+ onDismiss: () => void;
+}
+
+function ToastComponent({ toast, onDismiss }: ToastProps) {
+ const insets = useSafeAreaInsets();
+ const opacity = useSharedValue(0);
+ const translateY = useSharedValue(-20);
+
+ useEffect(() => {
+ if (toast) {
+ const duration = toast.duration ?? 2500;
+ opacity.value = withSequence(
+ withTiming(1, { duration: 200 }),
+ withTiming(1, { duration: duration - 400 }),
+ withTiming(0, { duration: 200 }, () => {
+ runOnJS(onDismiss)();
+ })
+ );
+ translateY.value = withTiming(0, { duration: 200 });
+ }
+ }, [toast, onDismiss, opacity, translateY]);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ transform: [{ translateY: translateY.value }],
+ }));
+
+ if (!toast) return null;
+
+ const color = toast.color ?? 'theme';
+ const backgroundColor = colorMap[color];
+ const textColor = textColorMap[color];
+
+ return (
+
+ {toast.message}
+
+ );
+}
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+ const [currentToast, setCurrentToast] = useState(null);
+
+ const showToast = useCallback((toast: ToastMessage) => {
+ setCurrentToast(toast);
+ }, []);
+
+ const dismissToast = useCallback(() => {
+ setCurrentToast(null);
+ }, []);
+
+ return (
+
+ {children}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ toastContainer: {
+ position: 'absolute',
+ left: 16,
+ right: 16,
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderRadius: 12,
+ zIndex: 9999,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.15,
+ shadowRadius: 8,
+ elevation: 8,
+ },
+ toastText: {
+ fontSize: 14,
+ fontWeight: '500',
+ textAlign: 'center',
+ },
+});
diff --git a/apps/mobile/src/lib/__tests__/messageRouting.test.ts b/apps/mobile/src/lib/__tests__/messageRouting.test.ts
new file mode 100644
index 0000000000..598473a1e2
--- /dev/null
+++ b/apps/mobile/src/lib/__tests__/messageRouting.test.ts
@@ -0,0 +1,212 @@
+/**
+ * Tests for the WebSocket message routing switch used by useMaestroWebSocket.
+ *
+ * Regression: the Claude Code per-turn path on the desktop emits `session_exit`
+ * instead of `session_state_change` -> 'idle'. Before this fix the mobile app
+ * had no case for `session_exit`, so the spinner stayed visible forever and
+ * `onSend` early-returned because `isGenerating` was always true.
+ *
+ * These tests verify the dispatch table separately from React so we don't have
+ * to spin up the full RN module graph.
+ */
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+type Handlers = {
+ onConnectionChange?: (state: string) => void;
+ onSessionsUpdate?: (sessions: any[]) => void;
+ onSessionStateChange?: (sessionId: string, state: string) => void;
+ onSessionOutput?: (
+ sessionId: string,
+ data: string,
+ source: 'ai' | 'terminal',
+ tabId?: string
+ ) => void;
+ onSessionExit?: (sessionId: string) => void;
+ onToolEvent?: (sessionId: string, tabId: string, toolLog: any) => void;
+ onUserInput?: (sessionId: string, command: string, inputMode: 'ai' | 'terminal') => void;
+ onTabsChanged?: (sessionId: string, aiTabs: any[], activeTabId: string) => void;
+ onThemeUpdate?: (theme: any) => void;
+ onError?: (error: string) => void;
+};
+
+/**
+ * Mirror of the switch in src/lib/useMaestroWebSocket.ts:handleMessage.
+ * Kept in sync intentionally so changes to either must update both.
+ */
+function dispatch(message: any, handlers: Handlers): void {
+ switch (message.type) {
+ case 'connected':
+ handlers.onConnectionChange?.(message.authenticated ? 'authenticated' : 'connected');
+ break;
+ case 'auth_success':
+ handlers.onConnectionChange?.('authenticated');
+ break;
+ case 'auth_failed':
+ handlers.onError?.(message.message);
+ break;
+ case 'sessions_list':
+ handlers.onSessionsUpdate?.(message.sessions);
+ break;
+ case 'session_state_change':
+ handlers.onSessionStateChange?.(message.sessionId, message.state);
+ break;
+ case 'session_output':
+ handlers.onSessionOutput?.(message.sessionId, message.data, message.source, message.tabId);
+ break;
+ case 'session_exit':
+ handlers.onSessionExit?.(message.sessionId);
+ break;
+ case 'tool_event':
+ handlers.onToolEvent?.(message.sessionId, message.tabId, message.toolLog);
+ break;
+ case 'user_input':
+ handlers.onUserInput?.(message.sessionId, message.command, message.inputMode);
+ break;
+ case 'tabs_changed':
+ handlers.onTabsChanged?.(message.sessionId, message.aiTabs, message.activeTabId);
+ break;
+ case 'error':
+ handlers.onError?.(message.message);
+ break;
+ case 'theme':
+ handlers.onThemeUpdate?.(message.theme);
+ break;
+ default:
+ break;
+ }
+}
+
+describe('WebSocket message routing', () => {
+ it('routes session_exit to onSessionExit (regression: stuck spinner)', () => {
+ const onSessionExit = jest.fn();
+ const onSessionStateChange = jest.fn();
+
+ dispatch({ type: 'session_exit', sessionId: 's1' }, { onSessionExit, onSessionStateChange });
+
+ expect(onSessionExit).toHaveBeenCalledTimes(1);
+ expect(onSessionExit).toHaveBeenCalledWith('s1');
+ // session_exit must NOT be misrouted to onSessionStateChange.
+ expect(onSessionStateChange).not.toHaveBeenCalled();
+ });
+
+ it('routes session_output to onSessionOutput with tabId', () => {
+ const onSessionOutput = jest.fn();
+ dispatch(
+ { type: 'session_output', sessionId: 's1', data: 'Hi', source: 'ai', tabId: 't1' },
+ { onSessionOutput }
+ );
+ expect(onSessionOutput).toHaveBeenCalledWith('s1', 'Hi', 'ai', 't1');
+ });
+
+ it('routes session_state_change to onSessionStateChange', () => {
+ const onSessionStateChange = jest.fn();
+ dispatch(
+ { type: 'session_state_change', sessionId: 's1', state: 'idle' },
+ { onSessionStateChange }
+ );
+ expect(onSessionStateChange).toHaveBeenCalledWith('s1', 'idle');
+ });
+
+ it('ignores unknown message types without throwing', () => {
+ expect(() =>
+ dispatch({ type: 'definitely-not-a-real-event' }, { onError: jest.fn() })
+ ).not.toThrow();
+ });
+
+ it('does not call session_exit handler for other message types', () => {
+ const onSessionExit = jest.fn();
+ dispatch({ type: 'session_state_change', sessionId: 's1', state: 'idle' }, { onSessionExit });
+ dispatch(
+ { type: 'session_output', sessionId: 's1', data: 'x', source: 'ai' },
+ { onSessionExit }
+ );
+ dispatch({ type: 'sessions_list', sessions: [] }, { onSessionExit });
+ expect(onSessionExit).not.toHaveBeenCalled();
+ });
+});
+
+describe('Subscriber fan-out semantics', () => {
+ /**
+ * Mirrors the Set-of-handlers fan-out pattern in SessionsContext: a single
+ * upstream event is delivered to every subscriber, and a thrown handler
+ * doesn't suppress others.
+ */
+ function makeRegistry void>() {
+ const subs = new Set();
+ return {
+ subscribe(handler: T) {
+ subs.add(handler);
+ return () => {
+ subs.delete(handler);
+ };
+ },
+ emit(...args: Parameters) {
+ subs.forEach((h) => {
+ try {
+ h(...args);
+ } catch {
+ // swallow per SessionsContext behavior
+ }
+ });
+ },
+ size: () => subs.size,
+ };
+ }
+
+ it('delivers a single emit to every subscriber exactly once', () => {
+ const r = makeRegistry<(s: string) => void>();
+ const a = jest.fn();
+ const b = jest.fn();
+ const c = jest.fn();
+ r.subscribe(a);
+ r.subscribe(b);
+ r.subscribe(c);
+
+ r.emit('hello');
+
+ expect(a).toHaveBeenCalledTimes(1);
+ expect(b).toHaveBeenCalledTimes(1);
+ expect(c).toHaveBeenCalledTimes(1);
+ expect(a).toHaveBeenCalledWith('hello');
+ });
+
+ it('does not double-deliver if the same handler subscribes twice (Set dedupe)', () => {
+ const r = makeRegistry<() => void>();
+ const a = jest.fn();
+ r.subscribe(a);
+ r.subscribe(a);
+ expect(r.size()).toBe(1);
+ r.emit();
+ expect(a).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns an unsubscribe that removes only the right handler', () => {
+ const r = makeRegistry<() => void>();
+ const a = jest.fn();
+ const b = jest.fn();
+ const unsubA = r.subscribe(a);
+ r.subscribe(b);
+
+ unsubA();
+
+ r.emit();
+ expect(a).not.toHaveBeenCalled();
+ expect(b).toHaveBeenCalledTimes(1);
+ });
+
+ it('isolates exceptions: a throwing subscriber does not block later ones', () => {
+ const r = makeRegistry<() => void>();
+ const thrower = jest.fn(() => {
+ throw new Error('boom');
+ });
+ const survivor = jest.fn();
+ r.subscribe(thrower);
+ r.subscribe(survivor);
+
+ r.emit();
+
+ expect(thrower).toHaveBeenCalledTimes(1);
+ expect(survivor).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/mobile/src/lib/credentials.ts b/apps/mobile/src/lib/credentials.ts
new file mode 100644
index 0000000000..f66d943eb2
--- /dev/null
+++ b/apps/mobile/src/lib/credentials.ts
@@ -0,0 +1,75 @@
+/**
+ * credentials.ts - SecureStore-backed credential management for Maestro mobile
+ *
+ * Stores and retrieves the pairing credentials obtained via QR code scanning.
+ * Credentials include: host, port, token (long-lived auth token from desktop),
+ * pairingId (device ID from the pairing record), and deviceName.
+ *
+ * Part of M3 Mobile Expo App implementation (decision 15B QR pairing).
+ */
+
+import * as SecureStore from 'expo-secure-store';
+
+const CREDENTIALS_KEY = 'maestro.pairing.active';
+
+export interface MaestroCredentials {
+ host: string;
+ port: number;
+ token: string;
+ pairingId: string;
+ deviceName: string;
+}
+
+/**
+ * Store pairing credentials in secure storage.
+ * Called after successfully scanning QR code and exchanging the pairing code.
+ */
+export async function storeCredentials(credentials: MaestroCredentials): Promise {
+ await SecureStore.setItemAsync(CREDENTIALS_KEY, JSON.stringify(credentials));
+}
+
+/**
+ * Retrieve stored pairing credentials.
+ * Returns null if no credentials are stored (app needs pairing).
+ */
+export async function getCredentials(): Promise {
+ try {
+ const stored = await SecureStore.getItemAsync(CREDENTIALS_KEY);
+ if (!stored) return null;
+ return JSON.parse(stored) as MaestroCredentials;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Check if the app has stored credentials.
+ * Used to determine if we should show the pairing screen.
+ */
+export async function hasCredentials(): Promise {
+ const creds = await getCredentials();
+ return creds !== null;
+}
+
+/**
+ * Clear stored credentials.
+ * Used when unpairing or when credentials are invalid.
+ */
+export async function clearCredentials(): Promise {
+ await SecureStore.deleteItemAsync(CREDENTIALS_KEY);
+}
+
+/**
+ * Build the WebSocket URL from stored credentials.
+ * Returns null if no credentials are stored.
+ */
+export async function buildWebSocketUrlFromCredentials(sessionId?: string): Promise {
+ const creds = await getCredentials();
+ if (!creds) return null;
+
+ let url = `ws://${creds.host}:${creds.port}/${creds.token}/ws`;
+ if (sessionId) {
+ url += `?sessionId=${encodeURIComponent(sessionId)}`;
+ }
+ return url;
+}
diff --git a/apps/mobile/src/lib/useMaestroWebSocket.ts b/apps/mobile/src/lib/useMaestroWebSocket.ts
new file mode 100644
index 0000000000..e34d1b4dd0
--- /dev/null
+++ b/apps/mobile/src/lib/useMaestroWebSocket.ts
@@ -0,0 +1,514 @@
+/**
+ * useMaestroWebSocket - M1 WebSocket hook for Maestro mobile
+ *
+ * A simplified WebSocket hook that connects to the Maestro desktop app
+ * and handles the core message types needed for M1 chat functionality.
+ *
+ * This is a local implementation to avoid cross-tree import resolution issues.
+ * For full desktop web interface, see src/web/hooks/useWebSocket.ts
+ */
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { buildWebSocketUrl } from '../../shims/config';
+import type { Theme } from '@maestro/shared/theme-types';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export type WebSocketState = 'disconnected' | 'connecting' | 'connected' | 'authenticated';
+
+/**
+ * AI Tab data for multi-tab support within a Maestro session
+ */
+export interface AITabData {
+ id: string;
+ agentSessionId: string | null;
+ name: string | null;
+ starred: boolean;
+ inputValue: string;
+ createdAt: number;
+ state: 'idle' | 'busy';
+ thinkingStartTime?: number | null;
+ hasUnread?: boolean;
+}
+
+export interface SessionData {
+ id: string;
+ name: string;
+ toolType: string;
+ state: string;
+ inputMode: string;
+ cwd: string;
+ aiTabs?: AITabData[];
+ activeTabId?: string;
+}
+
+export interface ToolEventLog {
+ id: string;
+ timestamp: number;
+ source: 'tool';
+ text: string;
+ metadata?: {
+ toolState?: {
+ name: string;
+ status: 'running' | 'completed' | 'error';
+ };
+ };
+}
+
+/**
+ * Single message from a `get_session_history` response. Matches the desktop's
+ * `SessionHistoryMessage` shape in `src/main/web-server/types.ts` and is the
+ * payload the CLI's `session show` command consumes.
+ */
+export interface SessionHistoryMessage {
+ id: string;
+ role: 'user' | 'assistant' | 'system' | 'tool' | 'thinking' | 'error' | 'unknown';
+ source: string;
+ content: string;
+ /** ISO-8601 timestamp. */
+ timestamp: string;
+}
+
+export interface SessionHistoryResult {
+ tabId: string;
+ sessionId: string;
+ agentId: string;
+ agentSessionId: string | null;
+ messages: SessionHistoryMessage[];
+}
+
+export interface WebSocketHandlers {
+ onConnectionChange?: (state: WebSocketState) => void;
+ onSessionsUpdate?: (sessions: SessionData[]) => void;
+ /**
+ * Fired when the desktop creates a new agent. The desktop broadcasts an
+ * incremental `session_added` rather than resending the full `sessions_list`,
+ * so without this the mobile sidebar never sees agents created after connect.
+ */
+ onSessionAdded?: (session: SessionData) => void;
+ /** Fired when the desktop removes an agent (incremental `session_removed`). */
+ onSessionRemoved?: (sessionId: string) => void;
+ onSessionStateChange?: (sessionId: string, state: string) => void;
+ onSessionOutput?: (
+ sessionId: string,
+ data: string,
+ source: 'ai' | 'terminal',
+ tabId?: string
+ ) => void;
+ /**
+ * Fired when the desktop signals a session has finished its current turn.
+ * Used as a fallback turn-complete signal when session_state_change is not
+ * emitted for the active code path.
+ */
+ onSessionExit?: (sessionId: string) => void;
+ onToolEvent?: (sessionId: string, tabId: string, toolLog: ToolEventLog) => void;
+ onUserInput?: (sessionId: string, command: string, inputMode: 'ai' | 'terminal') => void;
+ onTabsChanged?: (sessionId: string, aiTabs: AITabData[], activeTabId: string) => void;
+ /**
+ * Fired when the desktop acknowledges a `new_tab` request with success. The
+ * desktop also emits `tabs_changed` (via its live-broadcast poll), but that
+ * can lag or be gated, so this lets the client optimistically add the new tab
+ * immediately. The later `tabs_changed`/reconnect reconciles authoritatively.
+ */
+ onTabCreated?: (sessionId: string, tabId: string) => void;
+ /**
+ * Fired when the desktop acknowledges a `close_tab` request with success.
+ * Lets the client optimistically drop the tab without waiting on the
+ * (possibly gated) `tabs_changed` broadcast. Reconciles on the next
+ * `tabs_changed`/reconnect.
+ */
+ onTabClosed?: (sessionId: string, tabId: string) => void;
+ /** Called when theme is received or updated from Maestro desktop */
+ onThemeUpdate?: (theme: Theme) => void;
+ onError?: (error: string) => void;
+}
+
+export interface UseMaestroWebSocketOptions {
+ autoReconnect?: boolean;
+ handlers?: WebSocketHandlers;
+}
+
+export interface UseMaestroWebSocketReturn {
+ state: WebSocketState;
+ isAuthenticated: boolean;
+ error: string | null;
+ connect: () => void;
+ disconnect: () => void;
+ send: (message: object) => boolean;
+ /**
+ * Request the conversation backlog for a tab. Resolves with the messages
+ * (oldest first) so callers can seed their local message list before any
+ * streaming events arrive. Rejects if the socket isn't open or the desktop
+ * returns an error.
+ */
+ requestSessionHistory: (
+ tabId: string,
+ options?: { sinceMs?: number; tail?: number; timeoutMs?: number }
+ ) => Promise;
+}
+
+// ============================================================================
+// Hook Implementation
+// ============================================================================
+
+export function useMaestroWebSocket(
+ options: UseMaestroWebSocketOptions = {}
+): UseMaestroWebSocketReturn {
+ const { autoReconnect = true, handlers } = options;
+
+ const [state, setState] = useState('disconnected');
+ const [error, setError] = useState(null);
+
+ const wsRef = useRef(null);
+ const reconnectTimeoutRef = useRef | null>(null);
+ const handlersRef = useRef(handlers);
+ const shouldReconnectRef = useRef(true);
+ const reconnectAttemptsRef = useRef(0);
+
+ // In-flight `get_session_history` requests, keyed by requestId. The desktop
+ // echoes the same requestId in `session_history_result`, so we resolve the
+ // matching promise on arrival. A reconnect rejects every pending entry.
+ const pendingHistoryRequestsRef = useRef(
+ new Map<
+ string,
+ {
+ resolve: (result: SessionHistoryResult) => void;
+ reject: (error: Error) => void;
+ timeoutId: ReturnType;
+ }
+ >()
+ );
+
+ // Keep handlers ref up to date
+ handlersRef.current = handlers;
+
+ const clearTimers = useCallback(() => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ reconnectTimeoutRef.current = null;
+ }
+ }, []);
+
+ const handleMessage = useCallback((event: MessageEvent) => {
+ try {
+ const message = JSON.parse(event.data);
+
+ switch (message.type) {
+ case 'connected':
+ if (message.authenticated) {
+ setState('authenticated');
+ handlersRef.current?.onConnectionChange?.('authenticated');
+ } else {
+ setState('connected');
+ handlersRef.current?.onConnectionChange?.('connected');
+ }
+ setError(null);
+ reconnectAttemptsRef.current = 0;
+ break;
+
+ case 'auth_success':
+ setState('authenticated');
+ handlersRef.current?.onConnectionChange?.('authenticated');
+ break;
+
+ case 'auth_failed':
+ setError(message.message);
+ handlersRef.current?.onError?.(message.message);
+ break;
+
+ case 'sessions_list':
+ handlersRef.current?.onSessionsUpdate?.(message.sessions);
+ break;
+
+ case 'session_added':
+ handlersRef.current?.onSessionAdded?.(message.session);
+ break;
+
+ case 'session_removed':
+ handlersRef.current?.onSessionRemoved?.(message.sessionId);
+ break;
+
+ case 'session_state_change':
+ handlersRef.current?.onSessionStateChange?.(message.sessionId, message.state);
+ break;
+
+ case 'session_output':
+ handlersRef.current?.onSessionOutput?.(
+ message.sessionId,
+ message.data,
+ message.source,
+ message.tabId
+ );
+ break;
+
+ case 'session_exit':
+ handlersRef.current?.onSessionExit?.(message.sessionId);
+ break;
+
+ case 'tool_event':
+ handlersRef.current?.onToolEvent?.(message.sessionId, message.tabId, message.toolLog);
+ break;
+
+ case 'user_input':
+ handlersRef.current?.onUserInput?.(message.sessionId, message.command, message.inputMode);
+ break;
+
+ case 'tabs_changed':
+ handlersRef.current?.onTabsChanged?.(
+ message.sessionId,
+ message.aiTabs,
+ message.activeTabId
+ );
+ break;
+
+ case 'new_tab_result':
+ if (message.success && typeof message.tabId === 'string') {
+ handlersRef.current?.onTabCreated?.(message.sessionId, message.tabId);
+ }
+ break;
+
+ case 'close_tab_result':
+ if (message.success && typeof message.tabId === 'string') {
+ handlersRef.current?.onTabClosed?.(message.sessionId, message.tabId);
+ }
+ break;
+
+ case 'error':
+ setError(message.message);
+ handlersRef.current?.onError?.(message.message);
+ break;
+
+ case 'theme':
+ // Theme update from Maestro desktop (per decision 5C)
+ handlersRef.current?.onThemeUpdate?.(message.theme);
+ break;
+
+ case 'session_history_result': {
+ const requestId = typeof message.requestId === 'string' ? message.requestId : null;
+ if (!requestId) break;
+ const pending = pendingHistoryRequestsRef.current.get(requestId);
+ if (!pending) break;
+ pendingHistoryRequestsRef.current.delete(requestId);
+ clearTimeout(pending.timeoutId);
+ if (message.success === false) {
+ pending.reject(new Error(message.error || 'Failed to fetch session history'));
+ } else {
+ pending.resolve({
+ tabId: message.tabId,
+ sessionId: message.sessionId,
+ agentId: message.agentId,
+ agentSessionId: message.agentSessionId ?? null,
+ messages: Array.isArray(message.messages) ? message.messages : [],
+ });
+ }
+ break;
+ }
+
+ case 'pong':
+ // Heartbeat response - no action needed
+ break;
+
+ default:
+ // Unknown message type - ignore
+ break;
+ }
+ } catch (err) {
+ console.error('Failed to parse WebSocket message:', err);
+ }
+ }, []);
+
+ const attemptReconnect = useCallback(() => {
+ if (!shouldReconnectRef.current || !autoReconnect) {
+ return;
+ }
+
+ if (reconnectAttemptsRef.current >= 10) {
+ setError('Failed to connect after 10 attempts');
+ handlersRef.current?.onError?.('Failed to connect after 10 attempts');
+ return;
+ }
+
+ reconnectTimeoutRef.current = setTimeout(() => {
+ reconnectAttemptsRef.current++;
+ void connectInternal();
+ }, 2000);
+ }, [autoReconnect]);
+
+ const connectInternal = useCallback(async () => {
+ // Clean up existing connection
+ if (wsRef.current) {
+ wsRef.current.close();
+ wsRef.current = null;
+ }
+ clearTimers();
+
+ setState('connecting');
+ handlersRef.current?.onConnectionChange?.('connecting');
+
+ // Build URL without a specific sessionId - session is selected after connection
+ const url = await buildWebSocketUrl();
+
+ if (!url) {
+ // No credentials available - need pairing
+ setError('No credentials - please pair with Maestro desktop');
+ handlersRef.current?.onError?.('No credentials - please pair with Maestro desktop');
+ setState('disconnected');
+ handlersRef.current?.onConnectionChange?.('disconnected');
+ return;
+ }
+
+ try {
+ const ws = new WebSocket(url);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ // Wait for 'connected' message from server
+ };
+
+ ws.onmessage = handleMessage;
+
+ ws.onerror = (event) => {
+ console.error('WebSocket error:', event);
+ setError('WebSocket connection error');
+ handlersRef.current?.onError?.('WebSocket connection error');
+ };
+
+ ws.onclose = (event) => {
+ clearTimers();
+ wsRef.current = null;
+ setState('disconnected');
+ handlersRef.current?.onConnectionChange?.('disconnected');
+
+ // Attempt to reconnect if not a clean close
+ if (event.code !== 1000 && shouldReconnectRef.current) {
+ attemptReconnect();
+ }
+ };
+ } catch (err) {
+ console.error('Failed to create WebSocket:', err);
+ setError('Failed to create WebSocket connection');
+ handlersRef.current?.onError?.('Failed to create WebSocket connection');
+ setState('disconnected');
+ handlersRef.current?.onConnectionChange?.('disconnected');
+ }
+ }, [clearTimers, handleMessage, attemptReconnect]);
+
+ const connect = useCallback(() => {
+ shouldReconnectRef.current = true;
+ reconnectAttemptsRef.current = 0;
+ setError(null);
+ void connectInternal();
+ }, [connectInternal]);
+
+ const disconnect = useCallback(() => {
+ shouldReconnectRef.current = false;
+ clearTimers();
+
+ if (wsRef.current) {
+ wsRef.current.close(1000, 'Client disconnect');
+ wsRef.current = null;
+ }
+
+ setState('disconnected');
+ handlersRef.current?.onConnectionChange?.('disconnected');
+ }, [clearTimers]);
+
+ const send = useCallback((message: object): boolean => {
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify(message));
+ return true;
+ }
+ return false;
+ }, []);
+
+ const rejectPendingHistoryRequests = useCallback((reason: string) => {
+ const pending = pendingHistoryRequestsRef.current;
+ if (pending.size === 0) return;
+ for (const entry of pending.values()) {
+ clearTimeout(entry.timeoutId);
+ entry.reject(new Error(reason));
+ }
+ pending.clear();
+ }, []);
+
+ const requestSessionHistory = useCallback(
+ (
+ tabId: string,
+ options?: { sinceMs?: number; tail?: number; timeoutMs?: number }
+ ): Promise => {
+ return new Promise((resolve, reject) => {
+ if (!tabId) {
+ reject(new Error('tabId is required'));
+ return;
+ }
+ if (wsRef.current?.readyState !== WebSocket.OPEN) {
+ reject(new Error('WebSocket is not open'));
+ return;
+ }
+
+ const requestId = `history-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
+ const timeoutMs = options?.timeoutMs ?? 10_000;
+
+ const timeoutId = setTimeout(() => {
+ if (pendingHistoryRequestsRef.current.delete(requestId)) {
+ reject(new Error('Session history request timed out'));
+ }
+ }, timeoutMs);
+
+ pendingHistoryRequestsRef.current.set(requestId, { resolve, reject, timeoutId });
+
+ const message: Record = {
+ type: 'get_session_history',
+ tabId,
+ requestId,
+ };
+ if (options?.sinceMs !== undefined) message.sinceMs = options.sinceMs;
+ if (options?.tail !== undefined) message.tail = options.tail;
+
+ try {
+ wsRef.current.send(JSON.stringify(message));
+ } catch (err) {
+ pendingHistoryRequestsRef.current.delete(requestId);
+ clearTimeout(timeoutId);
+ reject(err instanceof Error ? err : new Error(String(err)));
+ }
+ });
+ },
+ []
+ );
+
+ // Reject any in-flight history fetches whenever the socket drops so callers
+ // get a deterministic error instead of hanging forever.
+ useEffect(() => {
+ if (state === 'disconnected') {
+ rejectPendingHistoryRequests('WebSocket disconnected');
+ }
+ }, [state, rejectPendingHistoryRequests]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ shouldReconnectRef.current = false;
+ clearTimers();
+ rejectPendingHistoryRequests('WebSocket hook unmounted');
+ if (wsRef.current) {
+ wsRef.current.close(1000, 'Component unmount');
+ wsRef.current = null;
+ }
+ };
+ }, [clearTimers, rejectPendingHistoryRequests]);
+
+ return {
+ state,
+ isAuthenticated: state === 'authenticated',
+ error,
+ connect,
+ disconnect,
+ send,
+ requestSessionHistory,
+ };
+}
+
+export default useMaestroWebSocket;
diff --git a/apps/mobile/src/pairing/__tests__/parseQrPayload.test.ts b/apps/mobile/src/pairing/__tests__/parseQrPayload.test.ts
new file mode 100644
index 0000000000..e714143070
--- /dev/null
+++ b/apps/mobile/src/pairing/__tests__/parseQrPayload.test.ts
@@ -0,0 +1,248 @@
+/**
+ * Tests for QR / pairing URL parsing.
+ *
+ * Covers both supported formats:
+ * 1. `maestro://pair?host=&port=&code=` (pair-code flow, needs redemption)
+ * 2. `http(s)://host:port/` (web-link flow, token usable directly)
+ */
+
+import { parseQrPayload } from '../parseQrPayload';
+
+// A realistic UUID v4 used by the desktop's web-server-factory.
+const SAMPLE_TOKEN = '232aead9-b3f8-46b0-9c56-4e104498a97e';
+
+describe('parseQrPayload', () => {
+ describe('pair-code URLs', () => {
+ describe('valid payloads', () => {
+ it('parses a valid maestro://pair URL', () => {
+ const result = parseQrPayload('maestro://pair?host=192.168.1.100&port=17170&code=ABC123');
+ expect(result).toEqual({
+ kind: 'pair-code',
+ host: '192.168.1.100',
+ port: 17170,
+ code: 'ABC123',
+ });
+ });
+
+ it('handles localhost as host', () => {
+ const result = parseQrPayload('maestro://pair?host=localhost&port=8080&code=XYZ');
+ expect(result).toEqual({
+ kind: 'pair-code',
+ host: 'localhost',
+ port: 8080,
+ code: 'XYZ',
+ });
+ });
+
+ it('handles URL-encoded host values', () => {
+ const result = parseQrPayload('maestro://pair?host=my-computer.local&port=3000&code=TEST');
+ expect(result).toEqual({
+ kind: 'pair-code',
+ host: 'my-computer.local',
+ port: 3000,
+ code: 'TEST',
+ });
+ });
+
+ it('handles IPv6 addresses', () => {
+ const result = parseQrPayload('maestro://pair?host=%3A%3A1&port=17170&code=IPV6');
+ expect(result).toEqual({
+ kind: 'pair-code',
+ host: '::1',
+ port: 17170,
+ code: 'IPV6',
+ });
+ });
+
+ it('handles port 1', () => {
+ const result = parseQrPayload('maestro://pair?host=localhost&port=1&code=MIN');
+ expect(result).toEqual({
+ kind: 'pair-code',
+ host: 'localhost',
+ port: 1,
+ code: 'MIN',
+ });
+ });
+
+ it('handles port 65535', () => {
+ const result = parseQrPayload('maestro://pair?host=localhost&port=65535&code=MAX');
+ expect(result).toEqual({
+ kind: 'pair-code',
+ host: 'localhost',
+ port: 65535,
+ code: 'MAX',
+ });
+ });
+ });
+
+ describe('missing parameters', () => {
+ it('returns null when host is missing', () => {
+ expect(parseQrPayload('maestro://pair?port=17170&code=ABC')).toBeNull();
+ });
+
+ it('returns null when port is missing', () => {
+ expect(parseQrPayload('maestro://pair?host=localhost&code=ABC')).toBeNull();
+ });
+
+ it('returns null when code is missing', () => {
+ expect(parseQrPayload('maestro://pair?host=localhost&port=17170')).toBeNull();
+ });
+
+ it('returns null when all parameters are missing', () => {
+ expect(parseQrPayload('maestro://pair')).toBeNull();
+ });
+
+ it('returns null when only query string marker is present', () => {
+ expect(parseQrPayload('maestro://pair?')).toBeNull();
+ });
+ });
+
+ describe('invalid port values', () => {
+ it('returns null for non-numeric port', () => {
+ expect(parseQrPayload('maestro://pair?host=localhost&port=abc&code=X')).toBeNull();
+ });
+
+ it('returns null for port 0', () => {
+ expect(parseQrPayload('maestro://pair?host=localhost&port=0&code=X')).toBeNull();
+ });
+
+ it('returns null for negative port', () => {
+ expect(parseQrPayload('maestro://pair?host=localhost&port=-1&code=X')).toBeNull();
+ });
+
+ it('returns null for port > 65535', () => {
+ expect(parseQrPayload('maestro://pair?host=localhost&port=65536&code=X')).toBeNull();
+ });
+
+ it('returns null for float port', () => {
+ expect(parseQrPayload('maestro://pair?host=localhost&port=8080.5&code=X')).toBeNull();
+ });
+ });
+
+ describe('whitespace-only code', () => {
+ it('returns null for whitespace-only code', () => {
+ expect(parseQrPayload('maestro://pair?host=localhost&port=8080&code= ')).toBeNull();
+ });
+ });
+ });
+
+ describe('web-link URLs', () => {
+ describe('valid payloads', () => {
+ it('parses the persistent web link format', () => {
+ const result = parseQrPayload(`http://192.168.1.6:61300/${SAMPLE_TOKEN}`);
+ expect(result).toEqual({
+ kind: 'web-link',
+ host: '192.168.1.6',
+ port: 61300,
+ token: SAMPLE_TOKEN,
+ });
+ });
+
+ it('accepts a trailing slash after the token', () => {
+ const result = parseQrPayload(`http://192.168.1.6:61300/${SAMPLE_TOKEN}/`);
+ expect(result).toEqual({
+ kind: 'web-link',
+ host: '192.168.1.6',
+ port: 61300,
+ token: SAMPLE_TOKEN,
+ });
+ });
+
+ it('ignores a trailing path after the token', () => {
+ const result = parseQrPayload(`http://192.168.1.6:61300/${SAMPLE_TOKEN}/sessions`);
+ expect(result).toEqual({
+ kind: 'web-link',
+ host: '192.168.1.6',
+ port: 61300,
+ token: SAMPLE_TOKEN,
+ });
+ });
+
+ it('accepts uppercase UUID tokens', () => {
+ const upper = SAMPLE_TOKEN.toUpperCase();
+ const result = parseQrPayload(`http://192.168.1.6:61300/${upper}`);
+ expect(result).toEqual({
+ kind: 'web-link',
+ host: '192.168.1.6',
+ port: 61300,
+ token: upper,
+ });
+ });
+
+ it('accepts https web links', () => {
+ const result = parseQrPayload(`https://maestro.local:17170/${SAMPLE_TOKEN}`);
+ expect(result).toEqual({
+ kind: 'web-link',
+ host: 'maestro.local',
+ port: 17170,
+ token: SAMPLE_TOKEN,
+ });
+ });
+
+ it('trims surrounding whitespace before parsing', () => {
+ const result = parseQrPayload(` http://192.168.1.6:61300/${SAMPLE_TOKEN} \n`);
+ expect(result?.kind).toBe('web-link');
+ });
+ });
+
+ describe('invalid payloads', () => {
+ it('returns null when port is missing', () => {
+ expect(parseQrPayload(`http://192.168.1.6/${SAMPLE_TOKEN}`)).toBeNull();
+ });
+
+ it('returns null when token is missing', () => {
+ expect(parseQrPayload('http://192.168.1.6:61300/')).toBeNull();
+ });
+
+ it('returns null when path token is not a UUID', () => {
+ expect(parseQrPayload('http://192.168.1.6:61300/not-a-uuid')).toBeNull();
+ });
+
+ it('returns null for an arbitrary http URL', () => {
+ expect(parseQrPayload('http://example.com:8080/about')).toBeNull();
+ });
+
+ it('returns null for a UUID-v1 token (wrong version nibble)', () => {
+ // v1 token: third group starts with 1 instead of 4
+ expect(
+ parseQrPayload('http://192.168.1.6:61300/232aead9-b3f8-16b0-9c56-4e104498a97e')
+ ).toBeNull();
+ });
+ });
+ });
+
+ describe('invalid URL schemes', () => {
+ it('returns null for wrong scheme prefix', () => {
+ expect(parseQrPayload('other://pair?host=localhost&port=8080&code=X')).toBeNull();
+ });
+
+ it('returns null for maestro:// without pair path', () => {
+ expect(parseQrPayload('maestro://connect?host=localhost&port=8080&code=X')).toBeNull();
+ });
+ });
+
+ describe('malformed inputs', () => {
+ it('returns null for empty string', () => {
+ expect(parseQrPayload('')).toBeNull();
+ });
+
+ it('returns null for null-ish input', () => {
+ // @ts-expect-error - testing runtime behavior
+ expect(parseQrPayload(null)).toBeNull();
+ // @ts-expect-error - testing runtime behavior
+ expect(parseQrPayload(undefined)).toBeNull();
+ });
+
+ it('returns null for plain text', () => {
+ expect(parseQrPayload('hello world')).toBeNull();
+ });
+
+ it('returns null for whitespace-only input', () => {
+ expect(parseQrPayload(' \n\t ')).toBeNull();
+ });
+
+ it('returns null for malformed pair URL', () => {
+ expect(parseQrPayload('maestro://pair?host=localhost&port=')).toBeNull();
+ });
+ });
+});
diff --git a/apps/mobile/src/pairing/parseQrPayload.ts b/apps/mobile/src/pairing/parseQrPayload.ts
new file mode 100644
index 0000000000..b2b01cd802
--- /dev/null
+++ b/apps/mobile/src/pairing/parseQrPayload.ts
@@ -0,0 +1,142 @@
+/**
+ * parseQrPayload - Parse Maestro pairing QR code / URL payloads
+ *
+ * The desktop app exposes two QR codes that a mobile device can use to pair:
+ *
+ * 1. The "Pair New Device" modal generates a short-lived pairing code wrapped
+ * in a custom URL scheme:
+ *
+ * maestro://pair?host=&port=&code=
+ *
+ * The mobile app exchanges with the desktop's
+ * POST /api/mobile-pairing/redeem endpoint for a long-lived (90-day) token.
+ *
+ * 2. The "Persistent Web Link" section shows the underlying web URL containing
+ * the server's security token in the URL path:
+ *
+ * http(s)://:/[/optional-path]
+ *
+ * That token is already a valid credential for the WebSocket route
+ * (`src/main/web-server/routes/wsRoute.ts`), so the mobile app can use it
+ * directly without going through the pair-code redemption flow.
+ *
+ * The parser returns a discriminated union so the caller knows which auth flow
+ * to run. Returning `null` means the input doesn't match either format.
+ *
+ * Part of M3 Mobile Expo App implementation.
+ */
+
+/** Pairing payload from the `maestro://pair?...` QR. Needs to be redeemed. */
+export interface PairCodePayload {
+ kind: 'pair-code';
+ host: string;
+ port: number;
+ code: string;
+}
+
+/** Pairing payload from a desktop web URL. Token is already usable. */
+export interface WebLinkPayload {
+ kind: 'web-link';
+ host: string;
+ port: number;
+ token: string;
+}
+
+/** Discriminated union of the two accepted QR / URL formats. */
+export type QrPairPayload = PairCodePayload | WebLinkPayload;
+
+/**
+ * Parse the raw string from a QR scan or manual entry field. Accepts both
+ * the `maestro://pair?...` pairing-code URL and the desktop's persistent web
+ * link URL. Returns null if neither format is recognised.
+ */
+export function parseQrPayload(data: string): QrPairPayload | null {
+ if (!data) return null;
+
+ const trimmed = data.trim();
+ if (!trimmed) return null;
+
+ if (trimmed.startsWith('maestro://pair')) {
+ return parsePairCodeUrl(trimmed);
+ }
+
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
+ return parseWebLinkUrl(trimmed);
+ }
+
+ return null;
+}
+
+/**
+ * Parse `maestro://pair?host=&port=&code=`.
+ */
+function parsePairCodeUrl(data: string): PairCodePayload | null {
+ let url: URL;
+ try {
+ url = new URL(data);
+ } catch {
+ return null;
+ }
+
+ const host = url.searchParams.get('host');
+ const portStr = url.searchParams.get('port');
+ const code = url.searchParams.get('code');
+ if (!host || !portStr || !code) return null;
+
+ const port = parsePort(portStr);
+ if (port === null) return null;
+
+ if (code.trim().length === 0) return null;
+
+ return { kind: 'pair-code', host, port, code };
+}
+
+/**
+ * Parse `http(s)://:/[/...]`. The token is the first path
+ * segment and must be a UUID v4 (the same shape the desktop generates via
+ * `crypto.randomUUID()` in `web-server-factory.ts`). Requiring UUID v4 keeps
+ * us from accepting random URLs like `http://example.com/about` as credentials.
+ */
+function parseWebLinkUrl(data: string): WebLinkPayload | null {
+ let url: URL;
+ try {
+ url = new URL(data);
+ } catch {
+ return null;
+ }
+
+ const host = url.hostname;
+ if (!host) return null;
+
+ // The desktop web URL always carries an explicit port. `URL.port` is empty
+ // for the default port (80 / 443), but the desktop binds to an ephemeral
+ // port, so a missing port means this URL is not the desktop web link.
+ if (!url.port) return null;
+ const port = parsePort(url.port);
+ if (port === null) return null;
+
+ // First non-empty path segment is the token; ignore any trailing path.
+ const segments = url.pathname.split('/').filter((s) => s.length > 0);
+ const token = segments[0];
+ if (!token) return null;
+ if (!UUID_V4_REGEX.test(token)) return null;
+
+ return { kind: 'web-link', host, port, token };
+}
+
+/**
+ * Strict integer port parser. Rejects floats (parseInt would truncate
+ * `8080.5` to `8080`) and out-of-range values.
+ */
+function parsePort(value: string): number | null {
+ const port = parseInt(value, 10);
+ if (isNaN(port) || port <= 0 || port > 65535) return null;
+ if (value !== String(port)) return null;
+ return port;
+}
+
+/**
+ * UUID v4 shape, matching the regex the desktop's web-server-factory uses to
+ * validate its own token. Lowercase and uppercase both accepted.
+ */
+const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
diff --git a/apps/mobile/src/sf.css b/apps/mobile/src/sf.css
new file mode 100644
index 0000000000..fcd886cae7
--- /dev/null
+++ b/apps/mobile/src/sf.css
@@ -0,0 +1,162 @@
+:root {
+ /* Accent colors */
+ --sf-blue: rgb(0 122 255);
+ --sf-brown: rgb(162 132 94);
+ --sf-cyan: rgb(50 173 230);
+ --sf-green: rgb(52 199 89);
+ --sf-indigo: rgb(88 86 214);
+ --sf-mint: rgb(0 199 190);
+ --sf-orange: rgb(255 149 0);
+ --sf-pink: rgb(255 45 85);
+ --sf-purple: rgb(175 82 222);
+ --sf-red: rgb(255 59 48);
+ --sf-teal: rgb(48 176 199);
+ --sf-yellow: rgb(255 204 0);
+
+ /* Gray scales */
+ --sf-gray: rgb(142 142 147);
+ --sf-gray-2: rgb(174 174 178);
+ --sf-gray-3: rgb(199 199 204);
+ --sf-gray-4: rgb(209 209 214);
+ --sf-gray-5: rgb(229 229 234);
+ --sf-gray-6: rgb(242 242 247);
+
+ /* Text and label colors */
+ --sf-text: rgb(0 0 0);
+ --sf-text-2: rgb(60 60 67 / 0.6);
+ --sf-text-3: rgb(60 60 67 / 0.3);
+ --sf-text-4: rgb(60 60 67 / 0.18);
+ --sf-text-placeholder: rgb(60 60 67 / 0.3);
+ --sf-text-dark: rgb(0 0 0);
+ --sf-text-light: rgb(255 255 255 / 0.6);
+
+ /* Fill colors */
+ --sf-fill: rgb(120 120 128 / 0.2);
+ --sf-fill-2: rgb(120 120 128 / 0.16);
+ --sf-fill-3: rgb(118 118 128 / 0.12);
+ --sf-fill-4: rgb(116 116 128 / 0.08);
+
+ /* Background colors */
+ --sf-bg: rgb(255 255 255);
+ --sf-bg-2: rgb(242 242 247);
+ --sf-bg-3: rgb(255 255 255);
+ --sf-grouped-bg: rgb(242 242 247);
+ --sf-grouped-bg-2: rgb(255 255 255);
+ --sf-grouped-bg-3: rgb(242 242 247);
+
+ /* Border and link colors */
+ --sf-border: rgb(60 60 67 / 0.29);
+ --sf-border-opaque: rgb(198 198 198);
+ --sf-link: rgb(0 122 255);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ /* Accent colors */
+ --sf-blue: rgb(10 132 255);
+ --sf-brown: rgb(172 142 104);
+ --sf-cyan: rgb(100 210 255);
+ --sf-green: rgb(48 209 89);
+ --sf-indigo: rgb(94 92 230);
+ --sf-mint: rgb(99 230 226);
+ --sf-orange: rgb(255 159 10);
+ --sf-pink: rgb(255 55 95);
+ --sf-purple: rgb(191 90 242);
+ --sf-red: rgb(255 69 58);
+ --sf-teal: rgb(64 200 224);
+ --sf-yellow: rgb(255 214 10);
+
+ /* Gray scales */
+ --sf-gray: rgb(142 142 147);
+ --sf-gray-2: rgb(99 99 102);
+ --sf-gray-3: rgb(72 72 74);
+ --sf-gray-4: rgb(58 58 60);
+ --sf-gray-5: rgb(44 44 46);
+ --sf-gray-6: rgb(28 28 30);
+
+ /* Text and label colors */
+ --sf-text: rgb(255 255 255);
+ --sf-text-2: rgb(235 235 245 / 0.6);
+ --sf-text-3: rgb(235 235 245 / 0.3);
+ --sf-text-4: rgb(235 235 245 / 0.16);
+ --sf-text-placeholder: rgb(235 235 245 / 0.3);
+ --sf-text-dark: rgb(0 0 0);
+ --sf-text-light: rgb(255 255 255 / 0.6);
+
+ /* Fill colors */
+ --sf-fill: rgb(120 120 128 / 0.36);
+ --sf-fill-2: rgb(120 120 128 / 0.32);
+ --sf-fill-3: rgb(118 118 128 / 0.24);
+ --sf-fill-4: rgb(118 118 128 / 0.18);
+
+ /* Background colors */
+ --sf-bg: rgb(0 0 0);
+ --sf-bg-2: rgb(28 28 30);
+ --sf-bg-3: rgb(44 44 46);
+ --sf-grouped-bg: rgb(0 0 0);
+ --sf-grouped-bg-2: rgb(28 28 30);
+ --sf-grouped-bg-3: rgb(44 44 46);
+
+ /* Border and link colors */
+ --sf-border: rgb(84 84 89 / 0.6);
+ --sf-border-opaque: rgb(56 56 58);
+ --sf-link: rgb(10 133 255);
+ }
+}
+
+@layer theme {
+ @theme {
+ /* Fonts */
+ --font-rounded: 'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif;
+
+ /* Accent colors */
+ --color-sf-blue: var(--sf-blue) /* rgba(0, 122, 255, 1) */;
+ --color-sf-brown: var(--sf-brown) /* rgba(162, 132, 94, 1) */;
+ --color-sf-cyan: var(--sf-cyan) /* rgba(50, 173, 230, 1) */;
+ --color-sf-green: var(--sf-green) /* rgba(52, 199, 89, 1) */;
+ --color-sf-indigo: var(--sf-indigo) /* rgba(88, 86, 214, 1) */;
+ --color-sf-mint: var(--sf-mint) /* rgba(0, 199, 190, 1) */;
+ --color-sf-orange: var(--sf-orange) /* rgba(255, 149, 0, 1) */;
+ --color-sf-pink: var(--sf-pink) /* rgba(255, 45, 85, 1) */;
+ --color-sf-purple: var(--sf-purple) /* rgba(175, 82, 222, 1) */;
+ --color-sf-red: var(--sf-red) /* rgba(255, 59, 48, 1) */;
+ --color-sf-teal: var(--sf-teal) /* rgba(48, 176, 199, 1) */;
+ --color-sf-yellow: var(--sf-yellow) /* rgba(255, 204, 0, 1) */;
+
+ /* Gray scales */
+ --color-sf-gray: var(--sf-gray) /* rgba(142, 142, 147, 1) */;
+ --color-sf-gray-2: var(--sf-gray-2) /* rgba(174, 174, 178, 1) */;
+ --color-sf-gray-3: var(--sf-gray-3) /* rgba(199, 199, 204, 1) */;
+ --color-sf-gray-4: var(--sf-gray-4) /* rgba(209, 209, 214, 1) */;
+ --color-sf-gray-5: var(--sf-gray-5) /* rgba(229, 229, 234, 1) */;
+ --color-sf-gray-6: var(--sf-gray-6) /* rgba(242, 242, 247, 1) */;
+
+ /* Text and label colors */
+ --color-sf-text: var(--sf-text) /* rgba(0, 0, 0, 1) */;
+ --color-sf-text-2: var(--sf-text-2) /* rgba(61.2, 61.2, 66, 0.6) */;
+ --color-sf-text-3: var(--sf-text-3) /* rgba(61.2, 61.2, 66, 0.3) */;
+ --color-sf-text-4: var(--sf-text-4) /* rgba(61.2, 61.2, 66, 0.18) */;
+ --color-sf-text-placeholder: var(--sf-text-placeholder) /* rgba(61.2, 61.2, 66, 0.3) */;
+ --color-sf-text-dark: var(--sf-text-dark) /* rgba(0, 0, 0, 1) */;
+ --color-sf-text-light: var(--sf-text-light) /* rgba(255, 255, 255, 0.6) */;
+
+ /* Fill colors */
+ --color-sf-fill: var(--sf-fill) /* rgba(119.85, 119.85, 127.5, 0.2) */;
+ --color-sf-fill-2: var(--sf-fill-2) /* rgba(119.85, 119.85, 127.5, 0.16) */;
+ --color-sf-fill-3: var(--sf-fill-3) /* rgba(117.3, 117.3, 127.5, 0.12) */;
+ --color-sf-fill-4: var(--sf-fill-4) /* rgba(114.75, 114.75, 127.5, 0.08) */;
+
+ /* Background colors */
+ --color-sf-bg: var(--sf-bg) /* rgba(255, 255, 255, 1) */;
+ --color-sf-bg-2: var(--sf-bg-2) /* rgba(242.25, 242.25, 247.35, 1) */;
+ --color-sf-bg-3: var(--sf-bg-3) /* rgba(255, 255, 255, 1) */;
+ --color-sf-grouped-bg: var(--sf-grouped-bg) /* rgba(242.25, 242.25, 247.35, 1) */;
+ --color-sf-grouped-bg-2: var(--sf-grouped-bg-2) /* rgba(255, 255, 255, 1) */;
+ --color-sf-grouped-bg-3: var(--sf-grouped-bg-3) /* rgba(242.25, 242.25, 247.35, 1) */;
+
+ /* Border and link colors */
+ --color-sf-border: var(--sf-border) /* rgba(61.2, 61.2, 66, 0.29) */;
+ --color-sf-border-opaque: var(--sf-border-opaque) /* rgba(198.9, 198.9, 198.9, 1) */;
+ --color-sf-link: var(--sf-link) /* rgba(0, 122.4, 255, 1) */;
+ }
+}
diff --git a/apps/mobile/src/storage/__tests__/asyncStorageAdapter.test.ts b/apps/mobile/src/storage/__tests__/asyncStorageAdapter.test.ts
new file mode 100644
index 0000000000..7284d77e1a
--- /dev/null
+++ b/apps/mobile/src/storage/__tests__/asyncStorageAdapter.test.ts
@@ -0,0 +1,188 @@
+/**
+ * Tests for asyncStorageAdapter
+ *
+ * Verifies round-trip set/get/remove operations, null handling for missing keys,
+ * and JSON-serializable value support.
+ *
+ * Since AsyncStorage is a native module, we mock @react-native-async-storage/async-storage
+ * to test the adapter's behavior in isolation.
+ */
+
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { asyncStorageAdapter } from '../asyncStorageAdapter';
+
+// Jest already sets up the mock via jest.setup.ts or jest-expo preset
+// We just verify the adapter delegates correctly
+
+describe('asyncStorageAdapter', () => {
+ beforeEach(() => {
+ // Clear all mock data between tests
+ jest.clearAllMocks();
+ });
+
+ describe('interface compliance', () => {
+ it('exports getItem, setItem, and removeItem methods', () => {
+ expect(asyncStorageAdapter.getItem).toBeDefined();
+ expect(asyncStorageAdapter.setItem).toBeDefined();
+ expect(asyncStorageAdapter.removeItem).toBeDefined();
+ });
+
+ it('getItem returns a Promise', () => {
+ const result = asyncStorageAdapter.getItem('test-key');
+ expect(result).toBeInstanceOf(Promise);
+ });
+
+ it('setItem returns a Promise', () => {
+ const result = asyncStorageAdapter.setItem('test-key', 'test-value');
+ expect(result).toBeInstanceOf(Promise);
+ });
+
+ it('removeItem returns a Promise', () => {
+ const result = asyncStorageAdapter.removeItem('test-key');
+ expect(result).toBeInstanceOf(Promise);
+ });
+ });
+
+ describe('round-trip operations', () => {
+ it('stores and retrieves a simple string', async () => {
+ await asyncStorageAdapter.setItem('simple', 'hello');
+ const result = await asyncStorageAdapter.getItem('simple');
+ expect(result).toBe('hello');
+ });
+
+ it('stores and retrieves an empty string', async () => {
+ await asyncStorageAdapter.setItem('empty', '');
+ const result = await asyncStorageAdapter.getItem('empty');
+ expect(result).toBe('');
+ });
+
+ it('stores and retrieves a JSON-serialized object', async () => {
+ const obj = { foo: 'bar', count: 42 };
+ await asyncStorageAdapter.setItem('json-obj', JSON.stringify(obj));
+ const result = await asyncStorageAdapter.getItem('json-obj');
+ expect(JSON.parse(result!)).toEqual(obj);
+ });
+
+ it('stores and retrieves a JSON-serialized array', async () => {
+ const arr = [1, 2, 3, 'four', { five: 5 }];
+ await asyncStorageAdapter.setItem('json-arr', JSON.stringify(arr));
+ const result = await asyncStorageAdapter.getItem('json-arr');
+ expect(JSON.parse(result!)).toEqual(arr);
+ });
+
+ it('stores and retrieves nested JSON structures', async () => {
+ const nested = {
+ level1: {
+ level2: {
+ level3: {
+ value: 'deep',
+ },
+ },
+ },
+ array: [{ a: 1 }, { b: 2 }],
+ };
+ await asyncStorageAdapter.setItem('nested', JSON.stringify(nested));
+ const result = await asyncStorageAdapter.getItem('nested');
+ expect(JSON.parse(result!)).toEqual(nested);
+ });
+
+ it('overwrites existing values', async () => {
+ await asyncStorageAdapter.setItem('overwrite', 'first');
+ await asyncStorageAdapter.setItem('overwrite', 'second');
+ const result = await asyncStorageAdapter.getItem('overwrite');
+ expect(result).toBe('second');
+ });
+
+ it('handles unicode strings', async () => {
+ const unicode = '🎉 Hello, 世界! مرحبا';
+ await asyncStorageAdapter.setItem('unicode', unicode);
+ const result = await asyncStorageAdapter.getItem('unicode');
+ expect(result).toBe(unicode);
+ });
+
+ it('handles large JSON payloads', async () => {
+ const largeArray = Array.from({ length: 1000 }, (_, i) => ({
+ id: i,
+ value: `item-${i}`,
+ timestamp: Date.now(),
+ }));
+ await asyncStorageAdapter.setItem('large', JSON.stringify(largeArray));
+ const result = await asyncStorageAdapter.getItem('large');
+ expect(JSON.parse(result!)).toHaveLength(1000);
+ });
+ });
+
+ describe('missing keys', () => {
+ it('returns null for a key that was never set', async () => {
+ const result = await asyncStorageAdapter.getItem('nonexistent-key');
+ expect(result).toBeNull();
+ });
+
+ it('returns null for a key that was removed', async () => {
+ await asyncStorageAdapter.setItem('to-remove', 'value');
+ await asyncStorageAdapter.removeItem('to-remove');
+ const result = await asyncStorageAdapter.getItem('to-remove');
+ expect(result).toBeNull();
+ });
+
+ it('does not throw when removing a nonexistent key', async () => {
+ await expect(asyncStorageAdapter.removeItem('never-existed')).resolves.not.toThrow();
+ });
+ });
+
+ describe('remove operations', () => {
+ it('removes a specific key without affecting others', async () => {
+ await asyncStorageAdapter.setItem('keep', 'kept');
+ await asyncStorageAdapter.setItem('remove', 'removed');
+ await asyncStorageAdapter.removeItem('remove');
+
+ const kept = await asyncStorageAdapter.getItem('keep');
+ const removed = await asyncStorageAdapter.getItem('remove');
+
+ expect(kept).toBe('kept');
+ expect(removed).toBeNull();
+ });
+
+ it('allows re-setting a key after removal', async () => {
+ await asyncStorageAdapter.setItem('reuse', 'first');
+ await asyncStorageAdapter.removeItem('reuse');
+ await asyncStorageAdapter.setItem('reuse', 'second');
+ const result = await asyncStorageAdapter.getItem('reuse');
+ expect(result).toBe('second');
+ });
+ });
+
+ describe('key isolation', () => {
+ it('stores different values for different keys', async () => {
+ await asyncStorageAdapter.setItem('key1', 'value1');
+ await asyncStorageAdapter.setItem('key2', 'value2');
+
+ expect(await asyncStorageAdapter.getItem('key1')).toBe('value1');
+ expect(await asyncStorageAdapter.getItem('key2')).toBe('value2');
+ });
+
+ it('handles keys with special characters', async () => {
+ const specialKey = 'maestro.pairing.active:device-123';
+ await asyncStorageAdapter.setItem(specialKey, 'special-value');
+ const result = await asyncStorageAdapter.getItem(specialKey);
+ expect(result).toBe('special-value');
+ });
+ });
+
+ describe('delegation to AsyncStorage', () => {
+ it('delegates getItem to AsyncStorage.getItem', async () => {
+ await asyncStorageAdapter.getItem('test');
+ expect(AsyncStorage.getItem).toHaveBeenCalledWith('test');
+ });
+
+ it('delegates setItem to AsyncStorage.setItem', async () => {
+ await asyncStorageAdapter.setItem('test', 'value');
+ expect(AsyncStorage.setItem).toHaveBeenCalledWith('test', 'value');
+ });
+
+ it('delegates removeItem to AsyncStorage.removeItem', async () => {
+ await asyncStorageAdapter.removeItem('test');
+ expect(AsyncStorage.removeItem).toHaveBeenCalledWith('test');
+ });
+ });
+});
diff --git a/apps/mobile/src/storage/asyncStorageAdapter.ts b/apps/mobile/src/storage/asyncStorageAdapter.ts
new file mode 100644
index 0000000000..e90542a7f8
--- /dev/null
+++ b/apps/mobile/src/storage/asyncStorageAdapter.ts
@@ -0,0 +1,29 @@
+/**
+ * asyncStorageAdapter - Native implementation of the storage contract for React Native
+ *
+ * This adapter implements the StorageAdapter interface defined in
+ * src/web/hooks/useOfflineQueue.ts, allowing the useOfflineQueue hook to persist
+ * queued commands to AsyncStorage on React Native.
+ *
+ * The interface contract:
+ * - getItem(key): Promise
+ * - setItem(key, value): Promise
+ * - removeItem(key): Promise
+ *
+ * This enables the same hook to work across web (localStorage) and mobile (AsyncStorage).
+ */
+
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import type { StorageAdapter } from '@maestro/web-hooks/useOfflineQueue';
+
+/**
+ * AsyncStorage adapter for React Native that implements the StorageAdapter interface.
+ * Delegates directly to AsyncStorage methods with matching signatures.
+ */
+export const asyncStorageAdapter: StorageAdapter = {
+ getItem: AsyncStorage.getItem,
+ setItem: AsyncStorage.setItem,
+ removeItem: AsyncStorage.removeItem,
+};
+
+export default asyncStorageAdapter;
diff --git a/apps/mobile/src/streaming/__tests__/streamingReconciliation.test.ts b/apps/mobile/src/streaming/__tests__/streamingReconciliation.test.ts
new file mode 100644
index 0000000000..e77528bb78
--- /dev/null
+++ b/apps/mobile/src/streaming/__tests__/streamingReconciliation.test.ts
@@ -0,0 +1,528 @@
+/**
+ * Tests for streaming message reconciliation
+ *
+ * Verifies the core M1 streaming logic:
+ * - Assistant text (SESSION_OUTPUT) appends to buffer
+ * - Tool events render as discrete items, NOT buffer appends
+ * - session_state_change to idle commits buffer to history
+ */
+
+import {
+ streamingReducer,
+ createInitialState,
+ selectStreamingContent,
+ selectIsStreaming,
+ selectCommittedMessages,
+ type StreamingState,
+ type StreamingAction,
+} from '../reconcileStreamingMessage';
+
+describe('streamingReconciliation', () => {
+ describe('initial state', () => {
+ it('creates empty initial state', () => {
+ const state = createInitialState();
+ expect(state.messages).toEqual([]);
+ expect(state.streamingBuffer).toBe('');
+ expect(state.streamingMessageId).toBeNull();
+ expect(state.isGenerating).toBe(false);
+ });
+ });
+
+ describe('SESSION_OUTPUT events', () => {
+ it('creates streaming message on first AI output', () => {
+ const state = createInitialState();
+ const action: StreamingAction = {
+ type: 'SESSION_OUTPUT',
+ data: 'Hello',
+ source: 'ai',
+ timestamp: 1000,
+ };
+
+ const next = streamingReducer(state, action);
+
+ expect(next.messages).toHaveLength(1);
+ expect(next.messages[0].role).toBe('assistant');
+ expect(next.messages[0].content).toBe(''); // Placeholder, actual content in buffer
+ expect(next.streamingBuffer).toBe('Hello');
+ expect(next.streamingMessageId).toBe('assistant-1000');
+ expect(next.isGenerating).toBe(true);
+ });
+
+ it('appends to buffer on subsequent AI output', () => {
+ let state = createInitialState();
+
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: 'Hello',
+ source: 'ai',
+ timestamp: 1000,
+ });
+
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: ' world',
+ source: 'ai',
+ timestamp: 1001,
+ });
+
+ expect(state.messages).toHaveLength(1); // Still just one message
+ expect(state.streamingBuffer).toBe('Hello world');
+ });
+
+ it('accumulates multiple chunks correctly', () => {
+ let state = createInitialState();
+ const chunks = ['I ', 'am ', 'streaming ', 'text.'];
+
+ for (let i = 0; i < chunks.length; i++) {
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: chunks[i],
+ source: 'ai',
+ timestamp: 1000 + i,
+ });
+ }
+
+ expect(state.streamingBuffer).toBe('I am streaming text.');
+ });
+
+ it('ignores terminal output in AI chat', () => {
+ const state = createInitialState();
+ const action: StreamingAction = {
+ type: 'SESSION_OUTPUT',
+ data: 'terminal stuff',
+ source: 'terminal',
+ timestamp: 1000,
+ };
+
+ const next = streamingReducer(state, action);
+
+ expect(next.messages).toHaveLength(0);
+ expect(next.streamingBuffer).toBe('');
+ expect(next.streamingMessageId).toBeNull();
+ });
+ });
+
+ describe('TOOL_EVENT events', () => {
+ it('adds tool event as discrete message, not buffer append', () => {
+ let state = createInitialState();
+
+ // Start streaming some text
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: 'Let me help...',
+ source: 'ai',
+ timestamp: 1000,
+ });
+
+ // Tool event arrives
+ state = streamingReducer(state, {
+ type: 'TOOL_EVENT',
+ toolId: 'tool-123',
+ toolName: 'ReadFile',
+ status: 'running',
+ });
+
+ // Should have 2 messages: streaming + tool
+ expect(state.messages).toHaveLength(2);
+ expect(state.messages[1].id).toBe('tool-tool-123');
+ expect(state.messages[1].content).toBe('Running: ReadFile');
+
+ // Buffer should be unchanged
+ expect(state.streamingBuffer).toBe('Let me help...');
+ });
+
+ it('updates existing tool message on status change', () => {
+ let state = createInitialState();
+
+ // Tool starts running
+ state = streamingReducer(state, {
+ type: 'TOOL_EVENT',
+ toolId: 'tool-456',
+ toolName: 'Write',
+ status: 'running',
+ });
+
+ expect(state.messages[0].content).toBe('Running: Write');
+
+ // Tool completes
+ state = streamingReducer(state, {
+ type: 'TOOL_EVENT',
+ toolId: 'tool-456',
+ toolName: 'Write',
+ status: 'completed',
+ });
+
+ expect(state.messages).toHaveLength(1); // Still one message
+ expect(state.messages[0].content).toBe('Completed: Write');
+ });
+
+ it('renders error status correctly', () => {
+ let state = createInitialState();
+
+ state = streamingReducer(state, {
+ type: 'TOOL_EVENT',
+ toolId: 'tool-789',
+ toolName: 'Execute',
+ status: 'error',
+ });
+
+ expect(state.messages[0].content).toBe('Error: Execute');
+ });
+
+ it('handles multiple concurrent tools', () => {
+ let state = createInitialState();
+
+ state = streamingReducer(state, {
+ type: 'TOOL_EVENT',
+ toolId: 'tool-a',
+ toolName: 'Read',
+ status: 'running',
+ });
+
+ state = streamingReducer(state, {
+ type: 'TOOL_EVENT',
+ toolId: 'tool-b',
+ toolName: 'Search',
+ status: 'running',
+ });
+
+ state = streamingReducer(state, {
+ type: 'TOOL_EVENT',
+ toolId: 'tool-a',
+ toolName: 'Read',
+ status: 'completed',
+ });
+
+ expect(state.messages).toHaveLength(2);
+ expect(state.messages[0].content).toBe('Completed: Read');
+ expect(state.messages[1].content).toBe('Running: Search');
+ });
+ });
+
+ describe('SESSION_STATE_CHANGE to idle', () => {
+ it('commits streaming buffer to message history', () => {
+ let state = createInitialState();
+
+ // Stream some content
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: 'Final answer.',
+ source: 'ai',
+ timestamp: 1000,
+ });
+
+ expect(state.messages[0].content).toBe(''); // Placeholder
+
+ // Session goes idle
+ state = streamingReducer(state, {
+ type: 'SESSION_STATE_CHANGE',
+ state: 'idle',
+ });
+
+ expect(state.messages[0].content).toBe('Final answer.');
+ expect(state.streamingBuffer).toBe('');
+ expect(state.streamingMessageId).toBeNull();
+ expect(state.isGenerating).toBe(false);
+ });
+
+ it('commits on ready state as well', () => {
+ let state = createInitialState();
+
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: 'Done.',
+ source: 'ai',
+ timestamp: 2000,
+ });
+
+ state = streamingReducer(state, {
+ type: 'SESSION_STATE_CHANGE',
+ state: 'ready',
+ });
+
+ expect(state.messages[0].content).toBe('Done.');
+ expect(state.isGenerating).toBe(false);
+ });
+
+ it('handles idle with no buffer gracefully', () => {
+ const state = createInitialState();
+
+ const next = streamingReducer(state, {
+ type: 'SESSION_STATE_CHANGE',
+ state: 'idle',
+ });
+
+ expect(next.messages).toHaveLength(0);
+ expect(next.isGenerating).toBe(false);
+ });
+
+ it('sets isGenerating true on running state', () => {
+ const state = createInitialState();
+
+ const next = streamingReducer(state, {
+ type: 'SESSION_STATE_CHANGE',
+ state: 'running',
+ });
+
+ expect(next.isGenerating).toBe(true);
+ });
+
+ it('sets isGenerating true on busy state', () => {
+ const state = createInitialState();
+
+ const next = streamingReducer(state, {
+ type: 'SESSION_STATE_CHANGE',
+ state: 'busy',
+ });
+
+ expect(next.isGenerating).toBe(true);
+ });
+ });
+
+ describe('USER_INPUT events', () => {
+ it('adds user message to history', () => {
+ const state = createInitialState();
+
+ const next = streamingReducer(state, {
+ type: 'USER_INPUT',
+ command: 'Hello!',
+ timestamp: 5000,
+ });
+
+ expect(next.messages).toHaveLength(1);
+ expect(next.messages[0].id).toBe('user-5000');
+ expect(next.messages[0].role).toBe('user');
+ expect(next.messages[0].content).toBe('Hello!');
+ });
+
+ it('interleaves user and assistant messages', () => {
+ let state = createInitialState();
+
+ state = streamingReducer(state, {
+ type: 'USER_INPUT',
+ command: 'Question?',
+ timestamp: 1000,
+ });
+
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: 'Answer.',
+ source: 'ai',
+ timestamp: 2000,
+ });
+
+ state = streamingReducer(state, {
+ type: 'SESSION_STATE_CHANGE',
+ state: 'idle',
+ });
+
+ state = streamingReducer(state, {
+ type: 'USER_INPUT',
+ command: 'Follow-up?',
+ timestamp: 3000,
+ });
+
+ expect(state.messages).toHaveLength(3);
+ expect(state.messages[0].role).toBe('user');
+ expect(state.messages[1].role).toBe('assistant');
+ expect(state.messages[2].role).toBe('user');
+ });
+ });
+
+ describe('CLEAR action', () => {
+ it('resets to initial state', () => {
+ let state = createInitialState();
+
+ state = streamingReducer(state, {
+ type: 'USER_INPUT',
+ command: 'test',
+ timestamp: 1000,
+ });
+
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: 'response',
+ source: 'ai',
+ timestamp: 2000,
+ });
+
+ state = streamingReducer(state, { type: 'CLEAR' });
+
+ expect(state).toEqual(createInitialState());
+ });
+ });
+
+ describe('selectors', () => {
+ it('selectStreamingContent returns buffer content', () => {
+ let state = createInitialState();
+
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: 'streaming...',
+ source: 'ai',
+ timestamp: 1000,
+ });
+
+ expect(selectStreamingContent(state)).toBe('streaming...');
+ });
+
+ it('selectIsStreaming returns true when streaming', () => {
+ let state = createInitialState();
+ expect(selectIsStreaming(state)).toBe(false);
+
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: 'x',
+ source: 'ai',
+ timestamp: 1000,
+ });
+
+ expect(selectIsStreaming(state)).toBe(true);
+ });
+
+ it('selectCommittedMessages excludes in-flight streaming message', () => {
+ let state = createInitialState();
+
+ state = streamingReducer(state, {
+ type: 'USER_INPUT',
+ command: 'test',
+ timestamp: 1000,
+ });
+
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: 'partial...',
+ source: 'ai',
+ timestamp: 2000,
+ });
+
+ const committed = selectCommittedMessages(state);
+ expect(committed).toHaveLength(1);
+ expect(committed[0].role).toBe('user');
+ });
+
+ it('selectCommittedMessages includes all after commit', () => {
+ let state = createInitialState();
+
+ state = streamingReducer(state, {
+ type: 'USER_INPUT',
+ command: 'test',
+ timestamp: 1000,
+ });
+
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: 'done',
+ source: 'ai',
+ timestamp: 2000,
+ });
+
+ state = streamingReducer(state, {
+ type: 'SESSION_STATE_CHANGE',
+ state: 'idle',
+ });
+
+ const committed = selectCommittedMessages(state);
+ expect(committed).toHaveLength(2);
+ });
+ });
+
+ describe('complex sequences', () => {
+ it('handles full conversation with tools', () => {
+ let state = createInitialState();
+
+ // User asks a question
+ state = streamingReducer(state, {
+ type: 'USER_INPUT',
+ command: 'Read my file',
+ timestamp: 1000,
+ });
+
+ // Assistant starts responding
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: 'Let me read that...',
+ source: 'ai',
+ timestamp: 2000,
+ });
+
+ // Tool starts
+ state = streamingReducer(state, {
+ type: 'TOOL_EVENT',
+ toolId: 'read-1',
+ toolName: 'Read',
+ status: 'running',
+ });
+
+ // Tool completes
+ state = streamingReducer(state, {
+ type: 'TOOL_EVENT',
+ toolId: 'read-1',
+ toolName: 'Read',
+ status: 'completed',
+ });
+
+ // More streaming output
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: ' Here is the content.',
+ source: 'ai',
+ timestamp: 3000,
+ });
+
+ // Session goes idle
+ state = streamingReducer(state, {
+ type: 'SESSION_STATE_CHANGE',
+ state: 'idle',
+ });
+
+ expect(state.messages).toHaveLength(3);
+ expect(state.messages[0].role).toBe('user');
+ expect(state.messages[1].role).toBe('assistant');
+ expect(state.messages[1].content).toBe('Let me read that... Here is the content.');
+ expect(state.messages[2].content).toBe('Completed: Read');
+ expect(state.isGenerating).toBe(false);
+ });
+
+ it('handles multiple conversation turns', () => {
+ let state = createInitialState();
+
+ // Turn 1
+ state = streamingReducer(state, {
+ type: 'USER_INPUT',
+ command: 'Hello',
+ timestamp: 1000,
+ });
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: 'Hi there!',
+ source: 'ai',
+ timestamp: 2000,
+ });
+ state = streamingReducer(state, {
+ type: 'SESSION_STATE_CHANGE',
+ state: 'idle',
+ });
+
+ // Turn 2
+ state = streamingReducer(state, {
+ type: 'USER_INPUT',
+ command: 'How are you?',
+ timestamp: 3000,
+ });
+ state = streamingReducer(state, {
+ type: 'SESSION_OUTPUT',
+ data: "I'm doing well!",
+ source: 'ai',
+ timestamp: 4000,
+ });
+ state = streamingReducer(state, {
+ type: 'SESSION_STATE_CHANGE',
+ state: 'idle',
+ });
+
+ expect(state.messages).toHaveLength(4);
+ expect(state.messages.map((m) => m.role)).toEqual(['user', 'assistant', 'user', 'assistant']);
+ });
+ });
+});
diff --git a/apps/mobile/src/streaming/index.ts b/apps/mobile/src/streaming/index.ts
new file mode 100644
index 0000000000..f380aa2b21
--- /dev/null
+++ b/apps/mobile/src/streaming/index.ts
@@ -0,0 +1,16 @@
+/**
+ * Streaming module - Pure functions for message reconciliation
+ *
+ * This module exports the core streaming logic extracted from useSessionChat
+ * for testability and potential reuse.
+ */
+
+export {
+ streamingReducer,
+ createInitialState,
+ selectStreamingContent,
+ selectIsStreaming,
+ selectCommittedMessages,
+ type StreamingState,
+ type StreamingAction,
+} from './reconcileStreamingMessage';
diff --git a/apps/mobile/src/streaming/reconcileStreamingMessage.ts b/apps/mobile/src/streaming/reconcileStreamingMessage.ts
new file mode 100644
index 0000000000..b5616e51f8
--- /dev/null
+++ b/apps/mobile/src/streaming/reconcileStreamingMessage.ts
@@ -0,0 +1,229 @@
+/**
+ * Streaming message reconciliation logic
+ *
+ * This module extracts the core M1 streaming logic from the useSessionChat hook
+ * into pure, testable functions. These functions determine how incoming WebSocket
+ * events are reconciled into chat messages:
+ *
+ * 1. session_output (source: 'ai') - appends text to the streaming buffer
+ * 2. tool_event - renders as discrete list items, NOT buffer appends
+ * 3. session_state_change (to idle) - commits buffer to message history
+ *
+ * The reconciliation is implemented as a reducer pattern for testability.
+ */
+
+import type { ChatMessage } from '../components/chat/types';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface StreamingState {
+ /** Current messages in the chat */
+ messages: ChatMessage[];
+ /** In-flight streaming buffer content */
+ streamingBuffer: string;
+ /** ID of the current streaming assistant message, if any */
+ streamingMessageId: string | null;
+ /** Whether the assistant is currently generating */
+ isGenerating: boolean;
+}
+
+export type StreamingAction =
+ | {
+ type: 'SESSION_OUTPUT';
+ data: string;
+ source: 'ai' | 'terminal';
+ timestamp: number;
+ }
+ | {
+ type: 'TOOL_EVENT';
+ toolId: string;
+ toolName: string;
+ status: 'running' | 'completed' | 'error';
+ }
+ | {
+ type: 'SESSION_STATE_CHANGE';
+ state: 'idle' | 'ready' | 'running' | 'busy';
+ }
+ | {
+ type: 'USER_INPUT';
+ command: string;
+ timestamp: number;
+ }
+ | {
+ type: 'CLEAR';
+ };
+
+// ============================================================================
+// Initial State
+// ============================================================================
+
+export function createInitialState(): StreamingState {
+ return {
+ messages: [],
+ streamingBuffer: '',
+ streamingMessageId: null,
+ isGenerating: false,
+ };
+}
+
+// ============================================================================
+// Reducer
+// ============================================================================
+
+/**
+ * Reconciles streaming events into chat message state.
+ *
+ * Key behaviors:
+ * - SESSION_OUTPUT (ai): Creates streaming message on first chunk, appends to buffer on subsequent
+ * - TOOL_EVENT: Adds/updates tool status as discrete messages (not buffer appends)
+ * - SESSION_STATE_CHANGE (idle/ready): Commits streaming buffer to final message
+ * - USER_INPUT: Adds user message to history
+ * - CLEAR: Resets all state
+ */
+export function streamingReducer(state: StreamingState, action: StreamingAction): StreamingState {
+ switch (action.type) {
+ case 'SESSION_OUTPUT': {
+ // Ignore terminal output in AI chat
+ if (action.source !== 'ai') {
+ return state;
+ }
+
+ // If no streaming message exists, create one
+ if (!state.streamingMessageId) {
+ const messageId = `assistant-${action.timestamp}`;
+ return {
+ ...state,
+ messages: [...state.messages, { id: messageId, role: 'assistant', content: '' }],
+ streamingBuffer: action.data,
+ streamingMessageId: messageId,
+ isGenerating: true,
+ };
+ }
+
+ // Append to existing buffer
+ return {
+ ...state,
+ streamingBuffer: state.streamingBuffer + action.data,
+ };
+ }
+
+ case 'TOOL_EVENT': {
+ const toolMessageId = `tool-${action.toolId}`;
+ const toolContent =
+ action.status === 'running'
+ ? `Running: ${action.toolName}`
+ : action.status === 'completed'
+ ? `Completed: ${action.toolName}`
+ : `Error: ${action.toolName}`;
+
+ // Check if tool message already exists
+ const existingIdx = state.messages.findIndex((m) => m.id === toolMessageId);
+
+ if (existingIdx >= 0) {
+ // Update existing tool message
+ const updated = [...state.messages];
+ updated[existingIdx] = {
+ ...updated[existingIdx],
+ content: toolContent,
+ };
+ return {
+ ...state,
+ messages: updated,
+ };
+ }
+
+ // Add new tool message (does NOT append to streaming buffer)
+ return {
+ ...state,
+ messages: [
+ ...state.messages,
+ { id: toolMessageId, role: 'assistant', content: toolContent },
+ ],
+ };
+ }
+
+ case 'SESSION_STATE_CHANGE': {
+ if (action.state === 'idle' || action.state === 'ready') {
+ // Commit streaming buffer to final message
+ if (state.streamingMessageId && state.streamingBuffer) {
+ const finalContent = state.streamingBuffer;
+ const msgId = state.streamingMessageId;
+
+ const updated = state.messages.map((m) =>
+ m.id === msgId ? { ...m, content: finalContent } : m
+ );
+
+ return {
+ ...state,
+ messages: updated,
+ streamingBuffer: '',
+ streamingMessageId: null,
+ isGenerating: false,
+ };
+ }
+
+ // No buffer to commit, just stop generating
+ return {
+ ...state,
+ isGenerating: false,
+ };
+ }
+
+ if (action.state === 'running' || action.state === 'busy') {
+ return {
+ ...state,
+ isGenerating: true,
+ };
+ }
+
+ return state;
+ }
+
+ case 'USER_INPUT': {
+ const messageId = `user-${action.timestamp}`;
+ return {
+ ...state,
+ messages: [...state.messages, { id: messageId, role: 'user', content: action.command }],
+ };
+ }
+
+ case 'CLEAR': {
+ return createInitialState();
+ }
+
+ default:
+ return state;
+ }
+}
+
+// ============================================================================
+// Selectors
+// ============================================================================
+
+/**
+ * Returns the current streaming buffer content for UI display.
+ * The actual message in state has empty content while streaming;
+ * the buffer provides the live text.
+ */
+export function selectStreamingContent(state: StreamingState): string {
+ return state.streamingBuffer;
+}
+
+/**
+ * Returns true if a streaming message is in progress.
+ */
+export function selectIsStreaming(state: StreamingState): boolean {
+ return state.streamingMessageId !== null;
+}
+
+/**
+ * Returns only committed messages (excludes the in-flight streaming placeholder).
+ */
+export function selectCommittedMessages(state: StreamingState): ChatMessage[] {
+ if (!state.streamingMessageId) {
+ return state.messages;
+ }
+ return state.messages.filter((m) => m.id !== state.streamingMessageId);
+}
diff --git a/apps/mobile/src/theme/AccentContext.tsx b/apps/mobile/src/theme/AccentContext.tsx
new file mode 100644
index 0000000000..808b941067
--- /dev/null
+++ b/apps/mobile/src/theme/AccentContext.tsx
@@ -0,0 +1,97 @@
+/**
+ * AccentContext - Bridges accent color from Maestro desktop theme
+ *
+ * Per decision 5C: accent color comes from the Maestro desktop theme via WebSocket,
+ * while surfaces/text remain chat-template's neutral light/dark system.
+ *
+ * The accent applies to:
+ * - Drawer row active state
+ * - Tab strip active pill
+ * - Send button
+ * - Link color
+ * - Streaming cursor on in-flight assistant bubble
+ *
+ * Usage:
+ * const { accentColor } = useAccent();
+ * // accentColor is a hex string like '#6366f1'
+ */
+
+import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
+import type { Theme } from '@maestro/shared/theme-types';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface AccentContextValue {
+ /** The current accent color hex string from the Maestro theme */
+ accentColor: string;
+ /** Dimmed accent color (with alpha) for subtle backgrounds */
+ accentColorDim: string;
+ /** Text color to use ON accent backgrounds (contrasting) */
+ accentForeground: string;
+ /** The full theme object from Maestro (for advanced use cases) */
+ theme: Theme | null;
+ /** Update the theme (called by WebSocket handler) */
+ setTheme: (theme: Theme) => void;
+}
+
+// ============================================================================
+// Default accent color
+// ============================================================================
+
+/** Default accent color when no Maestro theme has been received yet (indigo-500) */
+const DEFAULT_ACCENT = '#6366f1';
+/** Default dimmed accent */
+const DEFAULT_ACCENT_DIM = 'rgba(99, 102, 241, 0.2)';
+/** Default accent foreground (white for good contrast) */
+const DEFAULT_ACCENT_FOREGROUND = '#ffffff';
+
+// ============================================================================
+// Context
+// ============================================================================
+
+const AccentContext = createContext(null);
+
+// ============================================================================
+// Provider
+// ============================================================================
+
+interface AccentProviderProps {
+ children: React.ReactNode;
+}
+
+export function AccentProvider({ children }: AccentProviderProps) {
+ const [theme, setThemeState] = useState(null);
+
+ const setTheme = useCallback((newTheme: Theme) => {
+ setThemeState(newTheme);
+ }, []);
+
+ const value = useMemo((): AccentContextValue => {
+ const colors = theme?.colors;
+ return {
+ accentColor: colors?.accent || DEFAULT_ACCENT,
+ accentColorDim: colors?.accentDim || DEFAULT_ACCENT_DIM,
+ accentForeground: colors?.accentForeground || DEFAULT_ACCENT_FOREGROUND,
+ theme,
+ setTheme,
+ };
+ }, [theme, setTheme]);
+
+ return {children};
+}
+
+// ============================================================================
+// Hook
+// ============================================================================
+
+export function useAccent(): AccentContextValue {
+ const context = useContext(AccentContext);
+ if (!context) {
+ throw new Error('useAccent must be used within an AccentProvider');
+ }
+ return context;
+}
+
+export default AccentContext;
diff --git a/apps/mobile/src/utils/mock-chats.ts b/apps/mobile/src/utils/mock-chats.ts
new file mode 100644
index 0000000000..7e202ac6f4
--- /dev/null
+++ b/apps/mobile/src/utils/mock-chats.ts
@@ -0,0 +1,78 @@
+export type MockChat = {
+ id: string;
+ title: string;
+ daysAgo: number;
+ starred: boolean;
+};
+
+export const MOCK_CHATS: MockChat[] = [
+ { id: '1', title: 'Expo Job offer', daysAgo: 5, starred: false },
+ {
+ id: '2',
+ title: 'Existing tools for iOS app tech stack detection',
+ daysAgo: 5,
+ starred: false,
+ },
+ {
+ id: '3',
+ title: 'Headless iOS simulator gateway for concurrent testing',
+ daysAgo: 7,
+ starred: false,
+ },
+ { id: '4', title: 'Top three.js projects', daysAgo: 7, starred: true },
+ { id: '5', title: 'Austin magician review', daysAgo: 7, starred: false },
+ {
+ id: '6',
+ title: 'Expo agent GitHub bot description',
+ daysAgo: 14,
+ starred: false,
+ },
+ {
+ id: '7',
+ title: 'Building an iMessage bot with Claude',
+ daysAgo: 14,
+ starred: true,
+ },
+ {
+ id: '8',
+ title: 'Conditional HMR disabling in web frameworks',
+ daysAgo: 14,
+ starred: false,
+ },
+ {
+ id: '10',
+ title: 'Optimizing parallel git config queries',
+ daysAgo: 14,
+ starred: false,
+ },
+ {
+ id: '11',
+ title: 'Choosing between Tailwind and StyleX',
+ daysAgo: 21,
+ starred: false,
+ },
+ {
+ id: '12',
+ title: 'Structuring messages and timelines',
+ daysAgo: 28,
+ starred: false,
+ },
+ {
+ id: '13',
+ title: 'SVG morphing animation between shapes',
+ daysAgo: 28,
+ starred: false,
+ },
+ {
+ id: '14',
+ title: 'Expo navigation patterns',
+ daysAgo: 30,
+ starred: false,
+ },
+ {
+ id: '15',
+ title: 'Debugging Expo CLI',
+ daysAgo: 35,
+ starred: false,
+ },
+];
diff --git a/apps/mobile/src/utils/tailwind.ts b/apps/mobile/src/utils/tailwind.ts
new file mode 100644
index 0000000000..7637fb9e6d
--- /dev/null
+++ b/apps/mobile/src/utils/tailwind.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/apps/mobile/src/utils/use-system-background-color.ts b/apps/mobile/src/utils/use-system-background-color.ts
new file mode 100644
index 0000000000..ff18c9fa59
--- /dev/null
+++ b/apps/mobile/src/utils/use-system-background-color.ts
@@ -0,0 +1,10 @@
+import * as SystemUI from 'expo-system-ui';
+import { useEffect } from 'react';
+import { useCSSVariable } from 'uniwind';
+
+export function useSystemBackgroundColor() {
+ const color = useCSSVariable('--app-background');
+ useEffect(() => {
+ SystemUI.setBackgroundColorAsync(color as unknown as string);
+ }, [color]);
+}
diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json
new file mode 100644
index 0000000000..451afe73fd
--- /dev/null
+++ b/apps/mobile/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true,
+ "ignoreDeprecations": "6.0",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"],
+ "@maestro/shared/*": ["../../src/shared/*"],
+ "@maestro/web-hooks/*": ["../../src/web/hooks/*"]
+ }
+ },
+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
+ "exclude": ["**/*.test.ts", "**/*.test.tsx", "**/__tests__/**", "jest.setup.ts"]
+}
diff --git a/apps/mobile/uniwind-types.d.ts b/apps/mobile/uniwind-types.d.ts
new file mode 100644
index 0000000000..cc099419a9
--- /dev/null
+++ b/apps/mobile/uniwind-types.d.ts
@@ -0,0 +1,10 @@
+// NOTE: This file is generated by uniwind and it should not be edited manually.
+///
+
+declare module 'uniwind' {
+ export interface UniwindConfig {
+ themes: readonly ['light', 'dark']
+ }
+}
+
+export {}
diff --git a/eslint.config.mjs b/eslint.config.mjs
index f5a7b33b8b..4d5e3bc022 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -22,6 +22,7 @@ export default tseslint.config(
'src/web/public/**', // Service worker and static assets
'src/renderer/public/**', // Static browser scripts (splash, devtools)
'.cue-migration-backup-*/**', // Git-ignored migration backup snapshots
+ 'apps/**', // Sibling apps (mobile) have their own lint config
],
},
diff --git a/package-lock.json b/package-lock.json
index de25daa825..95b28ec406 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "maestro",
- "version": "0.17.0",
+ "version": "0.17.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "maestro",
- "version": "0.17.0",
+ "version": "0.17.1",
"hasInstallScript": true,
"license": "AGPL 3.0",
"dependencies": {
@@ -127,7 +127,7 @@
"canvas": "^3.2.0",
"concurrently": "^8.2.2",
"cross-env": "^7.0.3",
- "electron": "^41.5.0",
+ "electron": "^41.8.0",
"electron-builder": "^26.8.1",
"electron-devtools-installer": "^4.0.0",
"electron-rebuild": "^3.2.9",
@@ -990,6 +990,16 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/@electron-internal/extract-zip": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.3.tgz",
+ "integrity": "sha512-OjKpjB7gohtEjZiq6nDx1egqjZJhGPN1iFOIED+NFhB/MMkXw/XRcHjh1DGXKT5z2W9eW7Jy2UKU3gpjvusFTQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
"node_modules/@electron/asar": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
@@ -1126,35 +1136,48 @@
}
},
"node_modules/@electron/get": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
- "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz",
+ "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
- "env-paths": "^2.2.0",
- "fs-extra": "^8.1.0",
- "got": "^11.8.5",
+ "env-paths": "^3.0.0",
+ "graceful-fs": "^4.2.11",
"progress": "^2.0.3",
- "semver": "^6.2.0",
+ "semver": "^7.6.3",
"sumchecker": "^3.0.1"
},
"engines": {
- "node": ">=12"
+ "node": ">=22.12.0"
},
"optionalDependencies": {
- "global-agent": "^3.0.0"
+ "undici": "^7.24.4"
}
},
- "node_modules/@electron/get/node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "node_modules/@electron/get/node_modules/env-paths": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
+ "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
"dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@electron/get/node_modules/undici": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+ "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=20.18.1"
}
},
"node_modules/@electron/notarize": {
@@ -5302,17 +5325,6 @@
"@types/node": "*"
}
},
- "node_modules/@types/yauzl": {
- "version": "2.10.3",
- "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
- "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@types/node": "*"
- }
- },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz",
@@ -9313,22 +9325,22 @@
}
},
"node_modules/electron": {
- "version": "41.6.0",
- "resolved": "https://registry.npmjs.org/electron/-/electron-41.6.0.tgz",
- "integrity": "sha512-5UWV5FXkYMzCDV6FvLCa5mzlCBtlX/H1Af27TD5di+4CUCPi0/ZmWPBdSBlYrunsBlUvlcpW8X0NurW4zesQ6g==",
+ "version": "41.8.0",
+ "resolved": "https://registry.npmjs.org/electron/-/electron-41.8.0.tgz",
+ "integrity": "sha512-mQRqdFxB6/EAyA9pPn00EC1KFytTijOQO52zBvl9HypWBvvibC3P4iSrT4CSVVksolOPKZZm8U4Cfjb5IHxZVA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
- "@electron/get": "^2.0.0",
- "@types/node": "^24.9.0",
- "extract-zip": "^2.0.1"
+ "@electron-internal/extract-zip": "^1.0.1",
+ "@electron/get": "^5.0.0",
+ "@types/node": "^24.9.0"
},
"bin": {
"electron": "cli.js"
},
"engines": {
- "node": ">= 12.20.55"
+ "node": ">= 22.12.0"
}
},
"node_modules/electron-builder": {
@@ -10456,27 +10468,6 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
- "node_modules/extract-zip": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
- "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "debug": "^4.1.1",
- "get-stream": "^5.1.0",
- "yauzl": "^2.10.0"
- },
- "bin": {
- "extract-zip": "cli.js"
- },
- "engines": {
- "node": ">= 10.17.0"
- },
- "optionalDependencies": {
- "@types/yauzl": "^2.9.1"
- }
- },
"node_modules/extsprintf": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz",
@@ -10690,16 +10681,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/fd-slicer": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
- "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "pend": "~1.2.0"
- }
- },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -10943,21 +10924,6 @@
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
- "node_modules/fs-extra": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
- "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^4.0.0",
- "universalify": "^0.1.0"
- },
- "engines": {
- "node": ">=6 <7 || >=8"
- }
- },
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
@@ -16234,13 +16200,6 @@
"url": "https://github.com/sponsors/jet2jet"
}
},
- "node_modules/pend": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
- "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/perfect-freehand": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.3.tgz",
@@ -21073,27 +21032,6 @@
"node": ">=12"
}
},
- "node_modules/yauzl": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
- "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "buffer-crc32": "~0.2.3",
- "fd-slicer": "~1.1.0"
- }
- },
- "node_modules/yauzl/node_modules/buffer-crc32": {
- "version": "0.2.13",
- "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
- "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "*"
- }
- },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/package.json b/package.json
index a572600eea..f59d851ff4 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,9 @@
"dev:main:prod-data": "tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development USE_PROD_DATA=1 electron .",
"dev:renderer": "vite",
"dev:web": "vite --config vite.config.web.mts",
+ "dev:mobile:ios": "cd apps/mobile && npx expo run:ios --device 'iPhone 16 Pro'",
+ "dev:mobile:android": "cd apps/mobile && npx expo run:android",
+ "dev:mobile:start": "cd apps/mobile && npx expo start --dev-client",
"dev:win": "powershell -NoProfile -ExecutionPolicy Bypass -File ./scripts/start-dev.ps1",
"build": "npm run build:main && npm run build:preload && npm run build:renderer && npm run build:web && npm run build:cli && npm run build:maestro-p",
"build:main": "tsc -p tsconfig.main.json",
@@ -43,6 +46,7 @@
"postinstall": "patch-package && electron-rebuild -f -w node-pty,better-sqlite3",
"lint": "tsc -p tsconfig.lint.json && tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.cli.json --noEmit",
"lint:eslint": "eslint src/",
+ "lint:mobile": "cd apps/mobile && npx eslint .",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:all": "prettier --write .",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
@@ -57,6 +61,7 @@
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:integration:watch": "vitest --config vitest.integration.config.ts",
"test:performance": "vitest run --config vitest.performance.config.mts",
+ "test:mobile": "cd apps/mobile && npm test",
"refresh-speckit": "node scripts/refresh-speckit.mjs",
"refresh-openspec": "node scripts/refresh-openspec.mjs",
"refresh-bmad": "node scripts/refresh-bmad.mjs"
@@ -372,7 +377,7 @@
"canvas": "^3.2.0",
"concurrently": "^8.2.2",
"cross-env": "^7.0.3",
- "electron": "^41.5.0",
+ "electron": "^41.8.0",
"electron-builder": "^26.8.1",
"electron-devtools-installer": "^4.0.0",
"electron-rebuild": "^3.2.9",
diff --git a/src/__tests__/main/mobile-pairing.test.ts b/src/__tests__/main/mobile-pairing.test.ts
new file mode 100644
index 0000000000..05d015a42e
--- /dev/null
+++ b/src/__tests__/main/mobile-pairing.test.ts
@@ -0,0 +1,273 @@
+/**
+ * Unit tests for src/main/mobile-pairing/index.ts
+ *
+ * Covers code generation, redeem (success/expired/used/concurrent), token
+ * validation (timing-safe, expiry filter), updateDeviceLastUsed, revokeDevice,
+ * and listPairedDevices (hides hashes + expired entries).
+ */
+
+import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest';
+import { mkdtempSync, rmSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { readFile } from 'fs/promises';
+
+const tmpRoot = mkdtempSync(join(tmpdir(), 'maestro-mobile-pairing-test-'));
+
+vi.mock('electron', () => ({
+ app: {
+ getPath: vi.fn(() => tmpRoot),
+ isPackaged: false,
+ },
+}));
+
+// Re-import inside each describe so the in-memory pendingPairings map is fresh
+// per test (the module keeps it at module scope).
+async function freshModule() {
+ vi.resetModules();
+ return await import('../../main/mobile-pairing');
+}
+
+afterAll(() => {
+ rmSync(tmpRoot, { recursive: true, force: true });
+});
+
+describe('mobile-pairing', () => {
+ beforeEach(() => {
+ // Clear the pairings file between tests so list/validate start clean.
+ try {
+ rmSync(join(tmpRoot, 'mobile-pairings.json'), { force: true });
+ } catch {
+ // not present
+ }
+ });
+
+ describe('generatePairingCode', () => {
+ it('returns a 6-character base32 code, future expiry, and a 256-bit pending token', async () => {
+ const { generatePairingCode } = await freshModule();
+ const before = Date.now();
+ const { code, expiresAt, pendingToken } = generatePairingCode();
+
+ expect(code).toMatch(/^[A-Z2-7]{6}$/);
+ expect(expiresAt).toBeGreaterThan(before);
+ // 32 random bytes hex-encoded = 64 chars
+ expect(pendingToken).toMatch(/^[a-f0-9]{64}$/);
+ });
+
+ it('returns different codes on consecutive calls', async () => {
+ const { generatePairingCode } = await freshModule();
+ const a = generatePairingCode();
+ const b = generatePairingCode();
+ expect(a.code).not.toBe(b.code);
+ expect(a.pendingToken).not.toBe(b.pendingToken);
+ });
+ });
+
+ describe('redeemPairingCode', () => {
+ it('redeems a freshly generated code and persists a hashed device record', async () => {
+ const mod = await freshModule();
+ const { generatePairingCode, redeemPairingCode } = mod;
+ const { code, pendingToken } = generatePairingCode();
+
+ const result = await redeemPairingCode(code, 'Pixel 9');
+ expect(result).not.toBeNull();
+ expect(result!.token).toBe(pendingToken);
+ expect(result!.deviceId).toMatch(/^[0-9a-f-]{36}$/);
+
+ // Token must be stored as a SHA-256 hex hash, never plaintext.
+ const raw = await readFile(join(tmpRoot, 'mobile-pairings.json'), 'utf-8');
+ expect(raw).not.toContain(pendingToken);
+ expect(raw).toContain('"tokenHash"');
+ });
+
+ it('rejects an unknown code', async () => {
+ const { redeemPairingCode } = await freshModule();
+ expect(await redeemPairingCode('AAAAAA', 'phone')).toBeNull();
+ });
+
+ it('rejects malformed codes (length, charset)', async () => {
+ const { redeemPairingCode } = await freshModule();
+ expect(await redeemPairingCode('SHORT', 'phone')).toBeNull();
+ expect(await redeemPairingCode('TOOLONGCODE', 'phone')).toBeNull();
+ // '0' and '1' are not in base32 alphabet
+ expect(await redeemPairingCode('ABC011', 'phone')).toBeNull();
+ expect(await redeemPairingCode('', 'phone')).toBeNull();
+ });
+
+ it('normalizes lowercase + whitespace to match the canonical code', async () => {
+ const { generatePairingCode, redeemPairingCode } = await freshModule();
+ const { code } = generatePairingCode();
+ const r = await redeemPairingCode(` ${code.toLowerCase()} `, 'phone');
+ expect(r).not.toBeNull();
+ });
+
+ it('cannot be redeemed twice', async () => {
+ const { generatePairingCode, redeemPairingCode } = await freshModule();
+ const { code } = generatePairingCode();
+ const first = await redeemPairingCode(code, 'phone-a');
+ const second = await redeemPairingCode(code, 'phone-b');
+ expect(first).not.toBeNull();
+ expect(second).toBeNull();
+ });
+
+ it('rejects expired codes', async () => {
+ const { generatePairingCode, redeemPairingCode } = await freshModule();
+ const { code } = generatePairingCode();
+ // 5 minutes + 1 ms past the issue moment
+ vi.useFakeTimers();
+ vi.advanceTimersByTime(5 * 60 * 1000 + 1);
+ const result = await redeemPairingCode(code, 'phone');
+ vi.useRealTimers();
+ expect(result).toBeNull();
+ });
+
+ it('truncates an oversized deviceName to 200 chars and trims', async () => {
+ const { generatePairingCode, redeemPairingCode, listPairedDevices } = await freshModule();
+ const { code } = generatePairingCode();
+ const huge = ' ' + 'x'.repeat(10_000) + ' ';
+ const r = await redeemPairingCode(code, huge);
+ expect(r).not.toBeNull();
+ const devices = await listPairedDevices();
+ expect(devices).toHaveLength(1);
+ expect(devices[0].deviceName.length).toBe(200);
+ });
+
+ it('falls back to "Unknown Device" for empty/whitespace deviceName', async () => {
+ const { generatePairingCode, redeemPairingCode, listPairedDevices } = await freshModule();
+ const { code } = generatePairingCode();
+ await redeemPairingCode(code, ' ');
+ const devices = await listPairedDevices();
+ expect(devices[0].deviceName).toBe('Unknown Device');
+ });
+
+ it('serializes concurrent redemptions of distinct codes without losing writes', async () => {
+ const { generatePairingCode, redeemPairingCode, listPairedDevices } = await freshModule();
+
+ const codes = Array.from({ length: 8 }, () => generatePairingCode().code);
+ const results = await Promise.all(codes.map((c, i) => redeemPairingCode(c, `device-${i}`)));
+ expect(results.every((r) => r && r.token && r.deviceId)).toBe(true);
+ const devices = await listPairedDevices();
+ expect(devices).toHaveLength(8);
+ expect(new Set(devices.map((d) => d.id)).size).toBe(8);
+ });
+ });
+
+ describe('validateMobileToken', () => {
+ it('accepts a valid token and returns the matching device', async () => {
+ const { generatePairingCode, redeemPairingCode, validateMobileToken } = await freshModule();
+ const { code } = generatePairingCode();
+ const { token, deviceId } = (await redeemPairingCode(code, 'phone'))!;
+
+ const device = await validateMobileToken(token);
+ expect(device).not.toBeNull();
+ expect(device!.id).toBe(deviceId);
+ });
+
+ it('rejects an unknown token', async () => {
+ const { generatePairingCode, redeemPairingCode, validateMobileToken } = await freshModule();
+ const { code } = generatePairingCode();
+ await redeemPairingCode(code, 'phone');
+ expect(await validateMobileToken('a'.repeat(64))).toBeNull();
+ });
+
+ it('rejects empty / non-string input', async () => {
+ const { validateMobileToken } = await freshModule();
+ expect(await validateMobileToken('')).toBeNull();
+ // @ts-expect-error – exercise runtime guard for non-string input
+ expect(await validateMobileToken(123)).toBeNull();
+ });
+
+ it('rejects tokens for devices whose expiry has passed', async () => {
+ const { generatePairingCode, redeemPairingCode, validateMobileToken } = await freshModule();
+ const { code } = generatePairingCode();
+ const { token } = (await redeemPairingCode(code, 'phone'))!;
+
+ vi.useFakeTimers();
+ // 90-day token TTL + 1ms
+ vi.advanceTimersByTime(90 * 24 * 60 * 60 * 1000 + 1);
+ const result = await validateMobileToken(token);
+ vi.useRealTimers();
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('updateDeviceLastUsed', () => {
+ it('bumps lastUsedAt on the matching device', async () => {
+ const { generatePairingCode, redeemPairingCode, listPairedDevices, updateDeviceLastUsed } =
+ await freshModule();
+ const { code } = generatePairingCode();
+ const { deviceId } = (await redeemPairingCode(code, 'phone'))!;
+ const before = (await listPairedDevices()).find((d) => d.id === deviceId)!.lastUsedAt;
+
+ await new Promise((r) => setTimeout(r, 5));
+ await updateDeviceLastUsed(deviceId);
+
+ const after = (await listPairedDevices()).find((d) => d.id === deviceId)!.lastUsedAt;
+ expect(after).toBeGreaterThanOrEqual(before);
+ });
+
+ it('is a no-op for an unknown device id', async () => {
+ const { updateDeviceLastUsed, listPairedDevices } = await freshModule();
+ await updateDeviceLastUsed('does-not-exist');
+ expect(await listPairedDevices()).toEqual([]);
+ });
+
+ it('does not lose a concurrent revoke', async () => {
+ const {
+ generatePairingCode,
+ redeemPairingCode,
+ updateDeviceLastUsed,
+ revokeDevice,
+ listPairedDevices,
+ } = await freshModule();
+ const a = generatePairingCode().code;
+ const b = generatePairingCode().code;
+ const ra = (await redeemPairingCode(a, 'a'))!;
+ const rb = (await redeemPairingCode(b, 'b'))!;
+
+ await Promise.all([updateDeviceLastUsed(ra.deviceId), revokeDevice(rb.deviceId)]);
+
+ const remaining = await listPairedDevices();
+ expect(remaining.map((d) => d.id)).toEqual([ra.deviceId]);
+ });
+ });
+
+ describe('listPairedDevices', () => {
+ it('omits tokenHash from the returned shape', async () => {
+ const { generatePairingCode, redeemPairingCode, listPairedDevices } = await freshModule();
+ const { code } = generatePairingCode();
+ await redeemPairingCode(code, 'phone');
+ const devices = await listPairedDevices();
+ expect(devices[0]).not.toHaveProperty('tokenHash');
+ });
+
+ it('filters out expired devices', async () => {
+ const { generatePairingCode, redeemPairingCode, listPairedDevices } = await freshModule();
+ const { code } = generatePairingCode();
+ await redeemPairingCode(code, 'phone');
+
+ vi.useFakeTimers();
+ vi.advanceTimersByTime(90 * 24 * 60 * 60 * 1000 + 1);
+ const devices = await listPairedDevices();
+ vi.useRealTimers();
+ expect(devices).toEqual([]);
+ });
+ });
+
+ describe('revokeDevice', () => {
+ it('removes a device by id and returns true', async () => {
+ const { generatePairingCode, redeemPairingCode, listPairedDevices, revokeDevice } =
+ await freshModule();
+ const { code } = generatePairingCode();
+ const { deviceId } = (await redeemPairingCode(code, 'phone'))!;
+
+ expect(await revokeDevice(deviceId)).toBe(true);
+ expect(await listPairedDevices()).toEqual([]);
+ });
+
+ it('returns false for an unknown id', async () => {
+ const { revokeDevice } = await freshModule();
+ expect(await revokeDevice('does-not-exist')).toBe(false);
+ });
+ });
+});
diff --git a/src/__tests__/main/web-server/routes/wsRoute.test.ts b/src/__tests__/main/web-server/routes/wsRoute.test.ts
index b17eb34b1a..28a6afc05c 100644
--- a/src/__tests__/main/web-server/routes/wsRoute.test.ts
+++ b/src/__tests__/main/web-server/routes/wsRoute.test.ts
@@ -28,6 +28,12 @@ vi.mock('../../../../main/utils/logger', () => ({
},
}));
+// Mock mobile-pairing module
+vi.mock('../../../../main/mobile-pairing', () => ({
+ validateMobileToken: vi.fn().mockResolvedValue(null),
+ updateDeviceLastUsed: vi.fn().mockResolvedValue(undefined),
+}));
+
/**
* Create mock callbacks with all methods as vi.fn()
*/
@@ -95,6 +101,7 @@ function createMockSocket() {
return {
readyState: WebSocket.OPEN,
send: vi.fn(),
+ close: vi.fn(),
on: vi.fn((event: string, handler: Function) => {
if (!eventHandlers.has(event)) {
eventHandlers.set(event, []);
@@ -121,13 +128,14 @@ function createMockConnection() {
/**
* Create mock Fastify request
*/
-function createMockRequest(sessionId?: string) {
+function createMockRequest(sessionId?: string, token = 'test-token-123') {
const queryString = sessionId ? `?sessionId=${sessionId}` : '';
return {
- url: `/test-token/ws${queryString}`,
+ url: `/${token}/ws${queryString}`,
headers: {
host: 'localhost:3000',
},
+ ip: '127.0.0.1',
};
}
@@ -166,18 +174,18 @@ describe('WsRoute', () => {
describe('Route Registration', () => {
it('should register WebSocket route with correct path', () => {
expect(mockFastify.get).toHaveBeenCalledTimes(1);
- expect(mockFastify.routes.has(`GET:/${securityToken}/ws`)).toBe(true);
+ expect(mockFastify.routes.has('GET:/:token/ws')).toBe(true);
});
it('should register route with websocket option', () => {
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
expect(route?.options?.websocket).toBe(true);
});
});
describe('Connection Handling', () => {
it('should generate unique client IDs', () => {
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
// Connect first client
const conn1 = createMockConnection();
@@ -197,7 +205,7 @@ describe('WsRoute', () => {
});
it('should notify parent on client connect', () => {
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
route!.handler(connection, createMockRequest());
@@ -211,7 +219,7 @@ describe('WsRoute', () => {
});
it('should extract sessionId from query string', () => {
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
route!.handler(connection, createMockRequest('session-123'));
@@ -223,7 +231,7 @@ describe('WsRoute', () => {
});
it('should set subscribedSessionId to undefined when not in query', () => {
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
route!.handler(connection, createMockRequest());
@@ -237,7 +245,7 @@ describe('WsRoute', () => {
describe('Initial Sync Messages', () => {
it('should send connected message', () => {
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
route!.handler(connection, createMockRequest('session-123'));
@@ -253,7 +261,7 @@ describe('WsRoute', () => {
});
it('should send sessions_list with enriched live info', () => {
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
route!.handler(connection, createMockRequest());
@@ -270,7 +278,7 @@ describe('WsRoute', () => {
});
it('should send theme', () => {
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
route!.handler(connection, createMockRequest());
@@ -286,7 +294,7 @@ describe('WsRoute', () => {
it('should not send theme when null', () => {
(callbacks.getTheme as any).mockReturnValue(null);
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
route!.handler(connection, createMockRequest());
@@ -299,7 +307,7 @@ describe('WsRoute', () => {
});
it('should send custom_commands', () => {
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
route!.handler(connection, createMockRequest());
@@ -314,7 +322,7 @@ describe('WsRoute', () => {
});
it('should send autorun_state for running sessions', () => {
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
route!.handler(connection, createMockRequest());
@@ -344,7 +352,7 @@ describe('WsRoute', () => {
])
);
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
route!.handler(connection, createMockRequest());
@@ -359,7 +367,7 @@ describe('WsRoute', () => {
describe('Message Handling', () => {
it('should delegate messages to handleMessage callback', () => {
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
route!.handler(connection, createMockRequest());
@@ -373,7 +381,7 @@ describe('WsRoute', () => {
});
it('should send error for invalid JSON messages', () => {
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
route!.handler(connection, createMockRequest());
@@ -392,7 +400,7 @@ describe('WsRoute', () => {
describe('Disconnection Handling', () => {
it('should notify parent on client disconnect', () => {
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
route!.handler(connection, createMockRequest());
@@ -407,7 +415,7 @@ describe('WsRoute', () => {
describe('Error Handling', () => {
it('should notify parent on client error', () => {
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
route!.handler(connection, createMockRequest());
@@ -428,7 +436,7 @@ describe('WsRoute', () => {
const emptyFastify = createMockFastify();
emptyWsRoute.registerRoute(emptyFastify as any);
- const route = emptyFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = emptyFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
// Should not throw
@@ -461,7 +469,7 @@ describe('WsRoute', () => {
const partialFastify = createMockFastify();
partialWsRoute.registerRoute(partialFastify as any);
- const route = partialFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = partialFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
// Should not throw
@@ -481,7 +489,7 @@ describe('WsRoute', () => {
])
);
- const route = mockFastify.getRoute('GET', `/${securityToken}/ws`);
+ const route = mockFastify.getRoute('GET', '/:token/ws');
const connection = createMockConnection();
route!.handler(connection, createMockRequest());
@@ -494,4 +502,96 @@ describe('WsRoute', () => {
expect(autoRunMsgs.map((m: any) => m.sessionId)).toEqual(['session-1', 'session-2']);
});
});
+
+ describe('Mobile Token Authentication', () => {
+ beforeEach(async () => {
+ const { validateMobileToken, updateDeviceLastUsed } =
+ await import('../../../../main/mobile-pairing');
+ (validateMobileToken as any).mockClear();
+ (updateDeviceLastUsed as any).mockClear();
+ });
+
+ it('should accept valid mobile token', async () => {
+ const { validateMobileToken, updateDeviceLastUsed } =
+ await import('../../../../main/mobile-pairing');
+ const mockDevice = { id: 'device-123', deviceName: 'iPhone' };
+ (validateMobileToken as any).mockResolvedValueOnce(mockDevice);
+
+ const route = mockFastify.getRoute('GET', '/:token/ws');
+ const connection = createMockConnection();
+ // Use a different token than the security token
+ await route!.handler(connection, createMockRequest(undefined, 'mobile-token-abc'));
+
+ expect(validateMobileToken).toHaveBeenCalledWith('mobile-token-abc');
+ expect(updateDeviceLastUsed).toHaveBeenCalledWith('device-123');
+ expect(callbacks.onClientConnect).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isMobileClient: true,
+ mobileDeviceId: 'device-123',
+ })
+ );
+ });
+
+ it('should reject invalid mobile token', async () => {
+ const { validateMobileToken } = await import('../../../../main/mobile-pairing');
+ (validateMobileToken as any).mockResolvedValueOnce(null);
+
+ const route = mockFastify.getRoute('GET', '/:token/ws');
+ const connection = createMockConnection();
+ await route!.handler(connection, createMockRequest(undefined, 'invalid-token'));
+
+ expect(validateMobileToken).toHaveBeenCalledWith('invalid-token');
+ expect(callbacks.onClientConnect).not.toHaveBeenCalled();
+
+ const sentMessages = (connection.socket.send as any).mock.calls.map((call: any[]) =>
+ JSON.parse(call[0])
+ );
+ const errorMsg = sentMessages.find((m: any) => m.type === 'error');
+ expect(errorMsg).toBeDefined();
+ expect(errorMsg.code).toBe('AUTH_FAILED');
+ expect(connection.socket.close).toHaveBeenCalledWith(4001, 'Authentication failed');
+ });
+
+ it('should not call validateMobileToken for valid security token', async () => {
+ const { validateMobileToken } = await import('../../../../main/mobile-pairing');
+
+ const route = mockFastify.getRoute('GET', '/:token/ws');
+ const connection = createMockConnection();
+ // Use the correct security token
+ await route!.handler(connection, createMockRequest());
+
+ expect(validateMobileToken).not.toHaveBeenCalled();
+ // Browser clients don't have mobile-specific fields
+ const clientArg = (callbacks.onClientConnect as any).mock.calls[0][0];
+ expect(clientArg.isMobileClient).toBeFalsy();
+ expect(clientArg.mobileDeviceId).toBeFalsy();
+ });
+
+ it('should use web-client prefix for browser connections', async () => {
+ const route = mockFastify.getRoute('GET', '/:token/ws');
+ const connection = createMockConnection();
+ await route!.handler(connection, createMockRequest());
+
+ expect(callbacks.onClientConnect).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: expect.stringMatching(/^web-client-/),
+ })
+ );
+ });
+
+ it('should use mobile-client prefix for mobile connections', async () => {
+ const { validateMobileToken } = await import('../../../../main/mobile-pairing');
+ (validateMobileToken as any).mockResolvedValueOnce({ id: 'device-1', deviceName: 'Phone' });
+
+ const route = mockFastify.getRoute('GET', '/:token/ws');
+ const connection = createMockConnection();
+ await route!.handler(connection, createMockRequest(undefined, 'mobile-token'));
+
+ expect(callbacks.onClientConnect).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: expect.stringMatching(/^mobile-client-/),
+ })
+ );
+ });
+ });
});
diff --git a/src/__tests__/web/hooks/useOfflineQueue.test.ts b/src/__tests__/web/hooks/useOfflineQueue.test.ts
deleted file mode 100644
index 19ed15e01f..0000000000
--- a/src/__tests__/web/hooks/useOfflineQueue.test.ts
+++ /dev/null
@@ -1,1785 +0,0 @@
-/**
- * Tests for useOfflineQueue hook
- *
- * @fileoverview Comprehensive tests for offline command queueing functionality.
- * Tests cover:
- * - Pure helper functions (generateId, loadQueue, saveQueue)
- * - Hook initialization and state management
- * - Command queueing with capacity limits
- * - Command removal and queue clearing
- * - Queue processing with retries and error handling
- * - Pause/resume functionality
- * - Auto-processing on connection restore
- * - localStorage persistence
- */
-
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { renderHook, act, waitFor } from '@testing-library/react';
-import {
- useOfflineQueue,
- QueuedCommand,
- QueueStatus,
- UseOfflineQueueOptions,
- UseOfflineQueueReturn,
-} from '../../../web/hooks/useOfflineQueue';
-
-// Mock the webLogger module
-vi.mock('../../../web/utils/logger', () => ({
- webLogger: {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- },
-}));
-
-import { webLogger } from '../../../web/utils/logger';
-
-const STORAGE_KEY = 'maestro-offline-queue';
-const MAX_QUEUE_SIZE = 50;
-
-// Mock localStorage with proper implementation
-let localStorageStore: Record = {};
-
-// Create mock functions that also perform the actual storage operations
-const getItemMock = vi.fn().mockImplementation((key: string) => localStorageStore[key] ?? null);
-const setItemMock = vi.fn().mockImplementation((key: string, value: string) => {
- localStorageStore[key] = value;
-});
-const removeItemMock = vi.fn().mockImplementation((key: string) => {
- delete localStorageStore[key];
-});
-const clearMock = vi.fn().mockImplementation(() => {
- localStorageStore = {};
-});
-const keyMock = vi
- .fn()
- .mockImplementation((index: number) => Object.keys(localStorageStore)[index] ?? null);
-
-const localStorageMock = {
- getItem: getItemMock,
- setItem: setItemMock,
- removeItem: removeItemMock,
- clear: clearMock,
- get length() {
- return Object.keys(localStorageStore).length;
- },
- key: keyMock,
-};
-
-Object.defineProperty(window, 'localStorage', {
- value: localStorageMock,
- writable: true,
-});
-
-describe('useOfflineQueue', () => {
- // Default options for creating the hook
- const createDefaultOptions = (
- overrides: Partial = {}
- ): UseOfflineQueueOptions => ({
- isOnline: true,
- isConnected: true,
- sendCommand: vi.fn().mockReturnValue(true),
- ...overrides,
- });
-
- beforeEach(() => {
- // Clear localStorage mock store before each test
- localStorageStore = {};
- getItemMock.mockClear();
- setItemMock.mockClear();
- clearMock.mockClear();
- removeItemMock.mockClear();
- keyMock.mockClear();
- // Clear all mocks
- vi.clearAllMocks();
- // Use fake timers for testing async behavior
- vi.useFakeTimers();
- });
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- describe('Exported Types', () => {
- it('should export QueuedCommand interface with required properties', () => {
- const command: QueuedCommand = {
- id: 'test-id',
- command: 'test command',
- sessionId: 'session-1',
- timestamp: Date.now(),
- inputMode: 'ai',
- attempts: 0,
- };
-
- expect(command.id).toBe('test-id');
- expect(command.command).toBe('test command');
- expect(command.sessionId).toBe('session-1');
- expect(command.inputMode).toBe('ai');
- expect(command.attempts).toBe(0);
- });
-
- it('should export QueuedCommand with optional lastError', () => {
- const command: QueuedCommand = {
- id: 'test-id',
- command: 'test',
- sessionId: 'session-1',
- timestamp: Date.now(),
- inputMode: 'terminal',
- attempts: 1,
- lastError: 'Connection failed',
- };
-
- expect(command.lastError).toBe('Connection failed');
- });
-
- it('should export QueueStatus as union type', () => {
- const statuses: QueueStatus[] = ['idle', 'processing', 'paused'];
- expect(statuses).toContain('idle');
- expect(statuses).toContain('processing');
- expect(statuses).toContain('paused');
- });
- });
-
- describe('Initial State', () => {
- it('should initialize with empty queue when localStorage is empty', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- expect(result.current.queue).toEqual([]);
- expect(result.current.queueLength).toBe(0);
- expect(result.current.status).toBe('idle');
- expect(result.current.canQueue).toBe(true);
- });
-
- it('should load queue from localStorage on initialization', () => {
- const storedQueue: QueuedCommand[] = [
- {
- id: 'stored-1',
- command: 'stored command',
- sessionId: 'session-1',
- timestamp: 1000,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- expect(result.current.queue).toHaveLength(1);
- expect(result.current.queue[0].id).toBe('stored-1');
- expect(result.current.queueLength).toBe(1);
- });
-
- it('should handle invalid JSON in localStorage gracefully', () => {
- localStorage.setItem(STORAGE_KEY, 'invalid json {{{');
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- expect(result.current.queue).toEqual([]);
- expect(webLogger.warn).toHaveBeenCalled();
- });
-
- it('should handle non-array value in localStorage', () => {
- localStorage.setItem(STORAGE_KEY, JSON.stringify({ not: 'an array' }));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- expect(result.current.queue).toEqual([]);
- });
-
- it('should return all expected API properties', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- expect(result.current).toHaveProperty('queue');
- expect(result.current).toHaveProperty('queueLength');
- expect(result.current).toHaveProperty('status');
- expect(result.current).toHaveProperty('queueCommand');
- expect(result.current).toHaveProperty('removeCommand');
- expect(result.current).toHaveProperty('clearQueue');
- expect(result.current).toHaveProperty('processQueue');
- expect(result.current).toHaveProperty('pauseProcessing');
- expect(result.current).toHaveProperty('resumeProcessing');
- expect(result.current).toHaveProperty('canQueue');
- });
- });
-
- describe('queueCommand', () => {
- it('should add a command to the queue', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.queueCommand('session-1', 'test command', 'ai');
- });
-
- expect(result.current.queue).toHaveLength(1);
- expect(result.current.queue[0].command).toBe('test command');
- expect(result.current.queue[0].sessionId).toBe('session-1');
- expect(result.current.queue[0].inputMode).toBe('ai');
- expect(result.current.queue[0].attempts).toBe(0);
- });
-
- it('should generate unique IDs for each command', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.queueCommand('session-1', 'command 1', 'ai');
- result.current.queueCommand('session-1', 'command 2', 'ai');
- });
-
- const ids = result.current.queue.map((cmd) => cmd.id);
- expect(ids[0]).not.toBe(ids[1]);
- });
-
- it('should set timestamp on queued commands', () => {
- const now = Date.now();
- vi.setSystemTime(now);
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.queueCommand('session-1', 'test', 'ai');
- });
-
- expect(result.current.queue[0].timestamp).toBe(now);
- });
-
- it('should support terminal input mode', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.queueCommand('session-1', 'ls -la', 'terminal');
- });
-
- expect(result.current.queue[0].inputMode).toBe('terminal');
- });
-
- it('should return the queued command', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- let returnedCommand: QueuedCommand | null = null;
- act(() => {
- returnedCommand = result.current.queueCommand('session-1', 'test', 'ai');
- });
-
- expect(returnedCommand).not.toBeNull();
- expect(returnedCommand!.command).toBe('test');
- });
-
- it('should update queueLength', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- expect(result.current.queueLength).toBe(0);
-
- act(() => {
- result.current.queueCommand('session-1', 'cmd1', 'ai');
- });
- expect(result.current.queueLength).toBe(1);
-
- act(() => {
- result.current.queueCommand('session-1', 'cmd2', 'ai');
- });
- expect(result.current.queueLength).toBe(2);
- });
-
- it('should persist queue to localStorage', async () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.queueCommand('session-1', 'persisted', 'ai');
- });
-
- // Allow effect to run
- await act(async () => {
- vi.advanceTimersByTime(0);
- });
-
- const stored = localStorage.getItem(STORAGE_KEY);
- expect(stored).not.toBeNull();
- const parsed = JSON.parse(stored!);
- expect(parsed).toHaveLength(1);
- expect(parsed[0].command).toBe('persisted');
- });
-
- it('should reject commands at max capacity', () => {
- // Pre-fill storage with max queue size
- const fullQueue: QueuedCommand[] = Array.from({ length: MAX_QUEUE_SIZE }, (_, i) => ({
- id: `cmd-${i}`,
- command: `command ${i}`,
- sessionId: 'session-1',
- timestamp: Date.now(),
- inputMode: 'ai' as const,
- attempts: 0,
- }));
- localStorage.setItem(STORAGE_KEY, JSON.stringify(fullQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- expect(result.current.queue).toHaveLength(MAX_QUEUE_SIZE);
- expect(result.current.canQueue).toBe(false);
-
- let returnedCommand: QueuedCommand | null = null;
- act(() => {
- returnedCommand = result.current.queueCommand('session-1', 'overflow', 'ai');
- });
-
- expect(returnedCommand).toBeNull();
- expect(result.current.queue).toHaveLength(MAX_QUEUE_SIZE);
- expect(webLogger.warn).toHaveBeenCalledWith(
- expect.stringContaining('maximum capacity'),
- 'OfflineQueue'
- );
- });
-
- it('should allow queueing up to max capacity', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- // Fill up to just below max
- act(() => {
- for (let i = 0; i < MAX_QUEUE_SIZE - 1; i++) {
- result.current.queueCommand('session-1', `cmd ${i}`, 'ai');
- }
- });
-
- expect(result.current.canQueue).toBe(true);
-
- // Add one more to reach exactly max
- act(() => {
- result.current.queueCommand('session-1', 'last', 'ai');
- });
-
- expect(result.current.queue).toHaveLength(MAX_QUEUE_SIZE);
- expect(result.current.canQueue).toBe(false);
- });
-
- it('should log when command is queued', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.queueCommand('session-1', 'logged command', 'ai');
- });
-
- expect(webLogger.debug).toHaveBeenCalledWith(
- expect.stringContaining('Command queued'),
- 'OfflineQueue'
- );
- });
-
- it('should truncate long commands in log message', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
- const longCommand = 'a'.repeat(100);
-
- act(() => {
- result.current.queueCommand('session-1', longCommand, 'ai');
- });
-
- const debugCall = vi
- .mocked(webLogger.debug)
- .mock.calls.find((call) => call[0].includes('Command queued'));
- expect(debugCall).toBeDefined();
- // The log message truncates to 50 chars
- expect(debugCall![0].length).toBeLessThan(100);
- });
- });
-
- describe('removeCommand', () => {
- it('should remove a command by ID', () => {
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'first',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- {
- id: 'cmd-2',
- command: 'second',
- sessionId: 's1',
- timestamp: 2,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.removeCommand('cmd-1');
- });
-
- expect(result.current.queue).toHaveLength(1);
- expect(result.current.queue[0].id).toBe('cmd-2');
- });
-
- it('should do nothing if ID not found', () => {
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'first',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.removeCommand('nonexistent');
- });
-
- expect(result.current.queue).toHaveLength(1);
- });
-
- it('should update queueLength after removal', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- let cmdId = '';
- act(() => {
- const cmd = result.current.queueCommand('session-1', 'test', 'ai');
- cmdId = cmd!.id;
- });
-
- expect(result.current.queueLength).toBe(1);
-
- act(() => {
- result.current.removeCommand(cmdId);
- });
-
- expect(result.current.queueLength).toBe(0);
- });
-
- it('should persist removal to localStorage', async () => {
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'first',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.removeCommand('cmd-1');
- });
-
- await act(async () => {
- vi.advanceTimersByTime(0);
- });
-
- const stored = localStorage.getItem(STORAGE_KEY);
- expect(JSON.parse(stored!)).toEqual([]);
- });
-
- it('should log removal', () => {
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'first',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.removeCommand('cmd-1');
- });
-
- expect(webLogger.debug).toHaveBeenCalledWith(
- expect.stringContaining('cmd-1'),
- 'OfflineQueue'
- );
- });
- });
-
- describe('clearQueue', () => {
- it('should clear all commands', () => {
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'first',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- {
- id: 'cmd-2',
- command: 'second',
- sessionId: 's1',
- timestamp: 2,
- inputMode: 'ai',
- attempts: 0,
- },
- {
- id: 'cmd-3',
- command: 'third',
- sessionId: 's1',
- timestamp: 3,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- expect(result.current.queue).toHaveLength(3);
-
- act(() => {
- result.current.clearQueue();
- });
-
- expect(result.current.queue).toHaveLength(0);
- expect(result.current.queueLength).toBe(0);
- });
-
- it('should update canQueue after clearing', () => {
- const fullQueue: QueuedCommand[] = Array.from({ length: MAX_QUEUE_SIZE }, (_, i) => ({
- id: `cmd-${i}`,
- command: `command ${i}`,
- sessionId: 's1',
- timestamp: i,
- inputMode: 'ai' as const,
- attempts: 0,
- }));
- localStorage.setItem(STORAGE_KEY, JSON.stringify(fullQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- expect(result.current.canQueue).toBe(false);
-
- act(() => {
- result.current.clearQueue();
- });
-
- expect(result.current.canQueue).toBe(true);
- });
-
- it('should persist clear to localStorage', async () => {
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'first',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.clearQueue();
- });
-
- await act(async () => {
- vi.advanceTimersByTime(0);
- });
-
- const stored = localStorage.getItem(STORAGE_KEY);
- expect(JSON.parse(stored!)).toEqual([]);
- });
-
- it('should log clear action', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.clearQueue();
- });
-
- expect(webLogger.debug).toHaveBeenCalledWith('Queue cleared', 'OfflineQueue');
- });
- });
-
- describe('processQueue', () => {
- it('should not process when offline', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'test',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() =>
- useOfflineQueue(createDefaultOptions({ isOnline: false, sendCommand }))
- );
-
- await act(async () => {
- await result.current.processQueue();
- });
-
- expect(sendCommand).not.toHaveBeenCalled();
- expect(result.current.queue).toHaveLength(1);
- });
-
- it('should not process when not connected', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'test',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() =>
- useOfflineQueue(createDefaultOptions({ isConnected: false, sendCommand }))
- );
-
- await act(async () => {
- await result.current.processQueue();
- });
-
- expect(sendCommand).not.toHaveBeenCalled();
- });
-
- it('should not process empty queue', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
- const onProcessingStart = vi.fn();
-
- const { result } = renderHook(() =>
- useOfflineQueue(createDefaultOptions({ sendCommand, onProcessingStart }))
- );
-
- await act(async () => {
- await result.current.processQueue();
- });
-
- expect(sendCommand).not.toHaveBeenCalled();
- expect(onProcessingStart).not.toHaveBeenCalled();
- });
-
- it('should process all commands successfully', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
- const onCommandSent = vi.fn();
- const onProcessingComplete = vi.fn();
-
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'first',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- {
- id: 'cmd-2',
- command: 'second',
- sessionId: 's2',
- timestamp: 2,
- inputMode: 'terminal',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- // Start paused to prevent auto-processing
- const { result } = renderHook(() =>
- useOfflineQueue(createDefaultOptions({ sendCommand, onCommandSent, onProcessingComplete }))
- );
-
- // Pause to prevent any auto-processing effects
- act(() => {
- result.current.pauseProcessing();
- });
-
- // Resume and immediately process
- await act(async () => {
- result.current.resumeProcessing();
- // The resume will try to auto-process, but we want manual control
- // Clear the auto-process timer and call processQueue ourselves
- const processPromise = result.current.processQueue();
- await vi.advanceTimersByTimeAsync(2000);
- await processPromise;
- });
-
- // With auto-processing, sendCommand may be called twice (once by auto, once by manual)
- // Just verify we've processed the queue successfully
- expect(sendCommand).toHaveBeenCalled();
- expect(sendCommand).toHaveBeenCalledWith('s1', 'first');
- expect(sendCommand).toHaveBeenCalledWith('s2', 'second');
- expect(result.current.queue).toHaveLength(0);
- });
-
- it('should call onProcessingStart', async () => {
- const onProcessingStart = vi.fn();
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'test',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- // Start paused to prevent auto-processing
- const { result } = renderHook(() =>
- useOfflineQueue(createDefaultOptions({ onProcessingStart }))
- );
-
- // Pause immediately
- act(() => {
- result.current.pauseProcessing();
- });
-
- // Resume and process
- await act(async () => {
- result.current.resumeProcessing();
- await vi.advanceTimersByTimeAsync(2000);
- });
-
- // onProcessingStart should be called at least once
- expect(onProcessingStart).toHaveBeenCalled();
- });
-
- it('should set status to processing during execution', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
- const onProcessingStart = vi.fn();
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'test',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- // Start paused to prevent auto-processing
- const { result } = renderHook(() =>
- useOfflineQueue(createDefaultOptions({ sendCommand, onProcessingStart }))
- );
-
- expect(result.current.status).toBe('idle');
-
- // Pause immediately
- act(() => {
- result.current.pauseProcessing();
- });
-
- expect(result.current.status).toBe('paused');
-
- // Resume
- act(() => {
- result.current.resumeProcessing();
- });
-
- // After resume, the hook will try to process if queue has items
- // The status should transition to processing
- await act(async () => {
- await vi.advanceTimersByTimeAsync(2000);
- });
-
- // Verify that processing occurred (onProcessingStart was called)
- expect(onProcessingStart).toHaveBeenCalled();
- // After processing completes, status should be idle
- expect(result.current.status).toBe('idle');
- });
-
- it('should retry failed commands up to maxRetries', async () => {
- const sendCommand = vi.fn().mockReturnValue(false);
- const onCommandFailed = vi.fn();
-
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'fail',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- // Start paused to prevent auto-processing
- const { result } = renderHook(() =>
- useOfflineQueue(createDefaultOptions({ sendCommand, onCommandFailed, maxRetries: 3 }))
- );
-
- // Pause immediately to prevent auto-processing
- act(() => {
- result.current.pauseProcessing();
- });
-
- // First attempt
- await act(async () => {
- result.current.resumeProcessing();
- await vi.advanceTimersByTimeAsync(2000);
- });
-
- // Queue still has command with 1 attempt
- expect(result.current.queue).toHaveLength(1);
- expect(result.current.queue[0].attempts).toBe(1);
-
- // Manually trigger second attempt (auto-process won't re-trigger since queue.length didn't change)
- await act(async () => {
- result.current.processQueue();
- await vi.advanceTimersByTimeAsync(2000);
- });
-
- expect(result.current.queue).toHaveLength(1);
- expect(result.current.queue[0].attempts).toBe(2);
-
- // Third and final attempt
- await act(async () => {
- result.current.processQueue();
- await vi.advanceTimersByTimeAsync(2000);
- });
-
- // After max retries, command should be removed and onCommandFailed called
- expect(result.current.queue).toHaveLength(0);
- expect(onCommandFailed).toHaveBeenCalled();
- expect(onCommandFailed).toHaveBeenCalledWith(
- expect.objectContaining({ id: 'cmd-1', attempts: 3 }),
- 'Max retries exceeded'
- );
- });
-
- it('should handle sendCommand throwing error', async () => {
- const sendCommand = vi.fn().mockImplementation(() => {
- throw new Error('Network error');
- });
- const onCommandFailed = vi.fn();
-
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'error',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 2,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() =>
- useOfflineQueue(createDefaultOptions({ sendCommand, onCommandFailed, maxRetries: 3 }))
- );
-
- await act(async () => {
- const processPromise = result.current.processQueue();
- await vi.advanceTimersByTimeAsync(1000);
- await processPromise;
- });
-
- expect(onCommandFailed).toHaveBeenCalledWith(
- expect.objectContaining({ lastError: 'Network error' }),
- 'Network error'
- );
- });
-
- it('should handle non-Error throws', async () => {
- const sendCommand = vi.fn().mockImplementation(() => {
- throw 'string error';
- });
- const onCommandFailed = vi.fn();
-
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'test',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 2,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() =>
- useOfflineQueue(createDefaultOptions({ sendCommand, onCommandFailed, maxRetries: 3 }))
- );
-
- await act(async () => {
- const processPromise = result.current.processQueue();
- await vi.advanceTimersByTimeAsync(1000);
- await processPromise;
- });
-
- expect(onCommandFailed).toHaveBeenCalledWith(
- expect.objectContaining({ lastError: 'Unknown error' }),
- 'Unknown error'
- );
- });
-
- it('should prevent concurrent processing', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'test',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- // Start paused to prevent auto-processing
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions({ sendCommand })));
-
- // Pause immediately
- act(() => {
- result.current.pauseProcessing();
- });
-
- // Resume and process - concurrent protection is handled internally
- await act(async () => {
- result.current.resumeProcessing();
- // Give time for processing to complete
- await vi.advanceTimersByTimeAsync(2000);
- });
-
- // Verify the queue was processed (sendCommand called at least once)
- expect(sendCommand).toHaveBeenCalled();
- expect(result.current.queue).toHaveLength(0);
- });
-
- it('should update sendCommand ref correctly', async () => {
- const sendCommand1 = vi.fn().mockReturnValue(true);
- const sendCommand2 = vi.fn().mockReturnValue(true);
-
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'test',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- // Start paused to prevent auto-processing
- const { result, rerender } = renderHook(
- ({ sendCommand }) => useOfflineQueue(createDefaultOptions({ sendCommand })),
- { initialProps: { sendCommand: sendCommand1 } }
- );
-
- // Pause immediately
- act(() => {
- result.current.pauseProcessing();
- });
-
- // Update sendCommand while paused
- rerender({ sendCommand: sendCommand2 });
-
- // Resume and process
- await act(async () => {
- result.current.resumeProcessing();
- await vi.advanceTimersByTimeAsync(2000);
- });
-
- // Should use the updated sendCommand (sendCommand2)
- expect(sendCommand2).toHaveBeenCalled();
- // Note: sendCommand1 might be called if there was a brief window before pause
- expect(result.current.queue).toHaveLength(0);
- });
-
- it('should log processing progress', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'test',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions({ sendCommand })));
-
- await act(async () => {
- const processPromise = result.current.processQueue();
- await vi.advanceTimersByTimeAsync(1000);
- await processPromise;
- });
-
- expect(webLogger.debug).toHaveBeenCalledWith(
- expect.stringContaining('Starting queue processing'),
- 'OfflineQueue'
- );
- expect(webLogger.debug).toHaveBeenCalledWith(
- expect.stringContaining('Processing complete'),
- 'OfflineQueue'
- );
- });
-
- it('should use default maxRetries of 3', async () => {
- const sendCommand = vi.fn().mockReturnValue(false);
- const onCommandFailed = vi.fn();
-
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'fail',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 2,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- // Don't specify maxRetries - should default to 3
- const { result } = renderHook(() =>
- useOfflineQueue(createDefaultOptions({ sendCommand, onCommandFailed }))
- );
-
- await act(async () => {
- const processPromise = result.current.processQueue();
- await vi.advanceTimersByTimeAsync(1000);
- await processPromise;
- });
-
- // At attempts=2, one more try reaches 3, then fails permanently
- expect(onCommandFailed).toHaveBeenCalled();
- });
- });
-
- describe('pauseProcessing', () => {
- it('should set status to paused', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.pauseProcessing();
- });
-
- expect(result.current.status).toBe('paused');
- });
-
- it('should prevent processing when paused', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'test',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions({ sendCommand })));
-
- act(() => {
- result.current.pauseProcessing();
- });
-
- await act(async () => {
- await result.current.processQueue();
- });
-
- expect(sendCommand).not.toHaveBeenCalled();
- });
-
- it('should pause mid-processing', async () => {
- let callCount = 0;
- const sendCommand = vi.fn().mockImplementation(() => {
- callCount++;
- return true;
- });
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'first',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- {
- id: 'cmd-2',
- command: 'second',
- sessionId: 's1',
- timestamp: 2,
- inputMode: 'ai',
- attempts: 0,
- },
- {
- id: 'cmd-3',
- command: 'third',
- sessionId: 's1',
- timestamp: 3,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions({ sendCommand })));
-
- // Start processing but pause after first command
- await act(async () => {
- const processPromise = result.current.processQueue();
- // Process first command
- await vi.advanceTimersByTimeAsync(150);
- // Pause before second command completes
- result.current.pauseProcessing();
- await vi.advanceTimersByTimeAsync(1000);
- await processPromise;
- });
-
- // First command sent, remaining commands kept in queue
- expect(callCount).toBeGreaterThanOrEqual(1);
- expect(result.current.status).toBe('paused');
- });
-
- it('should log pause action', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.pauseProcessing();
- });
-
- expect(webLogger.debug).toHaveBeenCalledWith('Processing paused', 'OfflineQueue');
- });
- });
-
- describe('resumeProcessing', () => {
- it('should set status back to idle when not processing', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.pauseProcessing();
- });
- expect(result.current.status).toBe('paused');
-
- act(() => {
- result.current.resumeProcessing();
- });
- expect(result.current.status).toBe('idle');
- });
-
- it('should trigger processing if queue has items', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'test',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions({ sendCommand })));
-
- act(() => {
- result.current.pauseProcessing();
- });
-
- await act(async () => {
- result.current.resumeProcessing();
- // Let processQueue run
- await vi.advanceTimersByTimeAsync(2000);
- });
-
- expect(sendCommand).toHaveBeenCalled();
- });
-
- it('should not trigger processing if offline', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'test',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() =>
- useOfflineQueue(createDefaultOptions({ sendCommand, isOnline: false }))
- );
-
- act(() => {
- result.current.pauseProcessing();
- });
-
- await act(async () => {
- result.current.resumeProcessing();
- await vi.advanceTimersByTimeAsync(2000);
- });
-
- expect(sendCommand).not.toHaveBeenCalled();
- });
-
- it('should not trigger processing if queue is empty', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions({ sendCommand })));
-
- act(() => {
- result.current.pauseProcessing();
- });
-
- await act(async () => {
- result.current.resumeProcessing();
- await vi.advanceTimersByTimeAsync(2000);
- });
-
- expect(sendCommand).not.toHaveBeenCalled();
- });
-
- it('should log resume action', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.resumeProcessing();
- });
-
- expect(webLogger.debug).toHaveBeenCalledWith('Processing resumed', 'OfflineQueue');
- });
- });
-
- describe('Auto-processing on connection restore', () => {
- it('should automatically process queue when going online', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'test',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- // Start offline
- const { result, rerender } = renderHook(
- ({ isOnline, isConnected }) =>
- useOfflineQueue(createDefaultOptions({ sendCommand, isOnline, isConnected })),
- { initialProps: { isOnline: false, isConnected: false } }
- );
-
- expect(sendCommand).not.toHaveBeenCalled();
-
- // Go online and connected
- rerender({ isOnline: true, isConnected: true });
-
- await act(async () => {
- // Wait for the 500ms delay + processing time
- await vi.advanceTimersByTimeAsync(2000);
- });
-
- expect(sendCommand).toHaveBeenCalled();
- });
-
- it('should not auto-process when paused', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'test',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result, rerender } = renderHook(
- ({ isOnline, isConnected }) =>
- useOfflineQueue(createDefaultOptions({ sendCommand, isOnline, isConnected })),
- { initialProps: { isOnline: false, isConnected: false } }
- );
-
- act(() => {
- result.current.pauseProcessing();
- });
-
- rerender({ isOnline: true, isConnected: true });
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(2000);
- });
-
- expect(sendCommand).not.toHaveBeenCalled();
- });
-
- it('should have 500ms delay before auto-processing', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'test',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { rerender } = renderHook(
- ({ isOnline, isConnected }) =>
- useOfflineQueue(createDefaultOptions({ sendCommand, isOnline, isConnected })),
- { initialProps: { isOnline: false, isConnected: false } }
- );
-
- rerender({ isOnline: true, isConnected: true });
-
- // Before 500ms delay
- await act(async () => {
- await vi.advanceTimersByTimeAsync(400);
- });
- expect(sendCommand).not.toHaveBeenCalled();
-
- // After 500ms delay
- await act(async () => {
- await vi.advanceTimersByTimeAsync(200);
- });
- expect(sendCommand).toHaveBeenCalled();
- });
-
- it('should cleanup timer on unmount', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'test',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { unmount, rerender } = renderHook(
- ({ isOnline, isConnected }) =>
- useOfflineQueue(createDefaultOptions({ sendCommand, isOnline, isConnected })),
- { initialProps: { isOnline: false, isConnected: false } }
- );
-
- rerender({ isOnline: true, isConnected: true });
-
- // Unmount before timer fires
- unmount();
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(2000);
- });
-
- // Should not process after unmount
- expect(sendCommand).not.toHaveBeenCalled();
- });
- });
-
- describe('canQueue computed property', () => {
- it('should be true when queue is below max', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- expect(result.current.canQueue).toBe(true);
- });
-
- it('should be false when queue is at max', () => {
- const fullQueue: QueuedCommand[] = Array.from({ length: MAX_QUEUE_SIZE }, (_, i) => ({
- id: `cmd-${i}`,
- command: `command ${i}`,
- sessionId: 's1',
- timestamp: i,
- inputMode: 'ai' as const,
- attempts: 0,
- }));
- localStorage.setItem(STORAGE_KEY, JSON.stringify(fullQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- expect(result.current.canQueue).toBe(false);
- });
-
- it('should update when queue changes', () => {
- const nearFullQueue: QueuedCommand[] = Array.from({ length: MAX_QUEUE_SIZE - 1 }, (_, i) => ({
- id: `cmd-${i}`,
- command: `command ${i}`,
- sessionId: 's1',
- timestamp: i,
- inputMode: 'ai' as const,
- attempts: 0,
- }));
- localStorage.setItem(STORAGE_KEY, JSON.stringify(nearFullQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- expect(result.current.canQueue).toBe(true);
-
- act(() => {
- result.current.queueCommand('s1', 'last', 'ai');
- });
-
- expect(result.current.canQueue).toBe(false);
- });
- });
-
- describe('Connection loss during processing', () => {
- it('should stop processing and keep remaining commands when connection lost', async () => {
- let isConnectedValue = true;
- const sendCommand = vi.fn().mockImplementation(() => {
- // Simulate connection loss after first command
- if (sendCommand.mock.calls.length === 1) {
- isConnectedValue = false;
- }
- return isConnectedValue;
- });
-
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'first',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- {
- id: 'cmd-2',
- command: 'second',
- sessionId: 's1',
- timestamp: 2,
- inputMode: 'ai',
- attempts: 0,
- },
- {
- id: 'cmd-3',
- command: 'third',
- sessionId: 's1',
- timestamp: 3,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result, rerender } = renderHook(
- ({ isConnected }) => useOfflineQueue(createDefaultOptions({ sendCommand, isConnected })),
- { initialProps: { isConnected: true } }
- );
-
- await act(async () => {
- const processPromise = result.current.processQueue();
- await vi.advanceTimersByTimeAsync(150);
- // Simulate connection loss
- rerender({ isConnected: false });
- await vi.advanceTimersByTimeAsync(1000);
- await processPromise;
- });
-
- // First command succeeded, remaining kept in queue
- expect(sendCommand).toHaveBeenCalled();
- expect(result.current.queue.length).toBeGreaterThan(0);
- });
- });
-
- describe('localStorage error handling', () => {
- it('should handle localStorage.setItem throwing', async () => {
- const originalSetItem = localStorage.setItem.bind(localStorage);
- localStorage.setItem = vi.fn().mockImplementation(() => {
- throw new Error('Storage quota exceeded');
- });
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.queueCommand('s1', 'test', 'ai');
- });
-
- // Should still work in memory
- expect(result.current.queue).toHaveLength(1);
- expect(webLogger.warn).toHaveBeenCalledWith(
- expect.stringContaining('Failed to save'),
- 'OfflineQueue',
- expect.any(Error)
- );
-
- // Restore
- localStorage.setItem = originalSetItem;
- });
-
- it('should handle localStorage.getItem throwing', () => {
- const originalGetItem = localStorage.getItem.bind(localStorage);
- localStorage.getItem = vi.fn().mockImplementation(() => {
- throw new Error('Access denied');
- });
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- // Should initialize with empty queue
- expect(result.current.queue).toEqual([]);
- expect(webLogger.warn).toHaveBeenCalledWith(
- expect.stringContaining('Failed to load'),
- 'OfflineQueue',
- expect.any(Error)
- );
-
- // Restore
- localStorage.getItem = originalGetItem;
- });
- });
-
- describe('Function reference stability', () => {
- it('should maintain stable function references', () => {
- const { result, rerender } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- const {
- queueCommand: qc1,
- removeCommand: rc1,
- clearQueue: cq1,
- processQueue: pq1,
- pauseProcessing: pp1,
- resumeProcessing: rp1,
- } = result.current;
-
- rerender();
-
- // queueCommand depends on queue.length, so it may change
- expect(result.current.removeCommand).toBe(rc1);
- expect(result.current.clearQueue).toBe(cq1);
- expect(result.current.pauseProcessing).toBe(pp1);
- });
-
- it('should update queueCommand when queue length changes', () => {
- const { result, rerender } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- const qc1 = result.current.queueCommand;
-
- act(() => {
- result.current.queueCommand('s1', 'test', 'ai');
- });
-
- // queueCommand depends on queue.length, so reference should change
- expect(result.current.queueCommand).not.toBe(qc1);
- });
- });
-
- describe('Edge cases', () => {
- it('should handle empty command string', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.queueCommand('s1', '', 'ai');
- });
-
- expect(result.current.queue).toHaveLength(1);
- expect(result.current.queue[0].command).toBe('');
- });
-
- it('should handle special characters in command', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
- const specialCommand = '!@#$%^&*()_+{}[]|\\:";\'<>?,./\n\t emoji: 🚀';
-
- act(() => {
- result.current.queueCommand('s1', specialCommand, 'ai');
- });
-
- expect(result.current.queue[0].command).toBe(specialCommand);
- });
-
- it('should handle very long command', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
- const longCommand = 'x'.repeat(10000);
-
- act(() => {
- result.current.queueCommand('s1', longCommand, 'ai');
- });
-
- expect(result.current.queue[0].command).toBe(longCommand);
- });
-
- it('should preserve command order', () => {
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- act(() => {
- result.current.queueCommand('s1', 'first', 'ai');
- result.current.queueCommand('s1', 'second', 'ai');
- result.current.queueCommand('s1', 'third', 'ai');
- });
-
- expect(result.current.queue.map((c) => c.command)).toEqual(['first', 'second', 'third']);
- });
-
- it('should handle null localStorage return', () => {
- // localStorage.getItem returns null when key doesn't exist (default case)
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions()));
-
- expect(result.current.queue).toEqual([]);
- });
- });
-
- describe('Mixed success and failure in batch', () => {
- it('should handle mix of successful and failed commands', async () => {
- let callCount = 0;
- const sendCommand = vi.fn().mockImplementation(() => {
- callCount++;
- // Fail every other command
- return callCount % 2 === 1;
- });
- const onCommandSent = vi.fn();
- const onCommandFailed = vi.fn();
- const onProcessingComplete = vi.fn();
-
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'first',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- {
- id: 'cmd-2',
- command: 'second',
- sessionId: 's1',
- timestamp: 2,
- inputMode: 'ai',
- attempts: 2,
- },
- {
- id: 'cmd-3',
- command: 'third',
- sessionId: 's1',
- timestamp: 3,
- inputMode: 'ai',
- attempts: 0,
- },
- {
- id: 'cmd-4',
- command: 'fourth',
- sessionId: 's1',
- timestamp: 4,
- inputMode: 'ai',
- attempts: 2,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- // Start paused to prevent auto-processing
- const { result } = renderHook(() =>
- useOfflineQueue(
- createDefaultOptions({
- sendCommand,
- onCommandSent,
- onCommandFailed,
- onProcessingComplete,
- maxRetries: 3,
- })
- )
- );
-
- // Pause immediately
- act(() => {
- result.current.pauseProcessing();
- });
-
- // Resume and let processing run
- await act(async () => {
- result.current.resumeProcessing();
- await vi.advanceTimersByTimeAsync(10000);
- });
-
- // Verify callbacks were invoked
- // Due to auto-processing retries, we expect both successes and failures
- expect(onCommandSent).toHaveBeenCalled();
- expect(onCommandFailed).toHaveBeenCalled();
- expect(onProcessingComplete).toHaveBeenCalled();
- });
- });
-
- describe('Multiple sessions', () => {
- it('should handle commands for different sessions', async () => {
- const sendCommand = vi.fn().mockReturnValue(true);
- const storedQueue: QueuedCommand[] = [
- {
- id: 'cmd-1',
- command: 'for s1',
- sessionId: 's1',
- timestamp: 1,
- inputMode: 'ai',
- attempts: 0,
- },
- {
- id: 'cmd-2',
- command: 'for s2',
- sessionId: 's2',
- timestamp: 2,
- inputMode: 'terminal',
- attempts: 0,
- },
- {
- id: 'cmd-3',
- command: 'for s3',
- sessionId: 's3',
- timestamp: 3,
- inputMode: 'ai',
- attempts: 0,
- },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue));
-
- const { result } = renderHook(() => useOfflineQueue(createDefaultOptions({ sendCommand })));
-
- await act(async () => {
- const processPromise = result.current.processQueue();
- await vi.advanceTimersByTimeAsync(2000);
- await processPromise;
- });
-
- expect(sendCommand).toHaveBeenCalledWith('s1', 'for s1');
- expect(sendCommand).toHaveBeenCalledWith('s2', 'for s2');
- expect(sendCommand).toHaveBeenCalledWith('s3', 'for s3');
- });
- });
-});
diff --git a/src/__tests__/web/mobile/App.test.tsx b/src/__tests__/web/mobile/App.test.tsx
index 907897ac6a..5603cc3fc2 100644
--- a/src/__tests__/web/mobile/App.test.tsx
+++ b/src/__tests__/web/mobile/App.test.tsx
@@ -167,6 +167,12 @@ vi.mock('../../../web/hooks/useOfflineQueue', () => ({
clearQueue: mockClearQueue,
processQueue: mockProcessQueue,
}),
+ // Mock the localStorage adapter factory that's imported by the App component
+ createLocalStorageAdapter: () => ({
+ getItem: vi.fn(() => Promise.resolve(null)),
+ setItem: vi.fn(() => Promise.resolve()),
+ removeItem: vi.fn(() => Promise.resolve()),
+ }),
}));
// Mock config
diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts
index 086991ff41..d717d85c88 100644
--- a/src/main/ipc/handlers/index.ts
+++ b/src/main/ipc/handlers/index.ts
@@ -68,6 +68,7 @@ import { registerFeedbackHandlers } from './feedback';
import { registerMaestroCliHandlers } from './maestro-cli';
import { registerPromptsHandlers } from './prompts';
import { registerMemoryHandlers } from './memory';
+import { registerMobilePairingHandlers, MobilePairingHandlerDependencies } from './mobile-pairing';
import { AgentDetector } from '../../agents';
import { ProcessManager } from '../../process-manager';
import { WebServer } from '../../web-server';
@@ -130,6 +131,8 @@ export { registerFeedbackHandlers };
export { registerMaestroCliHandlers };
export { registerPromptsHandlers };
export { registerMemoryHandlers };
+export { registerMobilePairingHandlers };
+export type { MobilePairingHandlerDependencies };
export type { AgentsHandlerDependencies };
export type { ProcessHandlerDependencies };
export type { PersistenceHandlerDependencies };
@@ -327,6 +330,10 @@ export function registerAllHandlers(deps: HandlerDependencies): void {
registerPromptsHandlers();
// Register project Memory handlers (Claude Code per-project memory viewer)
registerMemoryHandlers();
+ // Register Mobile Pairing handlers (QR-based device pairing)
+ registerMobilePairingHandlers({
+ getWebServer: deps.getWebServer,
+ });
// Setup logger event forwarding to renderer
setupLoggerEventForwarding(deps.getMainWindow);
}
diff --git a/src/main/ipc/handlers/mobile-pairing.ts b/src/main/ipc/handlers/mobile-pairing.ts
new file mode 100644
index 0000000000..9337705ba5
--- /dev/null
+++ b/src/main/ipc/handlers/mobile-pairing.ts
@@ -0,0 +1,136 @@
+/**
+ * Mobile Pairing IPC Handlers
+ *
+ * Provides IPC handlers for mobile device pairing:
+ * - Generate pairing code for QR display
+ * - List paired devices
+ * - Revoke paired devices
+ */
+
+import { ipcMain } from 'electron';
+import { generatePairingCode, listPairedDevices, revokeDevice } from '../../mobile-pairing';
+import { createIpcHandler, CreateHandlerOptions } from '../../utils/ipcHandler';
+import { logger } from '../../utils/logger';
+import { WebServer } from '../../web-server';
+
+const LOG_CONTEXT = '[MobilePairing]';
+
+/**
+ * Helper to create handler options with consistent context
+ */
+const handlerOpts = (operation: string, logSuccess = true): CreateHandlerOptions => ({
+ context: LOG_CONTEXT,
+ operation,
+ logSuccess,
+});
+
+/**
+ * Dependencies required for mobile pairing handler registration
+ */
+export interface MobilePairingHandlerDependencies {
+ /** Function to get the WebServer instance */
+ getWebServer: () => WebServer | null;
+}
+
+/**
+ * Register all mobile pairing IPC handlers.
+ *
+ * Handlers:
+ * - mobile-pairing:generate-code - Generate a new pairing code with host/port info
+ * - mobile-pairing:list-devices - Get all paired devices (no tokens)
+ * - mobile-pairing:revoke-device - Revoke a paired device by ID
+ */
+export function registerMobilePairingHandlers(deps: MobilePairingHandlerDependencies): void {
+ const { getWebServer } = deps;
+
+ /**
+ * Generate a new pairing code for QR display.
+ *
+ * Returns the code, host, port, and expiration time.
+ * Requires the web server to be running to get host/port info.
+ */
+ ipcMain.handle(
+ 'mobile-pairing:generate-code',
+ createIpcHandler(
+ handlerOpts('generate-code'),
+ async (): Promise<{
+ code: string;
+ host: string;
+ port: number;
+ expiresAt: number;
+ }> => {
+ const webServer = getWebServer();
+ if (!webServer || !webServer.isActive()) {
+ throw new Error('Web server is not running. Enable web interface first.');
+ }
+
+ // Generate the pairing code
+ const pairing = generatePairingCode();
+
+ // Get host and port from the running web server
+ const url = webServer.getUrl();
+ const port = webServer.getPort();
+
+ // Extract host from URL (format: http://192.168.x.x:port)
+ const urlMatch = url.match(/^https?:\/\/([^:]+)/);
+ const host = urlMatch ? urlMatch[1] : 'localhost';
+
+ logger.info(`Generated pairing code (expires in 5 minutes)`, LOG_CONTEXT);
+
+ return {
+ code: pairing.code,
+ host,
+ port,
+ expiresAt: pairing.expiresAt,
+ };
+ }
+ )
+ );
+
+ /**
+ * List all paired devices.
+ *
+ * Returns device records without token hashes.
+ */
+ ipcMain.handle(
+ 'mobile-pairing:list-devices',
+ createIpcHandler(
+ handlerOpts('list-devices', false),
+ async (): Promise<{
+ devices: Array<{
+ id: string;
+ deviceName: string;
+ createdAt: number;
+ lastUsedAt: number;
+ expiresAt: number;
+ }>;
+ }> => {
+ const devices = await listPairedDevices();
+ return { devices };
+ }
+ )
+ );
+
+ /**
+ * Revoke a paired device by ID.
+ *
+ * Removes the device from the paired devices list.
+ */
+ ipcMain.handle(
+ 'mobile-pairing:revoke-device',
+ createIpcHandler(
+ handlerOpts('revoke-device'),
+ async (id: string): Promise<{ revoked: boolean }> => {
+ const revoked = await revokeDevice(id);
+ if (revoked) {
+ logger.info(`Revoked paired device: ${id}`, LOG_CONTEXT);
+ } else {
+ logger.warn(`Device not found for revocation: ${id}`, LOG_CONTEXT);
+ }
+ return { revoked };
+ }
+ )
+ );
+
+ logger.debug(`${LOG_CONTEXT} Mobile pairing IPC handlers registered`);
+}
diff --git a/src/main/mobile-pairing/index.ts b/src/main/mobile-pairing/index.ts
new file mode 100644
index 0000000000..cb0776524b
--- /dev/null
+++ b/src/main/mobile-pairing/index.ts
@@ -0,0 +1,276 @@
+/**
+ * Mobile Pairing Module
+ *
+ * Implements QR-based device pairing per decision 15B:
+ * - Short-lived pairing codes (6-char base32, 5-minute expiry)
+ * - Long-lived per-device hashed tokens stored in mobile-pairings.json
+ * - Token validation for WebSocket authentication
+ *
+ * Flow:
+ * 1. Desktop generates pairing code via generatePairingCode()
+ * 2. Mobile scans QR, posts to /api/mobile-pairing/redeem
+ * 3. redeemPairingCode() validates code, persists hashed token, returns plaintext token
+ * 4. Mobile stores token in SecureStore
+ * 5. On subsequent connections, validateMobileToken() authenticates via hashed token
+ */
+
+import crypto from 'crypto';
+import path from 'path';
+import { app } from 'electron';
+import { readFile, writeFile, mkdir, rename } from 'fs/promises';
+
+// Types
+
+export interface PendingPairing {
+ code: string;
+ pendingToken: string;
+ expiresAt: number;
+ used: boolean;
+}
+
+export interface PairedDevice {
+ id: string;
+ deviceName: string;
+ tokenHash: string;
+ createdAt: number;
+ lastUsedAt: number;
+ expiresAt: number;
+}
+
+export interface GeneratedCode {
+ code: string;
+ expiresAt: number;
+ pendingToken: string;
+}
+
+// Constants
+
+const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+const CODE_LENGTH = 6;
+const CODE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
+const TOKEN_EXPIRY_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
+const PAIRINGS_FILENAME = 'mobile-pairings.json';
+const CODE_PATTERN = /^[A-Z2-7]{6}$/;
+const MAX_DEVICE_NAME_LENGTH = 200;
+const DEFAULT_DEVICE_NAME = 'Unknown Device';
+
+// In-memory store for pending pairing codes
+const pendingPairings = new Map();
+
+// Helpers
+
+function generateBase32Code(length: number): string {
+ let result = '';
+ const bytes = crypto.randomBytes(length);
+ for (let i = 0; i < length; i++) {
+ result += BASE32_CHARS[bytes[i] % 32];
+ }
+ return result;
+}
+
+function generate256BitToken(): string {
+ return crypto.randomBytes(32).toString('hex');
+}
+
+function hashToken(token: string): string {
+ return crypto.createHash('sha256').update(token).digest('hex');
+}
+
+function generateUUID(): string {
+ return crypto.randomUUID();
+}
+
+function getPairingsFilePath(): string {
+ return path.join(app.getPath('userData'), PAIRINGS_FILENAME);
+}
+
+async function readPairings(): Promise {
+ try {
+ const filePath = getPairingsFilePath();
+ const content = await readFile(filePath, 'utf-8');
+ const data = JSON.parse(content);
+ return Array.isArray(data) ? data : [];
+ } catch {
+ return [];
+ }
+}
+
+async function writePairings(devices: PairedDevice[]): Promise {
+ const filePath = getPairingsFilePath();
+ const dir = path.dirname(filePath);
+ await mkdir(dir, { recursive: true });
+ // Atomic write: stage to a tmp file, then rename. Prevents readers from seeing
+ // a half-written file if the process dies mid-write.
+ const tmpPath = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
+ await writeFile(tmpPath, JSON.stringify(devices, null, '\t'), 'utf-8');
+ await rename(tmpPath, filePath);
+}
+
+// Serializes all read-modify-write mutations of mobile-pairings.json so concurrent
+// callers (e.g. redeem racing updateDeviceLastUsed) can't lose writes. Node is
+// single-threaded but the awaits between read and write open an interleaving
+// window; the chained promise enforces strict ordering.
+let pairingsLock: Promise = Promise.resolve();
+
+function withPairingsLock(fn: () => Promise): Promise {
+ const next = pairingsLock.then(fn, fn);
+ pairingsLock = next.catch(() => undefined);
+ return next;
+}
+
+// Cleanup expired pending codes periodically
+function cleanupExpiredCodes(): void {
+ const now = Date.now();
+ pendingPairings.forEach((pairing, code) => {
+ if (pairing.expiresAt < now || pairing.used) {
+ pendingPairings.delete(code);
+ }
+ });
+}
+
+// Run cleanup every minute
+setInterval(cleanupExpiredCodes, 60 * 1000);
+
+// Public API
+
+/** Generate a new pairing code for mobile device enrollment. */
+export function generatePairingCode(): GeneratedCode {
+ // Clean up old codes first
+ cleanupExpiredCodes();
+
+ const code = generateBase32Code(CODE_LENGTH);
+ const pendingToken = generate256BitToken();
+ const expiresAt = Date.now() + CODE_EXPIRY_MS;
+
+ pendingPairings.set(code, {
+ code,
+ pendingToken,
+ expiresAt,
+ used: false,
+ });
+
+ return { code, expiresAt, pendingToken };
+}
+
+/** Redeem a pairing code. Validates, marks used, persists device, returns token. */
+export async function redeemPairingCode(
+ code: string,
+ deviceName: string
+): Promise<{ token: string; deviceId: string } | null> {
+ if (typeof code !== 'string') {
+ return null;
+ }
+ const normalizedCode = code.toUpperCase().trim();
+ if (!CODE_PATTERN.test(normalizedCode)) {
+ return null;
+ }
+
+ const pending = pendingPairings.get(normalizedCode);
+
+ // Validate code exists, not expired, not used.
+ if (!pending) {
+ return null;
+ }
+
+ if (pending.expiresAt < Date.now()) {
+ pendingPairings.delete(normalizedCode);
+ return null;
+ }
+
+ if (pending.used) {
+ return null;
+ }
+
+ // Single-threaded JS guarantees these two ops are atomic w.r.t. other JS,
+ // so two parallel redeems of the same code can't both pass `!pending.used`.
+ pending.used = true;
+
+ const safeDeviceName =
+ typeof deviceName === 'string' && deviceName.trim().length > 0
+ ? deviceName.trim().slice(0, MAX_DEVICE_NAME_LENGTH)
+ : DEFAULT_DEVICE_NAME;
+
+ const now = Date.now();
+ const device: PairedDevice = {
+ id: generateUUID(),
+ deviceName: safeDeviceName,
+ tokenHash: hashToken(pending.pendingToken),
+ createdAt: now,
+ lastUsedAt: now,
+ expiresAt: now + TOKEN_EXPIRY_MS,
+ };
+
+ await withPairingsLock(async () => {
+ const devices = await readPairings();
+ devices.push(device);
+ await writePairings(devices);
+ });
+
+ pendingPairings.delete(normalizedCode);
+
+ return { token: pending.pendingToken, deviceId: device.id };
+}
+
+/** Validate a mobile token. Returns device record if valid, null otherwise. */
+export async function validateMobileToken(token: string): Promise {
+ if (!token || typeof token !== 'string') {
+ return null;
+ }
+
+ const tokenHash = hashToken(token);
+ const tokenHashBuf = Buffer.from(tokenHash, 'hex');
+ const devices = await readPairings();
+ const now = Date.now();
+
+ // Constant-time compare. The hash makes a real timing attack exotic, but
+ // avoiding short-circuit `===` on secret-derived values is cheap hygiene.
+ for (const d of devices) {
+ if (d.expiresAt <= now) continue;
+ const candidate = Buffer.from(d.tokenHash, 'hex');
+ if (
+ candidate.length === tokenHashBuf.length &&
+ crypto.timingSafeEqual(candidate, tokenHashBuf)
+ ) {
+ return d;
+ }
+ }
+
+ return null;
+}
+
+/** Update lastUsedAt timestamp when a device successfully authenticates. */
+export async function updateDeviceLastUsed(deviceId: string): Promise {
+ await withPairingsLock(async () => {
+ const devices = await readPairings();
+ const device = devices.find((d) => d.id === deviceId);
+ if (device) {
+ device.lastUsedAt = Date.now();
+ await writePairings(devices);
+ }
+ });
+}
+
+/** List all paired devices (without exposing token hashes). */
+export async function listPairedDevices(): Promise[]> {
+ const devices = await readPairings();
+ const now = Date.now();
+
+ // Filter expired devices and omit tokenHash
+ return devices.filter((d) => d.expiresAt > now).map(({ tokenHash: _, ...rest }) => rest);
+}
+
+/** Revoke a paired device by ID. */
+export async function revokeDevice(deviceId: string): Promise {
+ return withPairingsLock(async () => {
+ const devices = await readPairings();
+ const initialLength = devices.length;
+ const filtered = devices.filter((d) => d.id !== deviceId);
+
+ if (filtered.length < initialLength) {
+ await writePairings(filtered);
+ return true;
+ }
+
+ return false;
+ });
+}
diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts
index 4feb0449b2..8acbb693ed 100644
--- a/src/main/preload/index.ts
+++ b/src/main/preload/index.ts
@@ -57,6 +57,7 @@ import { createWakatimeApi } from './wakatime';
import { createMaestroCliApi } from './maestroCli';
import { createPromptsApi } from './prompts';
import { createMemoryApi } from './memory';
+import { createMobilePairingApi } from './mobilePairing';
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
@@ -218,6 +219,8 @@ contextBridge.exposeInMainWorld('maestro', {
prompts: createPromptsApi(),
// Per-project Memory API (Claude Code memory viewer)
memory: createMemoryApi(),
+ // Mobile Pairing API (QR-based device pairing)
+ mobilePairing: createMobilePairingApi(),
});
// Re-export factory functions for external consumers (e.g., tests)
@@ -306,6 +309,8 @@ export {
createPromptsApi,
// Memory Viewer
createMemoryApi,
+ // Mobile Pairing
+ createMobilePairingApi,
};
// Re-export types for TypeScript consumers
@@ -545,3 +550,11 @@ export type {
PromptsApi,
CorePromptData,
} from './prompts';
+export type {
+ // From mobilePairing
+ MobilePairingApi,
+ PairedDevice,
+ PairingCodeResponse,
+ DeviceListResponse,
+ RevokeDeviceResponse,
+} from './mobilePairing';
diff --git a/src/main/preload/mobilePairing.ts b/src/main/preload/mobilePairing.ts
new file mode 100644
index 0000000000..c7b5410155
--- /dev/null
+++ b/src/main/preload/mobilePairing.ts
@@ -0,0 +1,80 @@
+/**
+ * Preload API for mobile pairing operations
+ *
+ * Provides the window.maestro.mobilePairing namespace for:
+ * - Generating pairing codes for QR display
+ * - Listing paired devices
+ * - Revoking paired devices
+ */
+
+import { ipcRenderer } from 'electron';
+
+/**
+ * Paired device record (without token hash)
+ */
+export interface PairedDevice {
+ id: string;
+ deviceName: string;
+ createdAt: number;
+ lastUsedAt: number;
+ expiresAt: number;
+}
+
+/**
+ * Pairing code response
+ */
+export interface PairingCodeResponse {
+ success: boolean;
+ code?: string;
+ host?: string;
+ port?: number;
+ expiresAt?: number;
+ error?: string;
+}
+
+/**
+ * Device list response
+ */
+export interface DeviceListResponse {
+ success: boolean;
+ devices?: PairedDevice[];
+ error?: string;
+}
+
+/**
+ * Revoke device response
+ */
+export interface RevokeDeviceResponse {
+ success: boolean;
+ revoked?: boolean;
+ error?: string;
+}
+
+/**
+ * Creates the mobile pairing API object for preload exposure
+ */
+export function createMobilePairingApi() {
+ return {
+ /**
+ * Generate a new pairing code for QR display.
+ * Returns the code, host, port, and expiration time.
+ * Requires the web server to be running.
+ */
+ generateCode: (): Promise =>
+ ipcRenderer.invoke('mobile-pairing:generate-code'),
+
+ /**
+ * List all paired devices (without tokens).
+ */
+ listDevices: (): Promise =>
+ ipcRenderer.invoke('mobile-pairing:list-devices'),
+
+ /**
+ * Revoke a paired device by ID.
+ */
+ revokeDevice: (id: string): Promise =>
+ ipcRenderer.invoke('mobile-pairing:revoke-device', id),
+ };
+}
+
+export type MobilePairingApi = ReturnType;
diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts
index 023d827e29..1fd0c27c6c 100644
--- a/src/main/web-server/WebServer.ts
+++ b/src/main/web-server/WebServer.ts
@@ -34,8 +34,9 @@ import { getLocalIpAddress } from '../utils/networkUtils';
import { captureException } from '../utils/sentry';
import { WebSocketMessageHandler } from './handlers';
import { BroadcastService } from './services';
-import { ApiRoutes, StaticRoutes, WsRoute } from './routes';
+import { ApiRoutes, StaticRoutes, WsRoute, MobilePairingRoutes } from './routes';
import { LiveSessionManager, CallbackRegistry } from './managers';
+import { redeemPairingCode } from '../mobile-pairing';
// Import shared types from canonical location
import type {
@@ -178,6 +179,7 @@ export class WebServer {
private apiRoutes: ApiRoutes;
private staticRoutes: StaticRoutes;
private wsRoute: WsRoute;
+ private mobilePairingRoutes: MobilePairingRoutes;
constructor(port: number = 0, securityToken?: string) {
// Use port 0 to let OS assign a random available port
@@ -225,6 +227,7 @@ export class WebServer {
this.apiRoutes = new ApiRoutes(this.securityToken, this.rateLimitConfig);
this.staticRoutes = new StaticRoutes(this.securityToken, this.webAssetsPath);
this.wsRoute = new WsRoute(this.securityToken);
+ this.mobilePairingRoutes = new MobilePairingRoutes();
// Note: setupMiddleware and setupRoutes are called in start() to handle async properly
}
@@ -830,6 +833,14 @@ export class WebServer {
},
});
this.wsRoute.registerRoute(this.server);
+
+ // Setup mobile pairing routes (public, no token required)
+ this.mobilePairingRoutes.setCallbacks({
+ redeemPairingCode: async (code, deviceName) => {
+ return redeemPairingCode(code, deviceName);
+ },
+ });
+ this.mobilePairingRoutes.registerRoutes(this.server);
}
private handleWebClientMessage(clientId: string, message: WebClientMessage): void {
diff --git a/src/main/web-server/handlers/messageHandlers.ts b/src/main/web-server/handlers/messageHandlers.ts
index 60f4af80fa..0418e4da90 100644
--- a/src/main/web-server/handlers/messageHandlers.ts
+++ b/src/main/web-server/handlers/messageHandlers.ts
@@ -144,6 +144,10 @@ export interface WebClient {
id: string;
connectedAt: number;
subscribedSessionId?: string;
+ /** Whether this is a mobile app client (vs browser) */
+ isMobileClient?: boolean;
+ /** Device ID from mobile-pairings.json for mobile clients */
+ mobileDeviceId?: string;
}
/**
diff --git a/src/main/web-server/routes/index.ts b/src/main/web-server/routes/index.ts
index 28a7006103..39eb754e5d 100644
--- a/src/main/web-server/routes/index.ts
+++ b/src/main/web-server/routes/index.ts
@@ -27,3 +27,9 @@ export {
LiveSessionInfo as WsLiveSessionInfo,
CustomAICommand as WsCustomAICommand,
} from './wsRoute';
+
+export {
+ MobilePairingRoutes,
+ MobilePairingRouteCallbacks,
+ RedeemCodeResult,
+} from './mobilePairingRoutes';
diff --git a/src/main/web-server/routes/mobilePairingRoutes.ts b/src/main/web-server/routes/mobilePairingRoutes.ts
new file mode 100644
index 0000000000..b854bb184f
--- /dev/null
+++ b/src/main/web-server/routes/mobilePairingRoutes.ts
@@ -0,0 +1,156 @@
+/**
+ * Mobile Pairing Routes for Web Server
+ *
+ * Public (non-token-protected) routes for mobile device pairing.
+ * The security model relies on the short-lived pairing code instead of the security token.
+ *
+ * API Endpoints:
+ * - POST /api/mobile-pairing/redeem - Exchange pairing code for long-lived token
+ */
+
+import { FastifyInstance } from 'fastify';
+import { logger } from '../../utils/logger';
+
+// Logger context for all mobile pairing route logs
+const LOG_CONTEXT = 'WebServer:MobilePairing';
+
+// Surface-level validation. The module enforces the same bounds, but rejecting
+// junk at the HTTP boundary keeps log noise down and short-circuits map lookups
+// on oversized strings.
+const CODE_PATTERN = /^[A-Z2-7]{6}$/;
+const MAX_DEVICE_NAME_LENGTH = 200;
+
+/**
+ * Result of redeeming a pairing code
+ */
+export interface RedeemCodeResult {
+ token: string;
+ deviceId: string;
+}
+
+/**
+ * Callbacks required by mobile pairing routes
+ */
+export interface MobilePairingRouteCallbacks {
+ redeemPairingCode: (code: string, deviceName: string) => Promise;
+}
+
+/**
+ * Mobile Pairing Routes Class
+ *
+ * Handles device pairing without requiring the security token.
+ * Security is provided by the short-lived pairing code.
+ */
+export class MobilePairingRoutes {
+ private callbacks: Partial = {};
+
+ /**
+ * Set the callbacks for mobile pairing operations
+ */
+ setCallbacks(callbacks: MobilePairingRouteCallbacks): void {
+ this.callbacks = callbacks;
+ }
+
+ /**
+ * Register mobile pairing routes on the Fastify server
+ */
+ registerRoutes(server: FastifyInstance): void {
+ // POST /api/mobile-pairing/redeem - Exchange pairing code for token
+ // This endpoint is public (no security token required) because:
+ // 1. The pairing code itself is the authentication mechanism
+ // 2. Codes are short-lived (5 minutes) and single-use
+ // 3. The code must be obtained from the desktop via QR code
+ server.post(
+ '/api/mobile-pairing/redeem',
+ {
+ config: {
+ rateLimit: {
+ max: 10, // Very restrictive: 10 attempts per minute
+ timeWindow: 60000,
+ },
+ },
+ },
+ async (request, reply) => {
+ const body = request.body as { code?: string; deviceName?: string } | undefined;
+ const rawCode = body?.code;
+ const rawDeviceName = body?.deviceName;
+
+ if (typeof rawCode !== 'string') {
+ return reply.code(400).send({
+ error: 'Bad Request',
+ message: 'Pairing code is required',
+ timestamp: Date.now(),
+ });
+ }
+ const normalizedCode = rawCode.toUpperCase().trim();
+ if (!CODE_PATTERN.test(normalizedCode)) {
+ return reply.code(400).send({
+ error: 'Bad Request',
+ message: 'Pairing code must be 6 base32 characters',
+ timestamp: Date.now(),
+ });
+ }
+
+ if (rawDeviceName !== undefined && typeof rawDeviceName !== 'string') {
+ return reply.code(400).send({
+ error: 'Bad Request',
+ message: 'deviceName must be a string',
+ timestamp: Date.now(),
+ });
+ }
+ if (typeof rawDeviceName === 'string' && rawDeviceName.length > MAX_DEVICE_NAME_LENGTH) {
+ return reply.code(400).send({
+ error: 'Bad Request',
+ message: `deviceName must be at most ${MAX_DEVICE_NAME_LENGTH} characters`,
+ timestamp: Date.now(),
+ });
+ }
+ const deviceName =
+ typeof rawDeviceName === 'string' && rawDeviceName.trim().length > 0
+ ? rawDeviceName.trim()
+ : 'Unknown Device';
+
+ if (!this.callbacks.redeemPairingCode) {
+ return reply.code(503).send({
+ error: 'Service Unavailable',
+ message: 'Pairing service not configured',
+ timestamp: Date.now(),
+ });
+ }
+
+ try {
+ const result = await this.callbacks.redeemPairingCode(normalizedCode, deviceName);
+
+ if (!result) {
+ // Code not found, expired, or already used
+ return reply.code(401).send({
+ error: 'Unauthorized',
+ message: 'Invalid or expired pairing code',
+ timestamp: Date.now(),
+ });
+ }
+
+ logger.info(`Mobile device paired: ${deviceName}`, LOG_CONTEXT);
+
+ return {
+ success: true,
+ token: result.token,
+ deviceId: result.deviceId,
+ timestamp: Date.now(),
+ };
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ logger.error(`Failed to redeem pairing code: ${message}`, LOG_CONTEXT, error);
+
+ return reply.code(500).send({
+ error: 'Internal Server Error',
+ message: 'Failed to redeem pairing code',
+ timestamp: Date.now(),
+ });
+ }
+ }
+ );
+
+ logger.debug('Mobile pairing routes registered', LOG_CONTEXT);
+ }
+}
diff --git a/src/main/web-server/routes/wsRoute.ts b/src/main/web-server/routes/wsRoute.ts
index f5ebdd4c94..484fcb1973 100644
--- a/src/main/web-server/routes/wsRoute.ts
+++ b/src/main/web-server/routes/wsRoute.ts
@@ -4,9 +4,15 @@
* This module contains the WebSocket route setup extracted from web-server.ts.
* Handles WebSocket connections, initial state sync, and message delegation.
*
- * Route: /$TOKEN/ws
+ * Route: /:token/ws
*
- * Connection Flow:
+ * Authentication:
+ * 1. If URL token matches securityToken, connection proceeds (browser auth)
+ * 2. If URL token doesn't match, validate as mobile token via mobile-pairing module
+ * 3. If mobile token is valid, update lastUsedAt and proceed
+ * 4. If neither matches, reject with auth failure
+ *
+ * Connection Flow (after auth):
* 1. Client connects with optional ?sessionId= query param
* 2. Server sends 'connected' message with client ID
* 3. Server sends 'sessions_list' with all sessions (enriched with live info)
@@ -17,6 +23,7 @@
import { FastifyInstance } from 'fastify';
import { logger } from '../../utils/logger';
+import { validateMobileToken, updateDeviceLastUsed } from '../../mobile-pairing';
import type {
Theme,
WebClient,
@@ -79,13 +86,48 @@ export class WsRoute {
}
/**
- * Register the WebSocket route on the Fastify server
+ * Register the WebSocket route on the Fastify server.
+ * Uses wildcard route to validate both browser security tokens and mobile tokens.
*/
registerRoute(server: FastifyInstance): void {
- const token = this.securityToken;
+ // Use wildcard route to capture any token for validation
+ server.get('/:token/ws', { websocket: true }, async (connection, request) => {
+ // Extract token from URL path
+ const urlPath = request.url || '';
+ const tokenMatch = urlPath.match(/^\/([^/]+)\/ws/);
+ const urlToken = tokenMatch ? tokenMatch[1] : '';
+
+ // Validate token: first check browser security token, then mobile token
+ let isMobileClient = false;
+ let mobileDeviceId: string | undefined;
+
+ if (urlToken !== this.securityToken) {
+ // Not the browser token - try mobile token validation
+ const device = await validateMobileToken(urlToken);
+ if (!device) {
+ // Neither browser nor mobile token - reject
+ logger.warn(`Auth failed: invalid token from ${request.ip}`, LOG_CONTEXT);
+ connection.socket.send(
+ JSON.stringify({
+ type: 'error',
+ message: 'Authentication failed: invalid token',
+ code: 'AUTH_FAILED',
+ })
+ );
+ connection.socket.close(4001, 'Authentication failed');
+ return;
+ }
+ // Valid mobile token
+ isMobileClient = true;
+ mobileDeviceId = device.id;
+ // Update lastUsedAt in background (don't await)
+ updateDeviceLastUsed(device.id).catch((err) => {
+ logger.warn(`Failed to update device lastUsedAt: ${err}`, LOG_CONTEXT);
+ });
+ logger.info(`Mobile client authenticated: ${device.deviceName}`, LOG_CONTEXT);
+ }
- server.get(`/${token}/ws`, { websocket: true }, (connection, request) => {
- const clientId = `web-client-${++this.clientIdCounter}`;
+ const clientId = `${isMobileClient ? 'mobile' : 'web'}-client-${++this.clientIdCounter}`;
// Extract sessionId from query string if provided (for session-specific subscriptions)
const url = new URL(request.url || '', `http://${request.headers.host || 'localhost'}`);
@@ -96,6 +138,8 @@ export class WsRoute {
id: clientId,
connectedAt: Date.now(),
subscribedSessionId: sessionId,
+ isMobileClient,
+ mobileDeviceId,
};
// Notify parent about connection
diff --git a/src/main/web-server/types.ts b/src/main/web-server/types.ts
index 698753b0c9..c2796f6913 100644
--- a/src/main/web-server/types.ts
+++ b/src/main/web-server/types.ts
@@ -224,6 +224,10 @@ export interface WebClient {
id: string;
connectedAt: number;
subscribedSessionId?: string;
+ /** Whether this is a mobile app client (vs browser) */
+ isMobileClient?: boolean;
+ /** Device ID from mobile-pairings.json for mobile clients */
+ mobileDeviceId?: string;
}
/**
diff --git a/src/renderer/components/Settings/MobileDevicesSection.tsx b/src/renderer/components/Settings/MobileDevicesSection.tsx
new file mode 100644
index 0000000000..f00c32e417
--- /dev/null
+++ b/src/renderer/components/Settings/MobileDevicesSection.tsx
@@ -0,0 +1,386 @@
+/**
+ * MobileDevicesSection - Settings section for mobile device pairing
+ *
+ * This component provides a UI for:
+ * - Generating pairing codes with QR display
+ * - Listing paired mobile devices
+ * - Revoking paired devices
+ *
+ * Part of M3 Mobile Expo App implementation (decision 6A QR pairing).
+ */
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { Smartphone, Plus, Trash2, QrCode, Clock, AlertCircle } from 'lucide-react';
+import { QRCodeSVG } from 'qrcode.react';
+import { GhostIconButton } from '../ui/GhostIconButton';
+import { Spinner } from '../ui/Spinner';
+import { useModalLayer } from '../../hooks/ui/useModalLayer';
+import { MODAL_PRIORITIES } from '../../constants/modalPriorities';
+import type { Theme } from '../../types';
+import { formatRelativeTime } from '../../../shared/formatters';
+
+interface PairedDevice {
+ id: string;
+ deviceName: string;
+ createdAt: number;
+ lastUsedAt: number;
+ expiresAt: number;
+}
+
+export interface MobileDevicesSectionProps {
+ theme: Theme;
+}
+
+export function MobileDevicesSection({ theme }: MobileDevicesSectionProps) {
+ // Paired devices state
+ const [devices, setDevices] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Pairing modal state
+ const [showPairingModal, setShowPairingModal] = useState(false);
+ const [pairingCode, setPairingCode] = useState(null);
+ const [pairingHost, setPairingHost] = useState(null);
+ const [pairingPort, setPairingPort] = useState(null);
+ const [pairingExpiresAt, setPairingExpiresAt] = useState(null);
+ const [pairingError, setPairingError] = useState(null);
+ const [generatingCode, setGeneratingCode] = useState(false);
+ const [secondsRemaining, setSecondsRemaining] = useState(0);
+
+ // Revoke state
+ const [revokingId, setRevokingId] = useState(null);
+
+ // Dedupe concurrent listDevices calls (mount + 2s poll can overlap on a slow disk).
+ const loadInFlightRef = useRef(false);
+
+ const loadDevices = useCallback(async () => {
+ if (loadInFlightRef.current) return;
+ loadInFlightRef.current = true;
+ try {
+ const result = await window.maestro.mobilePairing.listDevices();
+ if (result.success && result.devices) {
+ setDevices(result.devices);
+ setError(null);
+ } else {
+ setError(result.error || 'Failed to load devices');
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load devices');
+ } finally {
+ setLoading(false);
+ loadInFlightRef.current = false;
+ }
+ }, []);
+
+ useEffect(() => {
+ loadDevices();
+ }, [loadDevices]);
+
+ // Poll the device list while the pairing modal is open so a successful
+ // scan from the phone shows up without the user closing/reopening.
+ useEffect(() => {
+ if (!showPairingModal) return;
+ const id = setInterval(loadDevices, 2000);
+ return () => clearInterval(id);
+ }, [showPairingModal, loadDevices]);
+
+ // Countdown timer. Runs while the modal is open and an active code is in
+ // memory; clears the code (and surfaces an error) when it hits 0.
+ useEffect(() => {
+ if (!pairingExpiresAt || !showPairingModal) return;
+ const tick = () => {
+ const remaining = Math.max(0, Math.floor((pairingExpiresAt - Date.now()) / 1000));
+ setSecondsRemaining(remaining);
+ if (remaining === 0) {
+ setPairingCode(null);
+ setPairingError('Pairing code expired. Generate a new one.');
+ }
+ };
+ tick();
+ const id = setInterval(tick, 1000);
+ return () => clearInterval(id);
+ }, [pairingExpiresAt, showPairingModal]);
+
+ // Generate pairing code
+ const handleGenerateCode = async () => {
+ setGeneratingCode(true);
+ setPairingError(null);
+ try {
+ const result = await window.maestro.mobilePairing.generateCode();
+ if (result.success && result.code && result.host && result.port && result.expiresAt) {
+ setPairingCode(result.code);
+ setPairingHost(result.host);
+ setPairingPort(result.port);
+ setPairingExpiresAt(result.expiresAt);
+ } else {
+ setPairingError(result.error || 'Failed to generate pairing code');
+ }
+ } catch (err) {
+ setPairingError(err instanceof Error ? err.message : 'Failed to generate pairing code');
+ } finally {
+ setGeneratingCode(false);
+ }
+ };
+
+ // Open pairing modal and generate code
+ const handleOpenPairingModal = async () => {
+ setShowPairingModal(true);
+ await handleGenerateCode();
+ };
+
+ // Close pairing modal
+ const handleClosePairingModal = () => {
+ setShowPairingModal(false);
+ setPairingCode(null);
+ setPairingHost(null);
+ setPairingPort(null);
+ setPairingExpiresAt(null);
+ setPairingError(null);
+ setSecondsRemaining(0);
+ };
+
+ // Revoke device
+ const handleRevoke = async (id: string) => {
+ setRevokingId(id);
+ try {
+ const result = await window.maestro.mobilePairing.revokeDevice(id);
+ if (result.success) {
+ setDevices((prev) => prev.filter((d) => d.id !== id));
+ }
+ } catch {
+ // Silently fail, device may have already been revoked
+ } finally {
+ setRevokingId(null);
+ }
+ };
+
+ // Format countdown as mm:ss
+ const formatCountdown = (seconds: number): string => {
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+ };
+
+ // Build QR code payload
+ const qrPayload =
+ pairingCode && pairingHost && pairingPort
+ ? `maestro://pair?host=${encodeURIComponent(pairingHost)}&port=${pairingPort}&code=${pairingCode}`
+ : '';
+
+ return (
+
+ {/* Section Header */}
+
+
+
+ Mobile Devices
+
+
+
+ Pair New Device
+
+
+
+ {/* Description */}
+
+ Pair your mobile device with Maestro using QR code scanning. Paired devices can access your
+ agents via the Maestro mobile app over your local network.
+