Skip to content

Mobile#1113

Open
Ashraf-Ali-aa wants to merge 12 commits into
RunMaestro:mainfrom
Ashraf-Ali-aa:mobile
Open

Mobile#1113
Ashraf-Ali-aa wants to merge 12 commits into
RunMaestro:mainfrom
Ashraf-Ali-aa:mobile

Conversation

@Ashraf-Ali-aa

@Ashraf-Ali-aa Ashraf-Ali-aa commented Jun 20, 2026

Copy link
Copy Markdown

Summary by CodeRabbit

  • New Features
    • Added Maestro Mobile experience: pairing (QR/manual), session-based multi-tab chat, searchable chats, drawer navigation, settings/profile/capabilities, offline command queue, and theme-aware UI (connection status, glass effects, custom headers).
  • Documentation
    • Added extensive mobile UI/build reference guides (routing/tabs/storage/animations/controls/media/WebGPU/Uniwind/SF Symbols/visual effects) plus new mobile README and agent instructions.
  • Tests
    • Added/expanded Jest/Vitest coverage for offline queue replay, WebSocket routing, session chat/history, streaming reconciliation, parsing, and core smoke/adapter behaviors.
  • Chores
    • Updated CI to conditionally run mobile checks; added mobile lint/test/dev scripts and new EAS deploy/preview workflows.

@greptile-apps

greptile-apps Bot commented Jun 20, 2026

Copy link
Copy Markdown

Too many files changed for review. (149 files found, 100 file limit)

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1d70fd20-0f42-49cd-8d08-c98ed2d147de

📥 Commits

Reviewing files that changed from the base of the PR and between b5f103a and 60120f0.

📒 Files selected for processing (1)
  • .github/workflows/ci.yml
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/ci.yml

📝 Walkthrough

Walkthrough

This change adds an Expo mobile workspace with pairing, WebSocket session chat, offline queueing, markdown rendering, app shell screens, mobile tooling and docs, conditional CI checks, and desktop WebSocket tests for mobile token authentication and parameterized token routes.

Changes

Mobile app workspace

Layer / File(s) Summary
Workspace setup and mobile guidance
.github/workflows/ci.yml, .prettierignore, apps/mobile/.agents/skills/..., apps/mobile/.claude/*, apps/mobile/.eas/workflows/*, apps/mobile/README.md, apps/mobile/app.json, apps/mobile/*config*, apps/mobile/src/*.css, apps/mobile/tsconfig.json, package.json
Adds the mobile workspace configuration, conditional CI checks, Expo and Metro setup, lint and test setup, design tokens, root scripts, and mobile guidance/reference documents.
Pairing, credentials, and transport
apps/mobile/shims/*, apps/mobile/src/pairing/*, apps/mobile/src/lib/credentials.ts, apps/mobile/src/hooks/usePairingCheck.ts, apps/mobile/src/lib/useMaestroWebSocket.ts, apps/mobile/src/hooks/useMaestroConnection.ts, apps/mobile/src/lib/__tests__/*, apps/mobile/src/__tests__/sessionAddedRouting.test.tsx
Adds QR/manual pairing, SecureStore-backed credentials, mobile config and logger shims, route guarding, WebSocket connection and reconnection handling, and transport-level parsing and routing tests.
Session context and streaming state
apps/mobile/src/lib/SessionsContext.tsx, apps/mobile/src/hooks/useSessionChat.ts, apps/mobile/src/hooks/useMaestroOfflineQueue.ts, apps/mobile/src/storage/*, apps/mobile/src/streaming/*, apps/mobile/src/__tests__/offlineQueueReplay.test.ts, apps/mobile/src/hooks/__tests__/*
Adds shared session state, offline command persistence, streaming message reconciliation, history loading, event subscriptions, and tests for queue, history, and streaming behaviors.
Chat rendering and markdown primitives
apps/mobile/src/components/chat/*, apps/mobile/src/components/markdown/*
Adds chat context, conversation and composer components for native and web, streaming text display, and a custom markdown renderer with code blocks, link handling, and tests.
App shell and reusable components
apps/mobile/src/app/_layout*.tsx, apps/mobile/src/components/{drawer-*,sidebar*,main-header*,AITabStrip,ConnectionStatusPill,icon,symbol-image,tw,touchable-glass,blur-raw,grabber}.tsx, apps/mobile/src/theme/AccentContext.tsx, apps/mobile/src/lib/ToastContext.tsx, apps/mobile/src/components/model-context.tsx, apps/mobile/src/utils/*
Adds the root navigation shell, drawer and sidebar infrastructure, headers, tab strip, status pill, glass and icon helpers, theming, model selection, toast handling, and shared UI utilities.
Chat, settings, and utility screens
apps/mobile/src/app/index.tsx, apps/mobile/src/app/session/[sessionId].tsx, apps/mobile/src/app/chats.tsx, apps/mobile/src/app/attachments.tsx, apps/mobile/src/app/model-picker.tsx, apps/mobile/src/app/(settings)/*, apps/mobile/src/app/__tests__/*
Adds the main chat screens, chats list, attachment and model sheets, settings routes, and a regression test that checks the chat screens use useSessionChat.

Desktop WebSocket auth tests

Layer / File(s) Summary
Parameterized token route and mobile auth cases
src/__tests__/main/web-server/routes/wsRoute.test.ts
Updates route lookups to use /:token/ws, extends test request and socket mocks, and adds tests for mobile token validation, auth failure handling, and client ID prefixes.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant PairScreen
  participant SecureStore
  participant useMaestroWebSocket
  participant SessionsContext
  participant useSessionChat

  User->>PairScreen: scan QR or submit manual code
  PairScreen->>SecureStore: store credentials
  PairScreen->>useMaestroWebSocket: connect()
  useMaestroWebSocket->>SessionsContext: emit authenticated and session events
  SessionsContext->>useSessionChat: deliver output/state/tool/input events
  useSessionChat->>User: render chat messages and streaming state
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐇 I packed a tiny mobile burrow bright,
with chats and tabs and pairing by moonlight.
I queued lost notes when tunnels lost their gleam,
then streamed them back in one connected stream.
With glassy buttons, docs, and tests in tow,
this rabbit app is ready now to go.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.37% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Mobile' is too vague and generic—it does not convey any meaningful information about the specific changes in this large changeset. Consider a more descriptive title that captures the main initiative, e.g., 'Add complete mobile app built with Expo and React Native' or 'Introduce Maestro mobile client with WebSocket connectivity and offline queue support'.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (28)
apps/mobile/src/app/(settings)/_layout.tsx-43-55 (1)

43-55: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Legal/help menu actions are currently non-functional.

Lines 43-55 render policy/support items without any action, so users cannot actually open these destinations. Please wire each item to its target (e.g., external URL or in-app route), especially for policy entries.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/app/`(settings)/_layout.tsx around lines 43 - 55, The
Stack.Toolbar.MenuAction components for Acceptable Use Policy, Consumer Terms,
Privacy Policy, and Help & Support are rendered without any onPress handlers or
navigation logic. Add onPress handlers to each of these menu action items that
navigate to the appropriate destinations, whether that be external URLs (using
linking APIs) for policy documents or in-app routes for help content. Ensure
each menu item triggers the correct navigation behavior when tapped by the user.
apps/mobile/src/utils/use-system-background-color.ts-6-9 (1)

6-9: ⚠️ Potential issue | 🟠 Major

Guard background color before invoking System UI API.

The double-cast bypasses type safety and allows useCSSVariable() to return undefined or numeric values, which gets coerced to a string. While undefined is technically valid (clears the background), invalid color strings will cause unhandled Promise rejections during theme transitions. Validate the value before calling the async function.

Suggested fix
 export function useSystemBackgroundColor() {
 	const color = useCSSVariable('--app-background');
 	useEffect(() => {
-		SystemUI.setBackgroundColorAsync(color as unknown as string);
+		if (typeof color !== 'string' || color.length === 0) return;
+		void SystemUI.setBackgroundColorAsync(color);
 	}, [color]);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/utils/use-system-background-color.ts` around lines 6 - 9, The
code in the useEffect hook uses a double-cast (as unknown as string) to bypass
type safety when passing the color value to SystemUI.setBackgroundColorAsync(),
which allows invalid values like undefined or numeric coercions to reach the
async function and cause unhandled Promise rejections. Remove the type coercion
and add validation to guard the SystemUI.setBackgroundColorAsync call by
checking that the color value returned from useCSSVariable is a valid non-empty
string before invoking the async function.
apps/mobile/src/app/(settings)/profile.tsx-35-69 (1)

35-69: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Wire the profile action buttons to handlers (or disable them until implemented).

Line 35, Line 59, and Line 67 render tappable actions with no onPress, so all primary actions are no-ops on a reachable settings screen.

Suggested fix
 export default function ProfileScreen() {
   const [fullName, setFullName] = useState('Evan Bacon');
   const [nickname, setNickname] = useState('Evan');
   const [preferences, setPreferences] = useState("I'm a creator and software developer.");
+  const handleUpdateProfile = () => {
+    // TODO: wire profile update mutation
+  };
+  const handleSavePreferences = () => {
+    // TODO: wire preferences save mutation
+  };
+  const handleDeleteAccount = () => {
+    // TODO: show confirm dialog + destructive delete flow
+  };

   return (
@@
-      <Pressable className="bg-foreground rounded-xl mt-6 py-3.5 items-center active:opacity-80 border-continuous">
+      <Pressable
+        onPress={handleUpdateProfile}
+        className="bg-foreground rounded-xl mt-6 py-3.5 items-center active:opacity-80 border-continuous"
+      >
@@
-      <Pressable className="bg-muted rounded-xl mt-4 py-3.5 items-center active:opacity-80 border-continuous">
+      <Pressable
+        onPress={handleSavePreferences}
+        className="bg-muted rounded-xl mt-4 py-3.5 items-center active:opacity-80 border-continuous"
+      >
@@
-      <Pressable className="flex-row items-center gap-2 active:opacity-60">
+      <Pressable onPress={handleDeleteAccount} className="flex-row items-center gap-2 active:opacity-60">
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/app/`(settings)/profile.tsx around lines 35 - 69, The three
Pressable buttons in the profile screen (the "Update Profile" button, the "Save
Preferences" button, and the "Delete account" button) are missing onPress event
handlers, making them non-functional. Add onPress handlers to each Pressable
component that reference their corresponding handler functions, or alternatively
disable these buttons by adding the disabled prop and setting their
opacity/styling to indicate a disabled state until the handlers are implemented.
Ensure all interactive elements on this reachable settings screen are either
fully wired to their functionality or clearly marked as disabled.
apps/mobile/src/components/markdown/chat-markdown.tsx-185-188 (1)

185-188: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent fallback link open after custom onPress.

This expression calls Linking.openURL whenever extras.onPress returns void, so links can open twice and wiki links can bypass the toast-only path.

💡 Proposed fix
-					const onPress = () => extras?.onPress?.(node.url) ?? Linking.openURL(node.url);
+					const onPress = () => {
+						if (!node.url) return;
+						if (extras?.onPress) {
+							extras.onPress(node.url);
+							return;
+						}
+						Linking.openURL(node.url);
+					};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/components/markdown/chat-markdown.tsx` around lines 185 -
188, The onPress arrow function in the link handler uses the nullish coalescing
operator which causes Linking.openURL to be called whenever extras.onPress
returns undefined (void), resulting in duplicate link opens and wiki links
bypassing the custom handler. Replace the conditional logic in the onPress
function to check if extras?.onPress exists as a function before calling it, and
only fall back to Linking.openURL if the custom handler is not provided, rather
than relying on the return value of the custom handler to determine whether to
invoke the fallback.
apps/mobile/src/components/chat/prompt-input.web.tsx-16-26 (1)

16-26: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

PromptInputAction children are parsed but never rendered.

actions is built in PromptInput, but the footer in PromptInputBody uses hardcoded controls and ignores those parsed children. That drops caller-provided action buttons.

Proposed patch
-import { Children, type ReactNode, isValidElement } from 'react';
+import { Children, cloneElement, type ReactNode, isValidElement } from 'react';
@@
 export function PromptInput({ children }: { children: ReactNode }) {
@@
 	return (
@@
-			<View className="flex w-full flex-col rounded-2xl border border-border/30 bg-card/70 shadow-composer transition-shadow duration-300 focus-within:shadow-composer-focus">
-				{body}
+			<View className="flex w-full flex-col rounded-2xl border border-border/30 bg-card/70 shadow-composer transition-shadow duration-300 focus-within:shadow-composer-focus">
+				{isValidElement(body) ? cloneElement(body, { actions }) : body}
 			</View>
 		</View>
 	);
 }
@@
-export function PromptInputBody({ children }: { children: ReactNode }) {
+export function PromptInputBody({
+	children,
+	actions = [],
+}: {
+	children: ReactNode;
+	actions?: ReactNode[];
+}) {
@@
-				<View className="flex flex-row items-center gap-1">
-					{/* Attachments button */}
-					<Pressable className="flex h-7 w-7 items-center justify-center rounded-lg border border-border/40 transition-colors hover:bg-accent">
-						<Paperclip size={14} className="text-muted-foreground" />
-					</Pressable>
-					{/* Model selector mock */}
-					<Pressable className="flex h-7 flex-row items-center gap-1.5 rounded-lg px-2 transition-colors hover:bg-accent">
-						<Text className="text-[12px] text-muted-foreground">Opus</Text>
-					</Pressable>
-				</View>
+				<View className="flex flex-row items-center gap-1">
+					{actions.length > 0 ? (
+						actions
+					) : (
+						<>
+							<Pressable className="flex h-7 w-7 items-center justify-center rounded-lg border border-border/40 transition-colors hover:bg-accent">
+								<Paperclip size={14} className="text-muted-foreground" />
+							</Pressable>
+							<Pressable className="flex h-7 flex-row items-center gap-1.5 rounded-lg px-2 transition-colors hover:bg-accent">
+								<Text className="text-[12px] text-muted-foreground">Opus</Text>
+							</Pressable>
+						</>
+					)}
+				</View>

Also applies to: 82-93

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/components/chat/prompt-input.web.tsx` around lines 16 - 26,
The `actions` array is being parsed from children in the `PromptInput` component
but is never passed to or rendered by the footer/action controls section. Locate
where the footer controls are rendered (likely in the `PromptInputBody`
component or around lines 82-93) and modify it to accept the `actions` array as
a prop or through context. Replace the hardcoded action button controls in that
footer rendering section with code that renders the items from the `actions`
array, ensuring that caller-provided `PromptInputAction` children are actually
displayed instead of being discarded.
apps/mobile/src/components/blur-raw.tsx-6-6 (1)

6-6: ⚠️ Potential issue | 🟠 Major

Replace internal import with the public expo-blur API.

expo-blur/build/NativeBlurModule is not a supported public API in Expo SDK 56. Import BlurView directly from the expo-blur package instead: import { BlurView } from 'expo-blur'. Internal paths under build/ are private implementation details and are subject to change without notice.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/components/blur-raw.tsx` at line 6, Replace the import
statement in blur-raw.tsx that currently imports NativeBlurView from the private
internal path 'expo-blur/build/NativeBlurModule' with the public API by
importing BlurView directly from 'expo-blur' instead. Update any references to
NativeBlurView throughout the file to use BlurView to ensure the component works
with the supported public API.
apps/mobile/src/components/chat/prompt-input.tsx-174-186 (1)

174-186: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Replace function-form style on Pressable with className + static style.

This uses the forbidden style={({ pressed }) => ...} pattern in mobile code. Move pressed styling to active: utilities and keep style static.

Proposed patch
 		<Pressable
-			style={({ pressed }) => ({
-				width: 34,
-				height: 34,
-				borderRadius: 17,
-				borderCurve: 'continuous',
-				justifyContent: 'center',
-				alignItems: 'center',
-				opacity: pressed ? 0.7 : 1,
-				margin: 5,
-				backgroundColor: disabled ? undefined : accentColor,
-			})}
-			className={disabled ? 'bg-secondary' : undefined}
+			style={{
+				width: 34,
+				height: 34,
+				borderRadius: 17,
+				borderCurve: 'continuous',
+				justifyContent: 'center',
+				alignItems: 'center',
+				margin: 5,
+				backgroundColor: disabled ? undefined : accentColor,
+			}}
+			className={cn('active:opacity-70', disabled && 'bg-secondary')}
 			onPress={onSend}
 			disabled={disabled}
 		>

As per coding guidelines, “Do NOT use the function form of style on Pressable components… use className with the active: modifier.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/components/chat/prompt-input.tsx` around lines 174 - 186, The
Pressable component in prompt-input.tsx uses the forbidden function-form style
pattern with the pressed parameter. Convert this to use static styling instead
by moving all the current style properties to a static style object, and then
use className with the active: Tailwind modifier to handle the opacity change
when pressed. Specifically, remove the function form style={({ pressed }) =>
...} pattern, make style a regular object with all the static properties, and
add the active:opacity-70 utility class to the className to replace the
conditional opacity styling based on the pressed state.

Source: Coding guidelines

apps/mobile/src/components/markdown/render-rules.tsx-145-147 (1)

145-147: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix the link fallback so URLs are actually opened.

onPress currently returns a fallback function instead of invoking it, so links without extras.onPress won’t open.

Proposed fix
-	const onPress = () => extras?.onPress(node.url) || (() => Linking.openURL(node.url));
+	const onPress = () => {
+		if (extras?.onPress) {
+			extras.onPress(node.url);
+			return;
+		}
+		Linking.openURL(node.url);
+	};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/components/markdown/render-rules.tsx` around lines 145 - 147,
The onPress function in the link render rule is returning a function instead of
invoking it as a fallback. When extras?.onPress does not exist or is falsy, the
current code returns a function definition (() => Linking.openURL(node.url))
rather than calling Linking.openURL(node.url) directly. Fix the onPress function
to ensure that when extras?.onPress is not available, Linking.openURL(node.url)
is actually executed, not returned as a function reference.
apps/mobile/src/components/touchable-glass.tsx-82-86 (1)

82-86: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid double-calling onPressOut in fallback tap success path.

onTouchEndSuccess calls onPressOut, and TouchableWithoutFeedback.onPressOut also calls it via onTouchEnd, so successful taps can invoke onPressOut twice.

Proposed fix
 const onTouchEndSuccess = () => {
   setPressed(false);
   onPress?.();
-  onPressOut?.();
 };

Also applies to: 90-95

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/components/touchable-glass.tsx` around lines 82 - 86, The
onTouchEndSuccess function is calling onPressOut?.() explicitly, but since this
is a fallback handler for onTouchEnd which is already handled by
TouchableWithoutFeedback.onPressOut, the callback gets invoked twice for
successful taps. Remove the explicit onPressOut?.() call from the
onTouchEndSuccess function, allowing the onPressOut callback to be handled only
once through the standard TouchableWithoutFeedback callback chain. Apply the
same fix to any similar functions in the range of lines 90-95 that may have the
same issue.
apps/mobile/src/components/touchable-glass.tsx-49-60 (1)

49-60: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fallback path drops touch-target props used by consumers.

PromptInputAction passes hitSlop into TouchableGlass (apps/mobile/src/components/chat/prompt-input.tsx, Line 63-77), but fallback strips props into Animated.View and never forwards touch props to TouchableWithoutFeedback. On non-liquid-glass devices, this shrinks the actionable hit target.

Proposed fix
 function TouchableGlassFallback({
   onPress,
   onPressIn,
   onPressOut,
   ref,
   disabled,
   children,
   style,
   className,
   ...rest
 }: TouchableGlassProps) {
@@
-  const safeViewProps = viewProps as ViewOnlyProps;
+  const safeViewProps = viewProps as ViewOnlyProps & { hitSlop?: ViewProps['hitSlop'] };
+  const { hitSlop, ...viewOnlyProps } = safeViewProps;
@@
   return (
     <TouchableWithoutFeedback
       className="contents"
       onPress={onTouchEndSuccess}
       onPressIn={onTouchBegin}
       onPressOut={onTouchEnd}
       disabled={disabled}
+      hitSlop={hitSlop}
     >
       <Animated.View
         ref={ref}
         className={className}
-        {...safeViewProps}
+        {...viewOnlyProps}

Also applies to: 90-101

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/components/touchable-glass.tsx` around lines 49 - 60, The
TouchableGlassFallback function accepts touch-target props like hitSlop from
consumers but fails to forward them to the TouchableWithoutFeedback component,
causing the actionable hit target to shrink on non-liquid-glass devices. Extract
touch-target props (such as hitSlop and other touch-related properties) from the
rest parameter and explicitly pass them to the TouchableWithoutFeedback
component to ensure touch targets are properly sized when the fallback path is
used.
apps/mobile/src/components/drawer-content.tsx-255-261 (1)

255-261: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent double navigation when opening Settings on Android.

This handler calls both onNavigate and onOpenModal on Android, causing two route actions for a single press.

Proposed fix
 onPress={() => {
-  if (process.env.EXPO_OS === 'android') {
-    onNavigate('/(settings)/settings');
-  }
-  onOpenModal('/(settings)/settings');
+  if (process.env.EXPO_OS === 'android') {
+    onNavigate('/(settings)/settings');
+    return;
+  }
+  onOpenModal('/(settings)/settings');
 }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/components/drawer-content.tsx` around lines 255 - 261, The
Settings button handler is calling both onNavigate and onOpenModal on Android,
which causes double navigation. Restructure the conditional logic so that only
one of these functions is executed per platform. On Android, call only
onNavigate with '/(settings)/settings', and for other platforms, call only
onOpenModal with the same route. Use an if-else statement to ensure mutually
exclusive execution of these navigation functions.
apps/mobile/src/components/tw.tsx-29-46 (1)

29-46: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fallback glass view drops incoming view props.

FallbackAppleGlassView ignores ...rest, so when liquid glass is unavailable, callers lose non-style props (accessibility/layout/handlers), creating behavior drift versus the primary implementation.

Proposed fix
 const FallbackAppleGlassView = ({
   fallbackTint,
   fallbackIntensity,
   children,
   style,
   className,
   ...rest
 }: FallbackAppleGlassViewProps) => {
+  const {
+    animatedProps,
+    glassEffectStyle,
+    tintColor,
+    isInteractive,
+    colorScheme,
+    ...viewProps
+  } = rest as Record<string, unknown>;
   return (
     <BlurView
       className={className}
       style={[{ overflow: 'hidden' }, StyleSheet.flatten(style) as ViewStyle]}
       tint={fallbackTint}
       intensity={fallbackIntensity}
+      {...(viewProps as Record<string, unknown>)}
     >
       {children as React.ReactNode}
     </BlurView>
   );
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/components/tw.tsx` around lines 29 - 46, The
FallbackAppleGlassView component extracts the rest props in its destructuring
but never passes them to the BlurView component, causing accessibility
attributes, layout props, and event handlers to be dropped. Spread the ...rest
props onto the BlurView component alongside the existing className, style, tint,
and intensity props to ensure all incoming props are properly forwarded and the
fallback implementation maintains consistent behavior with the primary glass
view.
apps/mobile/src/app/attachments.tsx-117-122 (1)

117-122: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Camera/Photos actions are unintentionally disabled on Android.

At Line 117-122, onPress is gated by IS_IOS, so Android users see buttons that do nothing. Since the handlers already use expo-image-picker permission APIs, this should be wired for supported non-iOS targets too (or visually disabled/hidden).

Suggested patch
-				<AttachmentButton icon={Camera} label="Camera" onPress={IS_IOS ? openCamera : undefined} />
+				<AttachmentButton icon={Camera} label="Camera" onPress={openCamera} />
 				<AttachmentButton
 					icon={ImageIcon}
 					label="Photos"
-					onPress={IS_IOS ? openPhotos : undefined}
+					onPress={openPhotos}
 				/>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/app/attachments.tsx` around lines 117 - 122, The
AttachmentButton components for Camera and Photos have their onPress handlers
conditionally gated by IS_IOS, which disables these buttons on Android. Since
the openCamera and openPhotos handlers already support Android via
expo-image-picker permission APIs, remove or modify the IS_IOS conditional
checks on both the Camera button's onPress and the ImageIcon Photos button's
onPress to enable functionality on Android as well. Update the conditional logic
to pass the handlers for all supported platforms, not just iOS.
.github/workflows/ci.yml-35-55 (1)

35-55: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Set explicit least-privilege permissions for the new jobs.

Both new jobs currently rely on default workflow token permissions. Add explicit minimal scopes (for these jobs, typically contents: read) to reduce blast radius.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 35 - 55, Add explicit least-privilege
permissions to both the changes and mobile-checks jobs in the workflow. For each
job, add a permissions section with minimal required scopes (typically contents:
read for jobs that only need to read repository contents). This should be added
at the job level under the job definition, before other configuration like
runs-on or needs. This restricts the default workflow token permissions and
reduces security risk if the workflow or its steps are compromised.

Source: Linters/SAST tools

apps/mobile/shims/config.ts-62-62 (1)

62-62: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Encode the token path segment before constructing the WebSocket URL.

Line 62 inserts config.token raw into the URL path. If token format ever includes reserved URL characters, connection/auth routing can fail. Encode this segment like sessionId is encoded.

Suggested patch
-	let url = `ws://${config.host}:${config.port}/${config.token}/ws`;
+	let url = `ws://${config.host}:${config.port}/${encodeURIComponent(config.token)}/ws`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/shims/config.ts` at line 62, The WebSocket URL construction in
the line assigning the `ws://` URL is inserting the `config.token` directly into
the path without URL encoding. If the token contains reserved URL characters,
this will cause connection failures. Apply URL encoding to the `config.token`
value using the same encoding method (likely encodeURIComponent) that is used
elsewhere in the file for encoding path segments like `sessionId`, and place the
encoded token into the URL path.
.github/workflows/ci.yml-40-42 (1)

40-42: ⚠️ Potential issue | 🟠 Major

Pin Actions by commit SHA and disable checkout credential persistence.

Lines 40-42 and 59-60 use mutable tag refs (@v6, @v3) instead of commit SHAs. Tags can be updated by repository maintainers to point to different (potentially malicious) code. Pin to the full 40-character commit SHA instead (optionally with a version comment for readability).

Additionally, both actions/checkout steps should set persist-credentials: false unless authentication is needed for subsequent steps. Currently, the GITHUB_TOKEN is persisted in .git/config by default, making it accessible to all subsequent steps—a risk that doesn't apply here since neither step requires credential access.

Example fix for lines 40-42:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
        with:
          persist-credentials: false
      - uses: dorny/paths-filter@512585a4c4beff36070b4b2da4cc2c589c235a27  # v3
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 40 - 42, Replace the mutable tag
references for actions/checkout@v6 and dorny/paths-filter@v3 with their full
40-character commit SHA equivalents (you can find these by checking the releases
or commits of each action's repository) and add a version comment for
readability. Additionally, add a with block to both the actions/checkout and
dorny/paths-filter steps to set persist-credentials: false, which prevents the
GITHUB_TOKEN from being persisted in the git config and reduces unnecessary
credential exposure for steps that don't require authentication.

Source: Linters/SAST tools

apps/mobile/.agents/skills/building-native-ui/references/storage.md-39-42 (1)

39-42: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard JSON.parse before returning cached values.

A malformed or migrated entry will throw here and crash every caller of useStorage during render. Fall back to defaultValue when parsing fails.

♻️ Proposed fix
 get<T>(key: string, defaultValue: T): T {
 	const value = localStorage.getItem(key);
-	return value ? JSON.parse(value) : defaultValue;
+	if (!value) return defaultValue;
+	try {
+		return JSON.parse(value);
+	} catch {
+		return defaultValue;
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/.agents/skills/building-native-ui/references/storage.md` around
lines 39 - 42, The `get` method lacks error handling around the `JSON.parse`
call, which will throw and crash the caller if the stored value is malformed or
corrupted. Wrap the `JSON.parse(value)` statement in a try-catch block and
return the `defaultValue` when parsing fails, ensuring graceful fallback
behavior instead of propagating the error up the call stack.
apps/mobile/src/app/pair.tsx-61-68 (1)

61-68: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add timeout/abort handling to the redeem request.

The redeem fetch has no timeout. On stalled networks this can leave pairing stuck in the processing state for a long period and block retry flow.

Proposed fix
 async function redeemPairingCode(payload: PairCodePayload): Promise<RedemptionResult> {
 	const deviceName = getDeviceName();
+	const controller = new AbortController();
+	const timeout = setTimeout(() => controller.abort(), 10_000);

-	const response = await fetch(`http://${payload.host}:${payload.port}/api/mobile-pairing/redeem`, {
-		method: 'POST',
-		headers: { 'Content-Type': 'application/json' },
-		body: JSON.stringify({
-			code: payload.code,
-			deviceName,
-		}),
-	});
+	let response: Response;
+	try {
+		response = await fetch(`http://${payload.host}:${payload.port}/api/mobile-pairing/redeem`, {
+			method: 'POST',
+			headers: { 'Content-Type': 'application/json' },
+			body: JSON.stringify({
+				code: payload.code,
+				deviceName,
+			}),
+			signal: controller.signal,
+		});
+	} finally {
+		clearTimeout(timeout);
+	}

Also applies to: 167-168

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/app/pair.tsx` around lines 61 - 68, The fetch request to the
mobile pairing redeem endpoint lacks timeout handling, which can cause the
pairing flow to hang indefinitely on stalled networks. Add an AbortController to
implement timeout functionality, create a timeout that aborts the request after
a reasonable duration (such as 30 seconds), pass the controller's signal to the
fetch options, and wrap the fetch call in a try-catch block to handle AbortError
exceptions appropriately so users can retry the pairing. Apply this same pattern
to the other fetch call mentioned at lines 167-168.
apps/mobile/src/app/pair.tsx-75-84 (1)

75-84: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate redeem response fields before persisting credentials.

Only token is checked. If deviceId is missing or non-string, invalid credentials are stored and downstream logic receives a broken pairingId.

Proposed fix
 	const result = await response.json();
-	if (!result.token) {
-		throw new Error('No token in response');
+	if (
+		!result ||
+		typeof result !== 'object' ||
+		typeof result.token !== 'string' ||
+		result.token.length === 0 ||
+		typeof result.deviceId !== 'string' ||
+		result.deviceId.length === 0
+	) {
+		throw new Error('Invalid redeem response payload');
 	}

Also applies to: 140-146

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/app/pair.tsx` around lines 75 - 84, The response validation
in the pair.tsx file only checks for the presence of the token field but does
not validate that deviceId exists or is a string. Add validation for the
deviceId field to ensure it is present and has the correct type before returning
the credentials object containing token, deviceId, and deviceName. This
validation should be added immediately after the existing token check and before
the return statement. Apply the same validation pattern to the second occurrence
of this code (around lines 140-146).
apps/mobile/src/lib/credentials.ts-35-40 (1)

35-40: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate parsed credential shape before returning it.

JSON.parse(...) as MaestroCredentials trusts unvalidated storage data. A malformed object can pass through and break downstream URL/socket behavior instead of cleanly returning null.

Proposed fix
 export interface MaestroCredentials {
 	host: string;
 	port: number;
 	token: string;
 	pairingId: string;
 	deviceName: string;
 }
+
+function isValidCredentials(value: unknown): value is MaestroCredentials {
+	if (!value || typeof value !== 'object') return false;
+	const v = value as Record<string, unknown>;
+	return (
+		typeof v.host === 'string' &&
+		v.host.length > 0 &&
+		typeof v.port === 'number' &&
+		Number.isInteger(v.port) &&
+		v.port >= 1 &&
+		v.port <= 65535 &&
+		typeof v.token === 'string' &&
+		v.token.length > 0 &&
+		typeof v.pairingId === 'string' &&
+		v.pairingId.length > 0 &&
+		typeof v.deviceName === 'string' &&
+		v.deviceName.length > 0
+	);
+}

 export async function getCredentials(): Promise<MaestroCredentials | null> {
 	try {
 		const stored = await SecureStore.getItemAsync(CREDENTIALS_KEY);
 		if (!stored) return null;
-		return JSON.parse(stored) as MaestroCredentials;
+		const parsed: unknown = JSON.parse(stored);
+		return isValidCredentials(parsed) ? parsed : null;
 	} catch {
 		return null;
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/lib/credentials.ts` around lines 35 - 40, The getCredentials
function parses stored JSON data and casts it to MaestroCredentials using `as`,
but this provides no runtime validation of the actual data structure. If the
stored data is malformed or missing required fields, it will still be returned
and could break downstream code. Add validation logic after the JSON.parse call
to verify the parsed object matches the MaestroCredentials shape (check for
required properties/fields), and return null if validation fails instead of
returning potentially invalid data.
apps/mobile/src/hooks/useMaestroConnection.ts-74-84 (1)

74-84: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear reconnect state on terminal disconnects.

isReconnecting is only reset on authenticated, so a failed reconnect that lands back in disconnected can leave the status pill stuck on “Reconnecting...” indefinitely. Reset the flag on the terminal disconnected path too.

🔧 Suggested fix
 	onConnectionChange: (state: WebSocketState) => {
 		if (state === 'authenticated') {
 			setIsReconnecting(false);
 			// Check for stale buffer on reconnect
 			if (hasStaleBufferRef.current) {
 				hasStaleBufferRef.current = false;
 				onStaleBufferDiscarded?.();
 			}
+		} else if (state === 'disconnected') {
+			setIsReconnecting(false);
 		}
 		handlers?.onConnectionChange?.(state);
 	},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/hooks/useMaestroConnection.ts` around lines 74 - 84, The
isReconnecting state is only being reset to false when the connection state
becomes 'authenticated', but if a reconnection fails and returns to
'disconnected', the flag remains true causing the UI to display a stuck
"Reconnecting..." status. In the onConnectionChange handler within
useMaestroConnection, add an additional check for when state equals
'disconnected' (or other terminal disconnected states) and reset isReconnecting
to false in that branch as well, similar to how it's reset for the
'authenticated' state.
apps/mobile/metro.config.js-51-53 (1)

51-53: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize resolver origin paths before mobile/web-hooks checks.

Lines 51-53 assume / separators; Windows paths use \, so shim routing may silently not apply.

Suggested fix
-	const origin = context.originModulePath || '';
-	const inMobileOrWebHooks = origin.includes('apps/mobile/') || origin.includes('src/web/hooks/');
+	const origin = (context.originModulePath || '').replace(/\\/g, '/');
+	const inMobileOrWebHooks =
+		origin.includes('/apps/mobile/') || origin.includes('/src/web/hooks/');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/metro.config.js` around lines 51 - 53, The path checks on
`context.originModulePath` assume forward slash separators, which causes the
mobile and web-hooks routing to silently fail on Windows systems that use
backslashes. Normalize the `origin` variable by converting all backslashes to
forward slashes before performing the `origin.includes()` checks for
'apps/mobile/' and 'src/web/hooks/' in the `inMobileOrWebHooks` assignment,
ensuring cross-platform compatibility.
apps/mobile/src/lib/useMaestroWebSocket.ts-350-352 (1)

350-352: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard buildWebSocketUrl() failures inside the existing connection try/catch.

Line 351 awaits buildWebSocketUrl() before the try block; if it rejects, the error bypasses your connection error handling and escapes as an unhandled async rejection.

Suggested fix
-		// 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 {
+		try {
+			// Build URL without a specific sessionId - session is selected after connection
+			const url = await buildWebSocketUrl();
+			if (!url) {
+				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;
+			}
 			const ws = new WebSocket(url);
 			wsRef.current = ws;

Also applies to: 362-395

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/lib/useMaestroWebSocket.ts` around lines 350 - 352, The
buildWebSocketUrl() call on line 351 is located before the try block, which
means if it rejects, the error will not be caught by the existing connection
error handling and will escape as an unhandled async rejection. Move the
buildWebSocketUrl() invocation inside the try block along with the other
connection logic (the WebSocket constructor and related setup code) so that any
failures from building the URL are properly handled by the existing error
handling mechanism.
apps/mobile/src/hooks/useSessionChat.ts-153-153 (1)

153-153: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Replace inline random message IDs with shared ID utility calls.

These lines create new local ID-generation logic; use the repo-standard generator to keep behavior consistent and avoid duplicate implementations.

Suggested fix
 import * as Haptics from 'expo-haptics';
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { generateUUID } from '`@maestro/shared/uuid`';
@@
-				const messageId = `assistant-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+				const messageId = `assistant-${generateUUID()}`;
@@
-			const messageId = `user-desktop-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+			const messageId = `user-desktop-${generateUUID()}`;
@@
-		const userMessageId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+		const userMessageId = `user-${generateUUID()}`;

As per coding guidelines, "Use generateId() from src/renderer/utils/ids.ts or generateUUID() from src/shared/uuid.ts for ID generation. Do not create duplicate implementations."

Also applies to: 228-228, 304-304

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/hooks/useSessionChat.ts` at line 153, The messageId
assignment on lines 153, 228, and 304 in the useSessionChat hook uses inline
random ID generation with Date.now() and Math.random(). Replace this inline
implementation with a call to either generateId() from src/renderer/utils/ids.ts
or generateUUID() from src/shared/uuid.ts to maintain consistency with the
codebase standard. Update all three occurrences to use the same shared utility
function instead of creating duplicate ID-generation logic.

Source: Coding guidelines

apps/mobile/src/global.css-11-11 (1)

11-11: ⚠️ Potential issue | 🟠 Major

Add ignoreAtRules configuration to stylelint for Tailwind/Uniwind directives.

The stylelint config at .stylelintrc.json is missing the allowlist for @variant (lines 11, 28) and @theme (line 46) directives. Add this rule to suppress unknown at-rule warnings:

Required configuration update
"at-rule-no-unknown": [true, { "ignoreAtRules": ["theme", "variant"] }]

Add this to the rules object in .stylelintrc.json.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/global.css` at line 11, The stylelint configuration is
missing the allowlist for custom at-rules used by Tailwind/Uniwind. In the
`.stylelintrc.json` file, add the `at-rule-no-unknown` rule to the rules object
with the `ignoreAtRules` option set to allow both "theme" and "variant"
directives. This will suppress the unknown at-rule warnings that are currently
being triggered by the `@variant` and `@theme` directives in the CSS files.

Source: Linters/SAST tools

apps/mobile/src/lib/useMaestroWebSocket.ts-451-451 (1)

451-451: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use shared ID utilities for request IDs instead of inline random construction.

Line 451 builds a new ad-hoc ID implementation; this should use the shared ID generator utility.

Suggested fix
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { buildWebSocketUrl } from '../../shims/config';
 import type { Theme } from '`@maestro/shared/theme-types`';
+import { generateUUID } from '`@maestro/shared/uuid`';
@@
-				const requestId = `history-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
+				const requestId = `history-${generateUUID()}`;

As per coding guidelines, "Use generateId() from src/renderer/utils/ids.ts or generateUUID() from src/shared/uuid.ts for ID generation. Do not create duplicate implementations."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/lib/useMaestroWebSocket.ts` at line 451, Replace the inline
ad-hoc request ID construction on line 451 in the useMaestroWebSocket hook with
a call to the shared ID generator utility. Remove the manual implementation
using Date.now() and Math.random(), and instead import and use either
generateId() from src/renderer/utils/ids.ts or generateUUID() from
src/shared/uuid.ts. Update the requestId assignment to use the imported utility
function, maintaining the history prefix if needed for context identification.

Source: Coding guidelines

apps/mobile/src/streaming/reconcileStreamingMessage.ts-95-96 (1)

95-96: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use shared ID utilities for new message IDs.

Assistant/user IDs are generated manually from timestamps. Please switch to generateId()/generateUUID() to follow repository standards and avoid ad-hoc ID generation drift.

Suggested change
+import { generateUUID } from '`@maestro/shared/uuid`';
...
-				const messageId = `assistant-${action.timestamp}`;
+				const messageId = `assistant-${generateUUID()}`;
...
-			const messageId = `user-${action.timestamp}`;
+			const messageId = `user-${generateUUID()}`;

As per coding guidelines, use generateId() from src/renderer/utils/ids.ts or generateUUID() from src/shared/uuid.ts for ID generation and avoid duplicate implementations.

Also applies to: 185-186

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/streaming/reconcileStreamingMessage.ts` around lines 95 - 96,
The message ID generation at the location containing
`assistant-${action.timestamp}` is using manual timestamp concatenation instead
of the repository's standard ID utilities. Replace this manual ID generation
with a call to `generateId()` from `src/renderer/utils/ids.ts` or
`generateUUID()` from `src/shared/uuid.ts`, and add the appropriate import
statement. Apply the same fix to similar manual ID generation patterns elsewhere
in the file that also use timestamp-based concatenation.

Source: Coding guidelines

apps/mobile/src/streaming/reconcileStreamingMessage.ts-148-171 (1)

148-171: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

SESSION_STATE_CHANGE can leave a stale in-flight placeholder.

At Line 150, commit requires a truthy streamingBuffer. If the first AI chunk is '', Line 168-171 runs and only flips isGenerating, leaving streamingMessageId and the empty placeholder message behind.

Proposed fix
 		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,
-					};
-				}
+				if (state.streamingMessageId) {
+					const msgId = state.streamingMessageId;
+					const updated =
+						state.streamingBuffer.length > 0
+							? state.messages.map((m) =>
+									m.id === msgId ? { ...m, content: state.streamingBuffer } : m
+							  )
+							: state.messages.filter((m) => m.id !== msgId);
+
+					return {
+						...state,
+						messages: updated,
+						streamingBuffer: '',
+						streamingMessageId: null,
+						isGenerating: false,
+					};
+				}
 
 				// No buffer to commit, just stop generating
 				return {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/streaming/reconcileStreamingMessage.ts` around lines 148 -
171, When SESSION_STATE_CHANGE action has state 'idle' or 'ready', the code only
commits the streaming buffer if it is truthy. If the first AI chunk is an empty
string, the condition fails and falls through to the "No buffer to commit, just
stop generating" return statement which only sets isGenerating to false. This
leaves streamingMessageId and the placeholder message as stale data. In the
fallback return statement that handles the case where there is no buffer to
commit, also reset streamingMessageId to null and streamingBuffer to an empty
string to properly clean up the stale placeholder message, matching the cleanup
done in the successful commit case above.
🧹 Nitpick comments (5)
apps/mobile/src/app/chats.tsx (1)

13-17: ⚡ Quick win

Use the shared formatter instead of a local time-ago helper.

formatTimeAgo duplicates shared formatting logic; please switch this to the shared formatter utility to keep behavior consistent across clients.

As per coding guidelines, **/*.{ts,tsx,js,jsx} should use formatSize(), formatNumber(), formatTokens(), formatTokensCompact(), estimateTokenCount(), formatElapsedTime(), formatElapsedTimeColon(), formatRelativeTime(), or formatCost() from src/shared/formatters.ts for formatting operations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/app/chats.tsx` around lines 13 - 17, The local formatTimeAgo
function in chats.tsx duplicates shared formatting logic that already exists in
src/shared/formatters.ts. Remove the formatTimeAgo function definition and
replace its usage with the appropriate shared formatter utility. Check
src/shared/formatters.ts for the correct formatter to use (likely
formatRelativeTime, formatElapsedTime, or similar) that provides the time-ago
formatting behavior, then update the call site to use the shared formatter
instead.

Source: Coding guidelines

package.json (1)

27-27: ⚡ Quick win

Avoid hardcoding a specific iOS simulator in the root dev script.

Line 27 ties local workflows to one simulator name, which commonly fails on machines that don’t have that exact runtime/device. Prefer default simulator selection or an env-driven device override.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 27, The dev:mobile:ios script in package.json hardcodes
the device selection to "iPhone 16 Pro", which causes failures on machines
without that specific simulator. Remove the --device 'iPhone 16 Pro' parameter
from the expo run:ios command to allow Expo to select a default simulator, or
alternatively make the device name configurable through an environment variable
that users can override locally without modifying the script itself.
apps/mobile/src/pairing/__tests__/parseQrPayload.test.ts (1)

214-222: ⚡ Quick win

Add a regression case for maestro://pairing?... payloads.

Current invalid-scheme tests don’t cover prefix-collision inputs like maestro://pairing?..., which should be rejected. Adding that case will lock in the intended strict format.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/pairing/__tests__/parseQrPayload.test.ts` around lines 214 -
222, Add a regression test case in the "invalid URL schemes" describe block to
verify that parseQrPayload correctly rejects payloads using maestro://pairing
instead of the valid maestro://pair format. Insert a new test case after the
existing "returns null for maestro:// without pair path" test that calls
parseQrPayload with maestro://pairing?host=localhost&port=8080&code=X and
asserts it returns null. This ensures the strict format validation for the pair
path segment is locked in against future regressions.
apps/mobile/src/streaming/__tests__/streamingReconciliation.test.ts (1)

31-103: ⚡ Quick win

Add a regression for empty first SESSION_OUTPUT chunks.

There’s no coverage for data: '' followed by SESSION_STATE_CHANGE to idle/ready. That edge case should assert the placeholder is cleared and streaming flags are reset.

Suggested test case
+it('clears placeholder when first AI chunk is empty and session becomes idle', () => {
+  let state = createInitialState();
+  state = streamingReducer(state, {
+    type: 'SESSION_OUTPUT',
+    data: '',
+    source: 'ai',
+    timestamp: 1234,
+  });
+
+  state = streamingReducer(state, {
+    type: 'SESSION_STATE_CHANGE',
+    state: 'idle',
+  });
+
+  expect(state.streamingMessageId).toBeNull();
+  expect(state.streamingBuffer).toBe('');
+  expect(state.isGenerating).toBe(false);
+});

Also applies to: 202-280

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/streaming/__tests__/streamingReconciliation.test.ts` around
lines 31 - 103, Add a new test case within the SESSION_OUTPUT events describe
block (or create a new describe block for SESSION_STATE_CHANGE events) that
covers the edge case where a SESSION_OUTPUT action with empty data (data: '') is
followed by a SESSION_STATE_CHANGE action with state transitioning to idle or
ready. The test should verify that after these actions, the placeholder message
in the messages array is properly cleared (content should not remain), the
streamingBuffer is empty, the streamingMessageId is null, and isGenerating is
false.
apps/mobile/src/__tests__/offlineQueueReplay.test.ts (1)

44-357: 🏗️ Heavy lift

These tests are disconnected from the production queue implementation.

This suite mostly validates local arrays/state transitions, so regressions in the real queue hook can still pass. Please anchor key cases to useMaestroOfflineQueue/shared queue logic via public API-level tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/__tests__/offlineQueueReplay.test.ts` around lines 44 - 357,
The test suite is testing isolated arrays and mock state transitions rather than
the actual queue implementation from useMaestroOfflineQueue, which means
production regressions could pass these tests. Refactor the test cases to use
the real useMaestroOfflineQueue hook and its public API instead of directly
manipulating arrays. For key test cases like those in queue ordering, storage
persistence, queue capacity, retry handling, and command removal sections,
replace the direct array manipulation with actual calls to the queue hook's
methods and verify the behavior through the hook's public interface. This
ensures the tests catch real regressions in the actual queue logic rather than
just validating basic array operations.

Ashraf-Ali-aa and others added 10 commits June 21, 2026 10:01
Implement core pairing infrastructure with short-lived codes (6-char base32,
5-minute expiry) and long-lived hashed tokens (90-day TTL) for mobile device
authentication. Includes device CRUD operations and periodic code cleanup.
Expose mobile pairing operations to renderer via window.maestro.mobilePairing
namespace. Handlers integrate with WebServer to include host/port in generated
pairing codes for QR display.
Extend WebSocket route to authenticate both browser security tokens and
mobile device tokens. Add public pairing code redemption endpoint with
rate limiting. Track mobile client metadata for connection management.
Implement MobileDevicesSection component with QR code generation for device
pairing, real-time countdown, paired device list, and revocation. Integrated
into General settings tab with full search support.
Replace direct localStorage calls with injected StorageAdapter interface.
Enables the same hook to work with AsyncStorage in React Native. Add async
initialization and factory function for web localStorage adapter.
Update WebSocket route tests for wildcard routing pattern. Add comprehensive
tests for mobile token validation, auth rejection, and client ID prefixes.
Add conditional mobile-checks CI job that runs TypeScript, ESLint, Jest, and
expo-doctor only when mobile or shared code changes. Add development scripts
for running the Expo app. Exclude apps/ from root ESLint config.
Initialize apps/mobile with Expo managed workflow. Configure Metro for
monorepo symlinks to shared code. Include Jest testing infrastructure
and standalone ESLint config.
The pre-push validate hook ran prettier over generated CocoaPods, .expo,
and native build output under apps/mobile, failing the format check on
files that are not meant to be hand-formatted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (1)
src/__tests__/main/mobile-pairing.test.ts (1)

113-121: ⚡ Quick win

Harden timer cleanup to prevent cross-test clock leakage

If an assertion throws before vi.useRealTimers(), later tests can run under fake time and fail non-deterministically. Add a global cleanup hook (afterEach) to always restore timers.

Suggested patch
 import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest';
@@
 afterAll(() => {
 	rmSync(tmpRoot, { recursive: true, force: true });
 });
+
+afterEach(() => {
+	vi.useRealTimers();
+	vi.clearAllTimers();
+});

Also applies to: 180-190, 244-253

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__tests__/main/mobile-pairing.test.ts` around lines 113 - 121, The test
`rejects expired codes` uses vi.useFakeTimers() and vi.useRealTimers() manually,
but if an assertion throws before vi.useRealTimers() is called, the fake timers
remain active and will cause subsequent tests to run under fake time and fail
non-deterministically. Add a global afterEach cleanup hook at the appropriate
scope (test file or describe block) that unconditionally calls
vi.useRealTimers() to guarantee timer restoration regardless of assertion
outcomes. This same pattern should be applied to the other affected tests at
lines 180-190 and 244-253.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Line 40: The checkout steps at lines 40 and 59 are leaving write-capable
tokens in the git config which is a security risk. Add the parameter
persist-credentials: false to both instances of actions/checkout@v6 to disable
credential persistence and prevent the token from remaining in git config after
the job execution completes.
- Around line 40-41: Replace the movable version tags with immutable commit SHAs
for all GitHub Actions to strengthen supply-chain integrity. For the
actions/checkout@v6 on line 40 and dorny/paths-filter@v3 on line 41, change the
`@v*` tag to the full commit SHA of the respective action version. The same
change applies to any other actions referenced on lines 59-60. Update each uses
statement to reference the specific commit hash instead of the version tag
format.
- Around line 35-54: Add explicit least-privilege permissions to the workflow to
restrict token access to only what is necessary. At the workflow level (after
the `on:` configuration), add a `permissions` section that restricts access to
read-only operations. For the `changes` job which uses `actions/checkout@v6` and
`dorny/paths-filter@v3`, and the `mobile-checks` job which only needs to read
outputs from the changes job, explicitly set `permissions: contents: read` to
limit token scope to content read access only, removing reliance on default
broader permissions.

In `@apps/mobile/.agents/skills/building-native-ui/references/route-structure.md`:
- Line 18: Add language identifiers to all fenced code blocks in the
route-structure.md file that are missing them. Specifically, modify the opening
fenced code block markers at lines 18, 30, 69, 95, 112, and 155 by adding a
language identifier (such as `text`) immediately after the opening triple
backticks. For example, change the opening marker from ``` to ```text to satisfy
the markdownlint MD040 rule.

In `@apps/mobile/.agents/skills/building-native-ui/references/tabs.md`:
- Line 305: The fenced code block in the route-structure section is missing a
language identifier, which violates the MD040 markdown linting rule. Locate the
opening triple backtick (```) that corresponds to the closing triple backtick on
line 305 and add an appropriate language identifier immediately after the
opening backticks (such as "route-structure" or another relevant language label)
to specify the language of the code block content.

In `@apps/mobile/.agents/skills/building-native-ui/SKILL.md`:
- Around line 34-40: Update the Expo Go guidance in lines 34-40 of the SKILL.md
file to reflect that this app requires a custom Expo development build and will
not work in Expo Go. Replace the current instructions that recommend trying Expo
Go first with a clear statement that custom builds are mandatory for this app,
and then provide the correct setup steps that skip Expo Go and go directly to
custom build commands like npx expo run:ios or npx expo run:android.
- Line 14: The fenced code blocks at line 14 and line 249 in the SKILL.md file
are missing language identifiers, which violates markdown lint rule MD040. Add
the appropriate language identifier (such as javascript, typescript, json, bash,
etc.) to each opening fence marker (the triple backticks) to specify what
language the code block contains. For example, change ``` to ```javascript or
```typescript depending on the content of each code block.

In `@apps/mobile/src/components/chat/prompt-input.web.tsx`:
- Around line 16-26: The actions array is being populated with PromptInputAction
children in the PromptInput component but these actions are never rendered in
the component's return statement, making them have no effect. After collecting
the actions and body by filtering children using Children.forEach, ensure the
actions array is included in the component's JSX output alongside the body.
Render the actions array elements in an appropriate location within the
component's return statement to make PromptInputAction children functional.
- Around line 110-127: The onKeyPress handler in the TextInput component calls
onSend() directly without validating input state, unlike the button submit
logic. Add validation checks before calling onSend() in the keyboard handler to
ensure the input is not blank and generation is not in progress, mirroring the
checks that exist in the button's submit handler.

---

Nitpick comments:
In `@src/__tests__/main/mobile-pairing.test.ts`:
- Around line 113-121: The test `rejects expired codes` uses vi.useFakeTimers()
and vi.useRealTimers() manually, but if an assertion throws before
vi.useRealTimers() is called, the fake timers remain active and will cause
subsequent tests to run under fake time and fail non-deterministically. Add a
global afterEach cleanup hook at the appropriate scope (test file or describe
block) that unconditionally calls vi.useRealTimers() to guarantee timer
restoration regardless of assertion outcomes. This same pattern should be
applied to the other affected tests at lines 180-190 and 244-253.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1cf5fa57-54b2-47a5-9117-6ae429305559

📥 Commits

Reviewing files that changed from the base of the PR and between 01a2f32 and b71347d.

⛔ Files ignored due to path filters (4)
  • apps/mobile/assets/images/splash-icon.png is excluded by !**/*.png
  • apps/mobile/bun.lock is excluded by !**/*.lock
  • apps/mobile/package-lock.json is excluded by !**/package-lock.json
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (147)
  • .github/workflows/ci.yml
  • .prettierignore
  • apps/mobile/.agents/skills/building-native-ui/SKILL.md
  • apps/mobile/.agents/skills/building-native-ui/references/animations.md
  • apps/mobile/.agents/skills/building-native-ui/references/controls.md
  • apps/mobile/.agents/skills/building-native-ui/references/form-sheet.md
  • apps/mobile/.agents/skills/building-native-ui/references/gradients.md
  • apps/mobile/.agents/skills/building-native-ui/references/icons.md
  • apps/mobile/.agents/skills/building-native-ui/references/media.md
  • apps/mobile/.agents/skills/building-native-ui/references/route-structure.md
  • apps/mobile/.agents/skills/building-native-ui/references/search.md
  • apps/mobile/.agents/skills/building-native-ui/references/storage.md
  • apps/mobile/.agents/skills/building-native-ui/references/tabs.md
  • apps/mobile/.agents/skills/building-native-ui/references/toolbar-and-headers.md
  • apps/mobile/.agents/skills/building-native-ui/references/visual-effects.md
  • apps/mobile/.agents/skills/building-native-ui/references/webgpu-three.md
  • apps/mobile/.agents/skills/building-native-ui/references/zoom-transitions.md
  • apps/mobile/.agents/skills/uniwind/SKILL.md
  • apps/mobile/.claude/launch.json
  • apps/mobile/.claude/settings.json
  • apps/mobile/.claude/skills/building-native-ui
  • apps/mobile/.claude/skills/uniwind
  • apps/mobile/.eas/workflows/deploy.yml
  • apps/mobile/.eas/workflows/preview-web.yml
  • apps/mobile/.env.example
  • apps/mobile/.gitattributes
  • apps/mobile/.gitignore
  • apps/mobile/AGENTS.md
  • apps/mobile/CLAUDE.md
  • apps/mobile/README.md
  • apps/mobile/app.json
  • apps/mobile/eslint.config.js
  • apps/mobile/jest.config.js
  • apps/mobile/jest.setup.ts
  • apps/mobile/metro.config.js
  • apps/mobile/package.json
  • apps/mobile/scripts/setup-symlinks.js
  • apps/mobile/shims/config.ts
  • apps/mobile/shims/logger.ts
  • apps/mobile/skills-lock.json
  • apps/mobile/src/__tests__/offlineQueueReplay.test.ts
  • apps/mobile/src/__tests__/sessionAddedRouting.test.tsx
  • apps/mobile/src/__tests__/smoke.test.ts
  • apps/mobile/src/app/(settings)/_layout.tsx
  • apps/mobile/src/app/(settings)/capabilities.tsx
  • apps/mobile/src/app/(settings)/profile.tsx
  • apps/mobile/src/app/(settings)/settings.tsx
  • apps/mobile/src/app/__tests__/chatScreensUseRealAgent.test.ts
  • apps/mobile/src/app/_layout.tsx
  • apps/mobile/src/app/_layout.web.tsx
  • apps/mobile/src/app/attachments.tsx
  • apps/mobile/src/app/chats.tsx
  • apps/mobile/src/app/index.tsx
  • apps/mobile/src/app/model-picker.tsx
  • apps/mobile/src/app/pair.tsx
  • apps/mobile/src/app/session/[sessionId].tsx
  • apps/mobile/src/components/AITabStrip.tsx
  • apps/mobile/src/components/ConnectionStatusPill.tsx
  • apps/mobile/src/components/blur-raw.tsx
  • apps/mobile/src/components/blur-raw.web.tsx
  • apps/mobile/src/components/chat/__tests__/streaming-store.test.ts
  • apps/mobile/src/components/chat/chat-context.tsx
  • apps/mobile/src/components/chat/conversation.tsx
  • apps/mobile/src/components/chat/conversation.web.tsx
  • apps/mobile/src/components/chat/index.ts
  • apps/mobile/src/components/chat/message.tsx
  • apps/mobile/src/components/chat/message.web.tsx
  • apps/mobile/src/components/chat/prompt-input.tsx
  • apps/mobile/src/components/chat/prompt-input.web.tsx
  • apps/mobile/src/components/chat/streaming-message.tsx
  • apps/mobile/src/components/chat/streaming-store.ts
  • apps/mobile/src/components/chat/types.ts
  • apps/mobile/src/components/drawer-content.tsx
  • apps/mobile/src/components/drawer-layout.tsx
  • apps/mobile/src/components/grabber.android.tsx
  • apps/mobile/src/components/grabber.tsx
  • apps/mobile/src/components/icon.tsx
  • apps/mobile/src/components/main-header.android.tsx
  • apps/mobile/src/components/main-header.fallback.tsx
  • apps/mobile/src/components/main-header.ios.tsx
  • apps/mobile/src/components/main-header.swiftui.tsx
  • apps/mobile/src/components/main-header.tsx
  • apps/mobile/src/components/markdown/ast-renderer.ts
  • apps/mobile/src/components/markdown/chat-markdown.tsx
  • apps/mobile/src/components/markdown/code-block.tsx
  • apps/mobile/src/components/markdown/index.ts
  • apps/mobile/src/components/markdown/markdown.tsx
  • apps/mobile/src/components/markdown/render-rules.tsx
  • apps/mobile/src/components/markdown/types.ts
  • apps/mobile/src/components/markdown/utils.ts
  • apps/mobile/src/components/model-context.tsx
  • apps/mobile/src/components/sidebar.tsx
  • apps/mobile/src/components/sidebar.web.tsx
  • apps/mobile/src/components/symbol-image.tsx
  • apps/mobile/src/components/touchable-glass.tsx
  • apps/mobile/src/components/tw.tsx
  • apps/mobile/src/global.css
  • apps/mobile/src/hooks/__tests__/useMaestroConnection.test.ts
  • apps/mobile/src/hooks/__tests__/useSessionChat.test.ts
  • apps/mobile/src/hooks/useMaestroConnection.ts
  • apps/mobile/src/hooks/useMaestroOfflineQueue.ts
  • apps/mobile/src/hooks/usePairingCheck.ts
  • apps/mobile/src/hooks/useSessionChat.ts
  • apps/mobile/src/lib/SessionsContext.tsx
  • apps/mobile/src/lib/ToastContext.tsx
  • apps/mobile/src/lib/__tests__/messageRouting.test.ts
  • apps/mobile/src/lib/credentials.ts
  • apps/mobile/src/lib/useMaestroWebSocket.ts
  • apps/mobile/src/pairing/__tests__/parseQrPayload.test.ts
  • apps/mobile/src/pairing/parseQrPayload.ts
  • apps/mobile/src/sf.css
  • apps/mobile/src/storage/__tests__/asyncStorageAdapter.test.ts
  • apps/mobile/src/storage/asyncStorageAdapter.ts
  • apps/mobile/src/streaming/__tests__/streamingReconciliation.test.ts
  • apps/mobile/src/streaming/index.ts
  • apps/mobile/src/streaming/reconcileStreamingMessage.ts
  • apps/mobile/src/theme/AccentContext.tsx
  • apps/mobile/src/utils/mock-chats.ts
  • apps/mobile/src/utils/tailwind.ts
  • apps/mobile/src/utils/use-system-background-color.ts
  • apps/mobile/tsconfig.json
  • apps/mobile/uniwind-types.d.ts
  • eslint.config.mjs
  • package.json
  • src/__tests__/main/mobile-pairing.test.ts
  • src/__tests__/main/web-server/routes/wsRoute.test.ts
  • src/__tests__/web/hooks/useOfflineQueue.test.ts
  • src/__tests__/web/mobile/App.test.tsx
  • src/main/ipc/handlers/index.ts
  • src/main/ipc/handlers/mobile-pairing.ts
  • src/main/mobile-pairing/index.ts
  • src/main/preload/index.ts
  • src/main/preload/mobilePairing.ts
  • src/main/web-server/WebServer.ts
  • src/main/web-server/handlers/messageHandlers.ts
  • src/main/web-server/routes/index.ts
  • src/main/web-server/routes/mobilePairingRoutes.ts
  • src/main/web-server/routes/wsRoute.ts
  • src/main/web-server/types.ts
  • src/renderer/components/Settings/MobileDevicesSection.tsx
  • src/renderer/components/Settings/searchableSettings.ts
  • src/renderer/components/Settings/tabs/GeneralTab.tsx
  • src/renderer/constants/modalPriorities.ts
  • src/renderer/global.d.ts
  • src/web/hooks/__tests__/useOfflineQueue.test.ts
  • src/web/hooks/useOfflineQueue.ts
  • src/web/mobile/App.tsx
✅ Files skipped from review due to trivial changes (23)
  • apps/mobile/skills-lock.json
  • apps/mobile/src/components/main-header.android.tsx
  • apps/mobile/.claude/skills/uniwind
  • .prettierignore
  • apps/mobile/.claude/settings.json
  • apps/mobile/CLAUDE.md
  • apps/mobile/app.json
  • apps/mobile/.claude/launch.json
  • apps/mobile/src/components/chat/index.ts
  • apps/mobile/.gitignore
  • apps/mobile/.env.example
  • apps/mobile/uniwind-types.d.ts
  • apps/mobile/.claude/skills/building-native-ui
  • apps/mobile/src/components/main-header.ios.tsx
  • apps/mobile/.agents/skills/building-native-ui/references/zoom-transitions.md
  • apps/mobile/src/utils/tailwind.ts
  • apps/mobile/.agents/skills/building-native-ui/references/form-sheet.md
  • apps/mobile/README.md
  • apps/mobile/.agents/skills/building-native-ui/references/storage.md
  • apps/mobile/.agents/skills/building-native-ui/references/icons.md
  • apps/mobile/.agents/skills/building-native-ui/references/gradients.md
  • eslint.config.mjs
  • apps/mobile/.agents/skills/building-native-ui/references/search.md
🚧 Files skipped from review as they are similar to previous changes (89)
  • apps/mobile/src/app/tests/chatScreensUseRealAgent.test.ts
  • apps/mobile/jest.setup.ts
  • apps/mobile/src/components/blur-raw.tsx
  • apps/mobile/jest.config.js
  • package.json
  • apps/mobile/src/components/sidebar.tsx
  • apps/mobile/src/components/blur-raw.web.tsx
  • apps/mobile/src/storage/asyncStorageAdapter.ts
  • apps/mobile/src/components/main-header.tsx
  • apps/mobile/src/utils/mock-chats.ts
  • apps/mobile/scripts/setup-symlinks.js
  • apps/mobile/src/components/chat/types.ts
  • apps/mobile/src/components/chat/tests/streaming-store.test.ts
  • apps/mobile/src/components/grabber.tsx
  • apps/mobile/src/app/_layout.web.tsx
  • apps/mobile/src/streaming/index.ts
  • apps/mobile/src/components/model-context.tsx
  • apps/mobile/src/utils/use-system-background-color.ts
  • apps/mobile/src/components/markdown/index.ts
  • apps/mobile/src/storage/tests/asyncStorageAdapter.test.ts
  • apps/mobile/src/components/grabber.android.tsx
  • apps/mobile/tsconfig.json
  • apps/mobile/.gitattributes
  • apps/mobile/src/app/(settings)/profile.tsx
  • apps/mobile/src/tests/smoke.test.ts
  • apps/mobile/.agents/skills/building-native-ui/references/animations.md
  • apps/mobile/shims/logger.ts
  • apps/mobile/shims/config.ts
  • apps/mobile/src/components/chat/streaming-store.ts
  • apps/mobile/src/components/chat/streaming-message.tsx
  • apps/mobile/src/app/(settings)/capabilities.tsx
  • apps/mobile/src/app/attachments.tsx
  • apps/mobile/src/components/chat/chat-context.tsx
  • apps/mobile/src/lib/tests/messageRouting.test.ts
  • apps/mobile/src/hooks/useMaestroOfflineQueue.ts
  • apps/mobile/.eas/workflows/preview-web.yml
  • apps/mobile/src/components/chat/conversation.web.tsx
  • apps/mobile/src/components/chat/message.web.tsx
  • apps/mobile/src/app/(settings)/settings.tsx
  • apps/mobile/src/hooks/usePairingCheck.ts
  • apps/mobile/src/components/drawer-layout.tsx
  • apps/mobile/src/components/markdown/code-block.tsx
  • apps/mobile/src/hooks/tests/useMaestroConnection.test.ts
  • apps/mobile/src/components/main-header.swiftui.tsx
  • apps/mobile/.eas/workflows/deploy.yml
  • apps/mobile/src/components/main-header.fallback.tsx
  • apps/mobile/src/components/ConnectionStatusPill.tsx
  • apps/mobile/src/hooks/tests/useSessionChat.test.ts
  • apps/mobile/.agents/skills/building-native-ui/references/visual-effects.md
  • apps/mobile/metro.config.js
  • apps/mobile/src/pairing/parseQrPayload.ts
  • apps/mobile/src/components/markdown/utils.ts
  • apps/mobile/package.json
  • apps/mobile/src/components/icon.tsx
  • apps/mobile/src/lib/credentials.ts
  • apps/mobile/src/components/markdown/markdown.tsx
  • apps/mobile/src/app/index.tsx
  • apps/mobile/src/components/touchable-glass.tsx
  • apps/mobile/src/app/(settings)/_layout.tsx
  • apps/mobile/src/components/symbol-image.tsx
  • apps/mobile/eslint.config.js
  • apps/mobile/src/app/model-picker.tsx
  • apps/mobile/src/streaming/tests/streamingReconciliation.test.ts
  • apps/mobile/src/components/drawer-content.tsx
  • apps/mobile/.agents/skills/building-native-ui/references/controls.md
  • apps/mobile/src/components/markdown/chat-markdown.tsx
  • apps/mobile/src/components/chat/prompt-input.tsx
  • apps/mobile/src/components/sidebar.web.tsx
  • apps/mobile/src/app/chats.tsx
  • apps/mobile/src/theme/AccentContext.tsx
  • apps/mobile/src/components/markdown/ast-renderer.ts
  • apps/mobile/src/components/AITabStrip.tsx
  • apps/mobile/src/components/chat/message.tsx
  • apps/mobile/src/tests/offlineQueueReplay.test.ts
  • apps/mobile/src/components/tw.tsx
  • apps/mobile/src/components/markdown/types.ts
  • apps/mobile/src/lib/ToastContext.tsx
  • apps/mobile/src/components/markdown/render-rules.tsx
  • apps/mobile/src/app/_layout.tsx
  • apps/mobile/src/hooks/useMaestroConnection.ts
  • apps/mobile/src/components/chat/conversation.tsx
  • apps/mobile/src/lib/useMaestroWebSocket.ts
  • apps/mobile/src/hooks/useSessionChat.ts
  • apps/mobile/src/tests/sessionAddedRouting.test.tsx
  • apps/mobile/src/app/pair.tsx
  • apps/mobile/src/streaming/reconcileStreamingMessage.ts
  • apps/mobile/src/app/session/[sessionId].tsx
  • src/tests/main/web-server/routes/wsRoute.test.ts
  • apps/mobile/src/lib/SessionsContext.tsx

Comment thread .github/workflows/ci.yml
Comment thread .github/workflows/ci.yml Outdated
Comment thread .github/workflows/ci.yml Outdated
Comment thread apps/mobile/.agents/skills/building-native-ui/references/route-structure.md Outdated
Comment thread apps/mobile/.agents/skills/building-native-ui/references/tabs.md Outdated
Comment thread apps/mobile/.agents/skills/building-native-ui/SKILL.md Outdated
Comment thread apps/mobile/.agents/skills/building-native-ui/SKILL.md Outdated
Comment thread apps/mobile/src/components/chat/prompt-input.web.tsx
Comment thread apps/mobile/src/components/chat/prompt-input.web.tsx Outdated
Ashraf-Ali-aa and others added 2 commits June 21, 2026 10:27
- ci.yml: add top-level least-privilege permissions and persist-credentials: false on checkouts
- skills/building-native-ui: label fenced code blocks with `text` to satisfy MD040
- skills/building-native-ui: rewrite "Running the App" to require a custom dev build (per apps/mobile/CLAUDE.md), since Expo Go does not support this app's native modules
- prompt-input.web.tsx: forward PromptInputAction children into PromptInputBody so they actually render
- prompt-input.web.tsx: gate Enter-to-send by input.trim() and isGenerating, matching the submit button

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace floating @v* tags in .github/workflows/ci.yml with full commit
SHAs (with version comments) so the workflow does not silently shift
under us if an upstream tag is moved.

- actions/checkout@v6     -> df4cb1c (v6.0.3)
- actions/setup-node@v6   -> 48b55a0 (v6.4.0)
- dorny/paths-filter@v3   -> d1c1ffe (v3.0.3)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@chr1syy

chr1syy commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Review: Mobile companion app

Read through the backend integration, the native app, and how it lines up with Maestro's model. The security foundation is solid and the desktop-side wiring is idiomatic, so the concerns below are mostly strategic rather than safety blockers. Scoping this as an "M2, chat companion" milestone, it's a reasonable foundation.

What holds up

  • Pairing crypto is correct. crypto.randomBytes codes, tokens stored only as SHA-256 hashes, timingSafeEqual compare, single-use flag, expiry at both layers (src/main/mobile-pairing/index.ts). No LAN attacker can pair without the on-screen code.
  • Token-at-rest uses expo-secure-store (Keychain/Keystore), not plaintext (apps/mobile/src/lib/credentials.ts:25).
  • CI is path-gated so unrelated PRs aren't slowed, plus SHA-pinned actions and tightened permissions.
  • Backend follows existing patterns (canonical createIpcHandler, preload factory, route classes, atomic write+rename).
  • The useOfflineQueue generalization to a StorageAdapter is legit shared code, not scope creep, and preserves web behavior.

1. It's a chat-only remote, and "New session" is stubbed

The app sends 6 of ~100 WS message types. Creating new agents from mobile isn't needed (that's a desktop task), but creating a new session is, and it's a stub: "New session feature is not available in M2" (apps/mobile/src/components/drawer-content.tsx:308); create_session is never sent. The plumbing is already there though, since new_tab proves the create -> *_result -> state round trip works, so this is a small follow-up rather than new architecture.

Also: the model picker is cosmetic and Claude-only. Hardcoded Anthropic names (_layout.tsx:22-46), selectedModel is a constant (model-context.tsx:26), rows are onPress={() => {}} no-ops, nothing sent over WS. A fake selector that implies a capability is worse than none. Either wire it to a real message sourced from the agent's actual provider, or remove it for now.

2. It forks the desktop-like web client instead of sharing it

src/web/ already has a mature WS client (useWebSocket.ts, ~1,282 lines, ~30 message types) and a full mobile PWA. The native app re-implements trimmed parallel copies of the WS client (useMaestroWebSocket.ts), sessions store, streaming reconciliation, markdown renderer, and theming. The one genuine reuse is the offline-queue hook via @maestro/web-hooks aliasing, which proves cross-tree imports work, contradicting the WS client header's "to avoid cross-tree import resolution issues" justification.

More importantly, SessionData/AITabData are hand-copied subsets that have already dropped fields, so the wire contract now lives in two places and will drift on every new desktop message type. Highest-leverage ask: promote the wire types + WS message-type union into src/shared and import them in both clients (the way theme-types already is).

Two loose ends worth resolving: apps/mobile/src/streaming/reconcileStreamingMessage.ts is a clean, tested reducer that nothing in production uses (the chat screen hand-rolls the same logic inline) - wire it up or delete it. And confirm useMaestroConnection.ts is actually mounted, since SessionsContext calls useMaestroWebSocket directly.

3. Remote access: the cloudflared tunnel already exists - pairing just doesn't use it

Maestro already ships a working built-in cloudflared tunnel: src/main/tunnel-manager.ts runs cloudflared tunnel --url http://localhost:${port} --protocol http2, and the tunnel:start/stop/getStatus IPC (src/main/ipc/handlers/system.ts:330-372) appends the security token to the tunnel URL -> https://<random>.trycloudflare.com/<token>. That already provides TLS + a routable public URL + token auth, and the desktop web interface uses it today.

The mobile gap is just that pairing ignores it: the QR is built from webServer.getUrl() (the LAN 192.168.x IP), and the client hardcodes ws:///http:// (credentials.ts:73, shims/config.ts, pair.tsx:60). So the internet story is a wiring job, not a net-new adapter:

  1. When tunnel:getStatus shows an active tunnel, have pairing advertise the tunnel URL instead of the LAN IP.
  2. Have the mobile client derive wss:///https:// from the payload scheme (the QR parser already accepts https:// web-links).
  3. Optionally an "Enable remote access" toggle that calls tunnel:start, gated by isCloudflaredInstalled.
  4. Harden the now-public redeem endpoint: decouple its rate limit from the global rate-limit toggle, and close already-open sockets on revokeDevice.

Smaller items

  • Two lockfiles committed in apps/mobile/ (both package-lock.json and bun.lock). Pick one - they will drift.
  • apps/mobile/package.json name is still "chat" (un-renamed scaffold).
  • Unused duplicate buildWebSocketUrlFromCredentials in credentials.ts.
  • .unref() the module-level cleanup setInterval (mobile-pairing/index.ts:132).
  • The offline-queue test suite shrank 1,785 -> 741 lines; confirm it still covers partial-failure / max-size / retry-exhaustion.

Bottom line

Solid, safe foundation. Before merge I'd want the two lockfiles resolved, the dead-code questions (streaming reducer, useMaestroConnection) answered, and the fake model picker wired or removed. Then track two follow-ups that decide long-term maintainability: shared protocol types in src/shared, and tunnel-aware pairing to unlock internet access via the cloudflared integration that already exists.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants