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' }); + + + + + {/* 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. +

+ + {/* Loading State */} + {loading && ( +
+ +
+ )} + + {/* Error State */} + {error && !loading && ( +
+ + {error} +
+ )} + + {/* Empty State */} + {!loading && !error && devices.length === 0 && ( +
+ +

No paired devices

+

Click "Pair New Device" to connect your mobile

+
+ )} + + {/* Devices List */} + {!loading && !error && devices.length > 0 && ( +
+ {devices.map((device) => ( +
+
+ +
+
{device.deviceName}
+
+ + Last used {formatRelativeTime(device.lastUsedAt)} +
+
+
+ handleRevoke(device.id)} + disabled={revokingId === device.id} + > + {revokingId === device.id ? ( + + ) : ( + + )} + +
+ ))} +
+ )} + + {showPairingModal && ( + + )} + + ); +} + +interface PairingModalProps { + theme: Theme; + pairingCode: string | null; + qrPayload: string; + pairingError: string | null; + generatingCode: boolean; + secondsRemaining: number; + onClose: () => void; + onRegenerate: () => void; + formatCountdown: (seconds: number) => string; +} + +function PairingModal({ + theme, + pairingCode, + qrPayload, + pairingError, + generatingCode, + secondsRemaining, + onClose, + onRegenerate, + formatCountdown, +}: PairingModalProps) { + // Register with the layer stack so Escape closes the dialog, focus is + // trapped, and lower layers stop receiving keyboard events. + useModalLayer(MODAL_PRIORITIES.MOBILE_PAIRING, 'Pair New Device', onClose); + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label="Pair New Device" + > +
+
+ + Pair New Device +
+ +
+ + {pairingError && ( +
+ + {pairingError} +
+ )} + + {generatingCode && !pairingCode && ( +
+ +

Generating pairing code...

+
+ )} + + {pairingCode && qrPayload && ( +
+
+ +
+ +

+ Open the Maestro mobile app and scan this QR code to pair your device. +

+ +
+ + Expires in {formatCountdown(secondsRemaining)} +
+ + +
+ )} +
+
+ ); +} diff --git a/src/renderer/components/Settings/searchableSettings.ts b/src/renderer/components/Settings/searchableSettings.ts index 3725fa6bda..cfc0f3b903 100644 --- a/src/renderer/components/Settings/searchableSettings.ts +++ b/src/renderer/components/Settings/searchableSettings.ts @@ -383,6 +383,30 @@ export const GENERAL_SETTINGS: SearchableSetting[] = [ 'inactive', ], }, + { + id: 'general-mobile-devices', + tab: 'general', + tabLabel: 'General', + label: 'Mobile Devices', + description: + 'Pair your mobile device with Maestro using QR code scanning. Manage paired devices that can access your agents via the Maestro mobile app.', + keywords: [ + 'mobile', + 'devices', + 'phone', + 'pairing', + 'pair', + 'qr', + 'qr code', + 'scan', + 'app', + 'ios', + 'android', + 'smartphone', + 'revoke', + 'connect', + ], + }, { id: 'general-storage', tab: 'general', diff --git a/src/renderer/components/Settings/tabs/GeneralTab.tsx b/src/renderer/components/Settings/tabs/GeneralTab.tsx index 5f64a3ad9c..94de28c23b 100644 --- a/src/renderer/components/Settings/tabs/GeneralTab.tsx +++ b/src/renderer/components/Settings/tabs/GeneralTab.tsx @@ -48,6 +48,7 @@ import { SettingCheckbox } from '../../SettingCheckbox'; import { ToggleSwitch } from '../../ui/ToggleSwitch'; import { KeyCaptureButton } from '../../ui/KeyCaptureButton'; import { logger } from '../../../utils/logger'; +import { MobileDevicesSection } from '../MobileDevicesSection'; export interface GeneralTabProps { theme: Theme; @@ -1480,6 +1481,11 @@ export function GeneralTab({ theme, isOpen }: GeneralTabProps) { + {/* Mobile Devices - QR pairing for mobile app */} +
+ +
+ {/* Settings Storage Location */}
diff --git a/src/renderer/constants/modalPriorities.ts b/src/renderer/constants/modalPriorities.ts index 0843c8e637..66ad0f912b 100644 --- a/src/renderer/constants/modalPriorities.ts +++ b/src/renderer/constants/modalPriorities.ts @@ -249,6 +249,10 @@ export const MODAL_PRIORITIES = { /** SSH Remote configuration modal (above settings) */ SSH_REMOTE: 458, + /** Mobile device pairing modal (above settings, Escape closes the pairing dialog + * first and leaves Settings open). */ + MOBILE_PAIRING: 459, + /** Custom theme base-theme picker dropdown (above settings so Escape closes * the dropdown first, leaving the Settings modal open for a second Esc). */ CUSTOM_THEME_BASE_SELECTOR: 451, diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 6efc4d98c8..fddf20af91 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -3520,6 +3520,34 @@ interface MaestroAPI { agentId?: string ) => Promise<{ success: boolean; path?: string; error?: string }>; }; + + // Mobile Pairing API (QR-based device pairing) + mobilePairing: { + generateCode: () => Promise<{ + success: boolean; + code?: string; + host?: string; + port?: number; + expiresAt?: number; + error?: string; + }>; + listDevices: () => Promise<{ + success: boolean; + devices?: Array<{ + id: string; + deviceName: string; + createdAt: number; + lastUsedAt: number; + expiresAt: number; + }>; + error?: string; + }>; + revokeDevice: (id: string) => Promise<{ + success: boolean; + revoked?: boolean; + error?: string; + }>; + }; } declare global { diff --git a/src/web/hooks/__tests__/useOfflineQueue.test.ts b/src/web/hooks/__tests__/useOfflineQueue.test.ts new file mode 100644 index 0000000000..eb173e0667 --- /dev/null +++ b/src/web/hooks/__tests__/useOfflineQueue.test.ts @@ -0,0 +1,741 @@ +/** + * Tests for useOfflineQueue hook + * + * This hook provides offline command queueing functionality that stores commands + * typed while offline and automatically sends them when reconnected. + * + * Tests cover: + * - Round-trip queue persistence via storage adapter + * - Load on mount behavior + * - Save on queue change behavior + * - Queue operations (add, remove, clear) + * - Processing behavior + * - Storage adapter injection + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + useOfflineQueue, + type QueuedCommand, + type StorageAdapter, + createLocalStorageAdapter, +} from '../useOfflineQueue'; + +// Storage key used by the hook +const STORAGE_KEY = 'maestro-offline-queue'; + +// Mock webLogger to avoid console noise +vi.mock('../../utils/logger', () => ({ + webLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +/** + * Create a mock storage adapter that wraps an in-memory store + */ +function createMockStorageAdapter(): { adapter: StorageAdapter; store: Record } { + const store: Record = {}; + const adapter: StorageAdapter = { + getItem: vi.fn((key: string) => Promise.resolve(store[key] ?? null)), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + return Promise.resolve(); + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + return Promise.resolve(); + }), + }; + return { adapter, store }; +} + +describe('useOfflineQueue', () => { + // Default options for hook + const defaultOptions = { + isOnline: true, + isConnected: true, + sendCommand: vi.fn().mockReturnValue(true), + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('load on mount', () => { + it('should load queued commands from storage adapter on mount', async () => { + const { adapter, store } = createMockStorageAdapter(); + const storedCommands: QueuedCommand[] = [ + { + id: 'cmd-1', + command: 'test command 1', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 0, + }, + { + id: 'cmd-2', + command: 'test command 2', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'terminal', + attempts: 1, + }, + ]; + store[STORAGE_KEY] = JSON.stringify(storedCommands); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + // Wait for async loading + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.queue).toHaveLength(2); + expect(result.current.queue[0].command).toBe('test command 1'); + expect(result.current.queue[1].command).toBe('test command 2'); + expect(adapter.getItem).toHaveBeenCalledWith(STORAGE_KEY); + }); + + it('should initialize with empty queue when storage is empty', async () => { + const { adapter } = createMockStorageAdapter(); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.queue).toHaveLength(0); + expect(result.current.queueLength).toBe(0); + }); + + it('should initialize with empty queue when storage has invalid JSON', async () => { + const { adapter, store } = createMockStorageAdapter(); + store[STORAGE_KEY] = 'invalid json {{'; + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.queue).toHaveLength(0); + }); + + it('should initialize with empty queue when storage has non-array data', async () => { + const { adapter, store } = createMockStorageAdapter(); + store[STORAGE_KEY] = JSON.stringify({ foo: 'bar' }); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.queue).toHaveLength(0); + }); + + it('should work without storage adapter (no-op persistence)', async () => { + const { result } = renderHook(() => + useOfflineQueue({ ...defaultOptions, isOnline: false, isConnected: false, storage: null }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.queue).toHaveLength(0); + + // Should still be able to queue commands (in-memory) + act(() => { + result.current.queueCommand('session-1', 'test', 'ai'); + }); + + expect(result.current.queueLength).toBe(1); + }); + }); + + describe('save on change', () => { + it('should persist queue to storage when command is added', async () => { + const { adapter, store } = createMockStorageAdapter(); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + // Wait for initialization + await act(async () => { + await vi.runAllTimersAsync(); + }); + + act(() => { + result.current.queueCommand('session-1', 'test command', 'ai'); + }); + + // Allow async effect to run + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(adapter.setItem).toHaveBeenCalled(); + const savedData = JSON.parse(store[STORAGE_KEY]); + expect(savedData).toHaveLength(1); + expect(savedData[0].command).toBe('test command'); + }); + + it('should persist queue to storage when command is removed', async () => { + const { adapter, store } = createMockStorageAdapter(); + const storedCommands: QueuedCommand[] = [ + { + id: 'cmd-1', + command: 'test command 1', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 0, + }, + { + id: 'cmd-2', + command: 'test command 2', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 0, + }, + ]; + store[STORAGE_KEY] = JSON.stringify(storedCommands); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + act(() => { + result.current.removeCommand('cmd-1'); + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + const savedData = JSON.parse(store[STORAGE_KEY]); + expect(savedData).toHaveLength(1); + expect(savedData[0].id).toBe('cmd-2'); + }); + + it('should persist empty queue to storage when cleared', async () => { + const { adapter, store } = createMockStorageAdapter(); + const storedCommands: QueuedCommand[] = [ + { + id: 'cmd-1', + command: 'test command', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 0, + }, + ]; + store[STORAGE_KEY] = JSON.stringify(storedCommands); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + act(() => { + result.current.clearQueue(); + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + const savedData = JSON.parse(store[STORAGE_KEY]); + expect(savedData).toHaveLength(0); + }); + }); + + describe('round-trip persistence', () => { + it('should survive unmount/remount with queue intact', async () => { + const { adapter, store } = createMockStorageAdapter(); + + // First render: queue some commands + const { result: result1, unmount } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + act(() => { + result1.current.queueCommand('session-1', 'command 1', 'ai'); + result1.current.queueCommand('session-1', 'command 2', 'terminal'); + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // Unmount first hook + unmount(); + + // Second render: queue should be restored + const { result: result2 } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result2.current.queue).toHaveLength(2); + expect(result2.current.queue[0].command).toBe('command 1'); + expect(result2.current.queue[1].command).toBe('command 2'); + }); + }); + + describe('queue operations', () => { + it('should add command to queue', async () => { + const { adapter } = createMockStorageAdapter(); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + act(() => { + const cmd = result.current.queueCommand('session-1', 'test command', 'ai'); + expect(cmd).not.toBeNull(); + expect(cmd!.command).toBe('test command'); + expect(cmd!.sessionId).toBe('session-1'); + expect(cmd!.inputMode).toBe('ai'); + }); + + expect(result.current.queueLength).toBe(1); + }); + + it('should not queue beyond max capacity (50)', async () => { + const { adapter } = createMockStorageAdapter(); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // Queue 50 commands + for (let i = 0; i < 50; i++) { + act(() => { + result.current.queueCommand('session-1', `command ${i}`, 'ai'); + }); + } + + expect(result.current.queueLength).toBe(50); + expect(result.current.canQueue).toBe(false); + + // Try to queue one more + act(() => { + const cmd = result.current.queueCommand('session-1', 'overflow', 'ai'); + expect(cmd).toBeNull(); + }); + + expect(result.current.queueLength).toBe(50); + }); + + it('should remove specific command', async () => { + const { adapter } = createMockStorageAdapter(); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + let cmdId: string; + act(() => { + const cmd1 = result.current.queueCommand('session-1', 'command 1', 'ai'); + cmdId = cmd1!.id; + result.current.queueCommand('session-1', 'command 2', 'ai'); + }); + + expect(result.current.queueLength).toBe(2); + + act(() => { + result.current.removeCommand(cmdId); + }); + + expect(result.current.queueLength).toBe(1); + expect(result.current.queue[0].command).toBe('command 2'); + }); + + it('should clear all commands', async () => { + const { adapter } = createMockStorageAdapter(); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + act(() => { + result.current.queueCommand('session-1', 'command 1', 'ai'); + result.current.queueCommand('session-1', 'command 2', 'ai'); + }); + + expect(result.current.queueLength).toBe(2); + + act(() => { + result.current.clearQueue(); + }); + + expect(result.current.queueLength).toBe(0); + expect(result.current.queue).toEqual([]); + }); + }); + + describe('queue processing', () => { + it('should process queue when connected', async () => { + const sendCommand = vi.fn().mockReturnValue(true); + const onCommandSent = vi.fn(); + const { adapter, store } = createMockStorageAdapter(); + + // Start disconnected with a queued command + const storedCommands: QueuedCommand[] = [ + { + id: 'cmd-1', + command: 'test command', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 0, + }, + ]; + store[STORAGE_KEY] = JSON.stringify(storedCommands); + + const { result, rerender } = renderHook( + ({ isOnline, isConnected }) => + useOfflineQueue({ + isOnline, + isConnected, + sendCommand, + onCommandSent, + storage: adapter, + }), + { initialProps: { isOnline: false, isConnected: false } } + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.queueLength).toBe(1); + + // Reconnect + rerender({ isOnline: true, isConnected: true }); + + // Allow auto-processing timer to fire + await act(async () => { + await vi.advanceTimersByTimeAsync(600); // 500ms delay + buffer + }); + + // Allow async processing to complete + await act(async () => { + await vi.advanceTimersByTimeAsync(200); // SEND_DELAY + }); + + expect(sendCommand).toHaveBeenCalledWith('session-1', 'test command'); + expect(onCommandSent).toHaveBeenCalled(); + }); + + it('should not process when offline', async () => { + const sendCommand = vi.fn(); + const { adapter, store } = createMockStorageAdapter(); + + const storedCommands: QueuedCommand[] = [ + { + id: 'cmd-1', + command: 'test command', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 0, + }, + ]; + store[STORAGE_KEY] = JSON.stringify(storedCommands); + + const { result } = renderHook(() => + useOfflineQueue({ + isOnline: false, + isConnected: false, + sendCommand, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + await act(async () => { + await result.current.processQueue(); + }); + + expect(sendCommand).not.toHaveBeenCalled(); + }); + + it('should pause and resume processing', async () => { + const { adapter } = createMockStorageAdapter(); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.status).toBe('idle'); + + act(() => { + result.current.pauseProcessing(); + }); + + expect(result.current.status).toBe('paused'); + + act(() => { + result.current.resumeProcessing(); + }); + + expect(result.current.status).toBe('idle'); + }); + }); + + describe('callbacks', () => { + it('should call onCommandFailed after max retries', async () => { + const sendCommand = vi.fn().mockReturnValue(false); + const onCommandFailed = vi.fn(); + const { adapter, store } = createMockStorageAdapter(); + + const storedCommands: QueuedCommand[] = [ + { + id: 'cmd-1', + command: 'failing command', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 2, // Already tried twice, next is third (max) + }, + ]; + store[STORAGE_KEY] = JSON.stringify(storedCommands); + + renderHook(() => + useOfflineQueue({ + isOnline: true, + isConnected: true, + sendCommand, + maxRetries: 3, + onCommandFailed, + storage: adapter, + }) + ); + + // Wait for storage load + initialization + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // Wait for auto-process timer (500ms) to start processing + await act(async () => { + await vi.advanceTimersByTimeAsync(600); + }); + + // Wait for SEND_DELAY between commands + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + + expect(onCommandFailed).toHaveBeenCalled(); + }); + + it('should call onProcessingStart and onProcessingComplete', async () => { + const onProcessingStart = vi.fn(); + const onProcessingComplete = vi.fn(); + const sendCommand = vi.fn().mockReturnValue(true); + const { adapter, store } = createMockStorageAdapter(); + + const storedCommands: QueuedCommand[] = [ + { + id: 'cmd-1', + command: 'test', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 0, + }, + ]; + store[STORAGE_KEY] = JSON.stringify(storedCommands); + + renderHook(() => + useOfflineQueue({ + isOnline: true, + isConnected: true, + sendCommand, + onProcessingStart, + onProcessingComplete, + storage: adapter, + }) + ); + + // Wait for storage load + initialization + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // Wait for auto-process timer (500ms) to start processing + await act(async () => { + await vi.advanceTimersByTimeAsync(600); + }); + + // Wait for SEND_DELAY between commands + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + + expect(onProcessingStart).toHaveBeenCalled(); + expect(onProcessingComplete).toHaveBeenCalledWith(1, 0); + }); + }); + + describe('createLocalStorageAdapter', () => { + it('should create a localStorage-backed adapter', async () => { + // Mock localStorage + const mockStorage: Record = {}; + vi.spyOn(Storage.prototype, 'getItem').mockImplementation( + (key: string) => mockStorage[key] ?? null + ); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string, value: string) => { + mockStorage[key] = value; + }); + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation((key: string) => { + delete mockStorage[key]; + }); + + const adapter = createLocalStorageAdapter(); + expect(adapter).not.toBeNull(); + + // Test the adapter works + await adapter!.setItem('test-key', 'test-value'); + const value = await adapter!.getItem('test-key'); + expect(value).toBe('test-value'); + + await adapter!.removeItem('test-key'); + const removed = await adapter!.getItem('test-key'); + expect(removed).toBeNull(); + }); + }); +}); diff --git a/src/web/hooks/useOfflineQueue.ts b/src/web/hooks/useOfflineQueue.ts index 647853d05c..f93bb3885b 100644 --- a/src/web/hooks/useOfflineQueue.ts +++ b/src/web/hooks/useOfflineQueue.ts @@ -5,11 +5,19 @@ * typed while offline and automatically sends them when reconnected. * * Features: - * - Persists queued commands to localStorage for survival across page reloads + * - Persists queued commands via injected storage adapter for survival across app reloads * - Automatically sends queued commands when connection is restored * - Tracks queue status and provides progress feedback * - Allows manual retry and clearing of queued commands * - Handles partial queue failures gracefully + * + * Storage contract: + * - storage.getItem(key): Promise + * - storage.setItem(key, value): Promise + * - storage.removeItem(key): Promise + * + * When storage is null/undefined, persistence calls are no-ops (useful for testing + * or environments without persistent storage). */ import { useState, useEffect, useCallback, useRef } from 'react'; @@ -24,6 +32,16 @@ const MAX_QUEUE_SIZE = 50; /** Delay between sending queued commands (ms) */ const SEND_DELAY = 100; +/** + * Storage adapter interface for queue persistence. + * Matches the async contract used by AsyncStorage, SecureStore, etc. + */ +export interface StorageAdapter { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; +} + /** * Queued command entry */ @@ -61,6 +79,8 @@ export interface UseOfflineQueueOptions { sendCommand: (sessionId: string, command: string) => boolean; /** Maximum retry attempts per command (default: 3) */ maxRetries?: number; + /** Storage adapter for queue persistence. When null/undefined, persistence is disabled. */ + storage?: StorageAdapter | null; /** Callback when a queued command is successfully sent */ onCommandSent?: (command: QueuedCommand) => void; /** Callback when a queued command fails after all retries */ @@ -109,32 +129,24 @@ function generateId(): string { } /** - * Load queue from localStorage - */ -function loadQueue(): QueuedCommand[] { - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - const parsed = JSON.parse(stored); - if (Array.isArray(parsed)) { - return parsed; - } - } - } catch (error) { - webLogger.warn('Failed to load queue from storage', 'OfflineQueue', error); - } - return []; -} - -/** - * Save queue to localStorage + * Create a thin Promise wrapper around localStorage for web environments. + * Returns null in environments where localStorage is not available. */ -function saveQueue(queue: QueuedCommand[]): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(queue)); - } catch (error) { - webLogger.warn('Failed to save queue to storage', 'OfflineQueue', error); +export function createLocalStorageAdapter(): StorageAdapter | null { + if (typeof window === 'undefined' || typeof localStorage === 'undefined') { + return null; } + return { + getItem: (key: string) => Promise.resolve(localStorage.getItem(key)), + setItem: (key: string, value: string) => { + localStorage.setItem(key, value); + return Promise.resolve(); + }, + removeItem: (key: string) => { + localStorage.removeItem(key); + return Promise.resolve(); + }, + }; } /** @@ -142,13 +154,16 @@ function saveQueue(queue: QueuedCommand[]): void { * * @example * ```tsx - * function MobileApp() { + * // Web usage with localStorage adapter + * function WebApp() { + * const storage = createLocalStorageAdapter(); * const { queue, queueLength, queueCommand, status } = useOfflineQueue({ * isOnline: navigator.onLine, * isConnected: wsState === 'authenticated', * sendCommand: (sessionId, command) => { * return send({ type: 'send_command', sessionId, command }); * }, + * storage, * onCommandSent: (cmd) => { * console.log('Queued command sent:', cmd.command); * }, @@ -172,6 +187,14 @@ function saveQueue(queue: QueuedCommand[]): void { *
* ); * } + * + * // React Native usage with AsyncStorage adapter + * import AsyncStorage from '@react-native-async-storage/async-storage'; + * const asyncStorageAdapter = { + * getItem: AsyncStorage.getItem, + * setItem: AsyncStorage.setItem, + * removeItem: AsyncStorage.removeItem, + * }; * ``` */ export function useOfflineQueue(options: UseOfflineQueueOptions): UseOfflineQueueReturn { @@ -180,6 +203,7 @@ export function useOfflineQueue(options: UseOfflineQueueOptions): UseOfflineQueu isConnected, sendCommand, maxRetries = 3, + storage, onCommandSent, onCommandFailed, onProcessingStart, @@ -187,25 +211,79 @@ export function useOfflineQueue(options: UseOfflineQueueOptions): UseOfflineQueu } = options; // State - const [queue, setQueue] = useState(() => loadQueue()); + const [queue, setQueue] = useState([]); const [status, setStatus] = useState('idle'); + const [isInitialized, setIsInitialized] = useState(false); // Refs for async processing const isProcessingRef = useRef(false); const isPausedRef = useRef(false); const sendCommandRef = useRef(sendCommand); + const storageRef = useRef(storage); - // Keep sendCommand ref up to date + // Keep refs up to date useEffect(() => { sendCommandRef.current = sendCommand; }, [sendCommand]); + useEffect(() => { + storageRef.current = storage; + }, [storage]); + /** - * Save queue to localStorage whenever it changes + * Load queue from storage on mount */ useEffect(() => { - saveQueue(queue); - }, [queue]); + let cancelled = false; + + async function loadQueue() { + if (!storage) { + setIsInitialized(true); + return; + } + + try { + const stored = await storage.getItem(STORAGE_KEY); + if (cancelled) return; + + if (stored) { + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) { + setQueue(parsed); + } + } + } catch (error) { + webLogger.warn('Failed to load queue from storage', 'OfflineQueue', error); + } + setIsInitialized(true); + } + + loadQueue(); + + return () => { + cancelled = true; + }; + }, [storage]); + + /** + * Save queue to storage whenever it changes (after initialization) + */ + useEffect(() => { + if (!isInitialized) return; + + async function saveQueue() { + const currentStorage = storageRef.current; + if (!currentStorage) return; + + try { + await currentStorage.setItem(STORAGE_KEY, JSON.stringify(queue)); + } catch (error) { + webLogger.warn('Failed to save queue to storage', 'OfflineQueue', error); + } + } + + saveQueue(); + }, [queue, isInitialized]); /** * Queue a command for later sending diff --git a/src/web/mobile/App.tsx b/src/web/mobile/App.tsx index 85ce3cb622..b3ca4b16cc 100644 --- a/src/web/mobile/App.tsx +++ b/src/web/mobile/App.tsx @@ -19,7 +19,7 @@ import { // Command history is no longer used in the mobile UI import { useNotifications } from '../hooks/useNotifications'; import { useUnreadBadge } from '../hooks/useUnreadBadge'; -import { useOfflineQueue } from '../hooks/useOfflineQueue'; +import { useOfflineQueue, createLocalStorageAdapter } from '../hooks/useOfflineQueue'; import { useMobileSessionManagement } from '../hooks/useMobileSessionManagement'; import { useOfflineStatus, useDesktopTheme } from '../main'; import { buildApiUrl } from '../utils/config'; @@ -1079,6 +1079,9 @@ function GroupChatListSheet({ chats, onSelectChat, onNewChat, onClose }: GroupCh ); } +// Storage adapter for offline queue persistence (created once at module load) +const offlineQueueStorage = createLocalStorageAdapter(); + /** * Main mobile app component with WebSocket connection management */ @@ -1823,6 +1826,7 @@ export default function MobileApp() { triggerHaptic(HAPTIC_PATTERNS.success); } }, + storage: offlineQueueStorage, }); // Retry connection handler