From 9f3de0e431389ce323d6cbeb0143f05013e56617 Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Wed, 19 Aug 2026 16:59:28 +0530 Subject: [PATCH 01/18] test(mcp): cover the "Connect desktop tools" row + bump pro pointer - McpAddServerSheet: a paired Off Grid desktop renders a row that fires onAddDesktop, and an already-added companion shows the connected state. - McpServersScreen: the remote-server store mock now provides `servers` (the real store always has it), which the new desktop-detection reads. - Bumps the pro submodule to the companion-desktop feature commits. Leaves package-lock.json / Podfile.lock (local install churn) out of this change. --- .../components/McpAddServerSheet.test.tsx | 27 +++++++++++++++++++ .../rntl/components/McpServersScreen.test.tsx | 2 +- pro | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/__tests__/rntl/components/McpAddServerSheet.test.tsx b/__tests__/rntl/components/McpAddServerSheet.test.tsx index de0d59295..a63994689 100644 --- a/__tests__/rntl/components/McpAddServerSheet.test.tsx +++ b/__tests__/rntl/components/McpAddServerSheet.test.tsx @@ -124,4 +124,31 @@ maybe('McpAddServerSheet', () => { expect(useMcpStore.getState().servers).toHaveLength(1); expect(props.onAddedPreset).not.toHaveBeenCalled(); }); + + it('lists a paired Off Grid desktop and calls onAddDesktop when tapped', () => { + const props = baseProps(); + const onAddDesktop = jest.fn(); + const desktops = [ + { id: 'pc-1', name: 'My Mac', endpoint: 'http://192.168.1.18:7878' }, + ] as any; + const { getByTestId } = render( + , + ); + fireEvent.press(getByTestId('mcp-desktop-pc-1')); + expect(onAddDesktop).toHaveBeenCalledWith(desktops[0]); + }); + + it('marks a desktop whose /mcp companion is already added as connected', () => { + useMcpStore.setState({ + servers: [{ id: 'c', name: 'My Mac tools', url: 'http://192.168.1.18:7878/mcp', authMode: 'header' }], + }); + const desktops = [ + { id: 'pc-1', name: 'My Mac', endpoint: 'http://192.168.1.18:7878' }, + ] as any; + const { getByText } = render( + , + ); + // Feather icons render as their name via the mock; the connected row shows check-circle. + expect(getByText('check-circle')).toBeTruthy(); + }); }); diff --git a/__tests__/rntl/components/McpServersScreen.test.tsx b/__tests__/rntl/components/McpServersScreen.test.tsx index e936b0a30..ecc3fd90e 100644 --- a/__tests__/rntl/components/McpServersScreen.test.tsx +++ b/__tests__/rntl/components/McpServersScreen.test.tsx @@ -45,7 +45,7 @@ jest.mock('@react-navigation/native', () => { jest.mock('../../../src/services/tools/extensions', () => ({ getToolExtensions: () => [] })); const mockAppState = { settings: { enabledTools: [] as string[] }, updateSettings: jest.fn(), activeModelId: undefined, downloadedModels: [] as any[] }; -const mockRemoteState = { activeRemoteTextModelId: 'remote-1' }; +const mockRemoteState = { activeRemoteTextModelId: 'remote-1', servers: [] as any[] }; jest.mock('../../../src/stores', () => ({ useAppStore: (selector?: any) => (selector ? selector(mockAppState) : mockAppState), useRemoteServerStore: (selector?: any) => (selector ? selector(mockRemoteState) : mockRemoteState), diff --git a/pro b/pro index 20ff55a1d..7a7196902 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 20ff55a1da292e247db80bbf48655b6cf94f2345 +Subproject commit 7a719690245e174a0c81fe3397093a1a43a39d7a From d48de0f3bbbb638c0538354303091dea5fe2bcc8 Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Thu, 20 Aug 2026 12:13:35 +0530 Subject: [PATCH 02/18] feat(mcp): QR pairing scanner deps + tests + pro bump - Adds react-native-vision-camera (v4) for the desktop-QR scanner; Podfile.lock updated (VisionCamera 4.7.3). Camera usage string was already in Info.plist. - jest: stub react-native-vision-camera (native module) so screens importing the scanner render in tests; cover the new "Scan a desktop QR" button. - Bumps the pro submodule to the QR pairing commits (scanner, parser, panel, empty-token guard). Leaves the local personal-team signing tweaks (pbxproj DEVELOPMENT_TEAM, entitlements) out of this change. --- .../components/McpAddServerSheet.test.tsx | 14 ++++ ios/Podfile.lock | 16 +++- jest.setup.ts | 9 +++ package-lock.json | 73 +++++++------------ package.json | 1 + pro | 2 +- 6 files changed, 63 insertions(+), 52 deletions(-) diff --git a/__tests__/rntl/components/McpAddServerSheet.test.tsx b/__tests__/rntl/components/McpAddServerSheet.test.tsx index a63994689..e27f7aa10 100644 --- a/__tests__/rntl/components/McpAddServerSheet.test.tsx +++ b/__tests__/rntl/components/McpAddServerSheet.test.tsx @@ -78,6 +78,20 @@ maybe('McpAddServerSheet', () => { expect(props.onAddCustom).toHaveBeenCalledTimes(1); }); + it('shows a "Scan a desktop QR" button that calls onScan (only when provided)', () => { + const withScan = render(); + expect(withScan.queryByTestId('scan-desktop-qr')).toBeTruthy(); + + const onScan = jest.fn(); + const r = render(); + fireEvent.press(r.getByTestId('scan-desktop-qr')); + expect(onScan).toHaveBeenCalledTimes(1); + + // No scan handler -> no scan button (older callers / manual-only). + const noScan = render(); + expect(noScan.queryByTestId('scan-desktop-qr')).toBeNull(); + }); + it('lists the preset rows', () => { const props = baseProps(); const { getByTestId } = render(); diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2fb5a5cd1..39c621d9a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3459,6 +3459,12 @@ PODS: - SSZipArchive (~> 2.5.5) - SocketRocket (0.7.1) - SSZipArchive (2.5.5) + - VisionCamera (4.7.3): + - VisionCamera/Core (= 4.7.3) + - VisionCamera/React (= 4.7.3) + - VisionCamera/Core (4.7.3) + - VisionCamera/React (4.7.3): + - React-Core - whisper-rn (0.5.5): - boost - DoubleConversion @@ -3599,6 +3605,7 @@ DEPENDENCIES: - RNWorklets (from `../node_modules/react-native-worklets`) - RNZipArchive (from `../node_modules/react-native-zip-archive`) - SocketRocket (~> 0.7.1) + - VisionCamera (from `../node_modules/react-native-vision-camera`) - whisper-rn (from `../node_modules/whisper.rn`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -3823,6 +3830,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-worklets" RNZipArchive: :path: "../node_modules/react-native-zip-archive" + VisionCamera: + :path: "../node_modules/react-native-vision-camera" whisper-rn: :path: "../node_modules/whisper.rn" Yoga: @@ -3836,7 +3845,7 @@ SPEC CHECKSUMS: FBLazyVector: 309703e71d3f2f1ed7dc7889d58309c9d77a95a4 fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - hermes-engine: 3de70ea2100f1780402cf146bb8110a0cdb2f34e + hermes-engine: 34c7ed219c0c2601856fc539a2b11284f30b7735 llama-rn: 21f400cc2cf8ae1785f0fdd38b674db646d9cd22 MMKV: 86859fdfa2b0b21db1fd6e48788474a6416a2c77 MMKVCore: 3d16ce9f7d411e135020915fde98a056859a1efa @@ -3881,7 +3890,7 @@ SPEC CHECKSUMS: react-native-background-downloader: b02d12c3961322ce1c85fa0f8b3e4adb5b652106 react-native-document-picker: dc2d83366e47e89e7c51e8a41eab99c1d54e941c react-native-document-viewer: 8c6ed07e7e27352743fa98e8dd6d288ad925b884 - react-native-executorch: 9a44ee2b18773cbe5ad2e6d7376eb76f347e2935 + react-native-executorch: 9b76d1e297ae1ea4c3c93d58c6ecec693423206a react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba react-native-image-picker: 0314366753615115fa55c3cc937ac44cb7e75702 react-native-keyboard-controller: 7534b5a39d1e8b2b79f86e8e998ed71c7154f69f @@ -3940,9 +3949,10 @@ SPEC CHECKSUMS: RNZipArchive: f2806ba80e24cf1984d6a7cb361d8a07d734997d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SSZipArchive: c69881e8ac5521f0e622291387add5f60f30f3c4 + VisionCamera: 7187b3dac1ff3071234ead959ce311875748e14f whisper-rn: 7566faf9b7d78e39ab9fc634cb90fdee81177793 Yoga: 5456bb010373068fc92221140921b09d126b116e PODFILE CHECKSUM: f66f810a788ead15881075527443239e49b50db1 -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/jest.setup.ts b/jest.setup.ts index 17436bc3b..d95d3e98f 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -41,6 +41,15 @@ jest.mock('react-native-edge-to-edge', () => ({ NavigationBar: () => null, })); +// react-native-vision-camera is a native module (NitroModules); stub it so any +// screen that imports the QR scanner can render in jest without the native view. +jest.mock('react-native-vision-camera', () => ({ + Camera: () => null, + useCameraDevice: () => undefined, + useCameraPermission: () => ({ hasPermission: false, requestPermission: jest.fn() }), + useCodeScanner: (config: unknown) => config, +})); + // ============================================================================ // AsyncStorage Mock // ============================================================================ diff --git a/package-lock.json b/package-lock.json index 6156fcd4c..9b280e0d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "react-native-tcp-socket": "^6.4.1", "react-native-url-polyfill": "^3.0.0", "react-native-vector-icons": "^10.3.0", + "react-native-vision-camera": "^4.7.3", "react-native-worklets": "^0.7.3", "react-native-zeroconf": "^0.14.0", "react-native-zip-archive": "7.1.0", @@ -4595,9 +4596,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4615,9 +4613,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4635,9 +4630,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4655,9 +4647,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4675,9 +4664,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4695,9 +4681,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4715,9 +4698,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4735,9 +4715,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4950,9 +4927,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4967,9 +4941,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4984,9 +4955,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5001,9 +4969,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5018,9 +4983,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5035,9 +4997,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5052,9 +5011,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5069,9 +5025,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -15107,6 +15060,30 @@ "node": ">=10" } }, + "node_modules/react-native-vision-camera": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.7.3.tgz", + "integrity": "sha512-g1/neOyjSqn1kaAa2FxI/qp5KzNvPcF0bnQw6NntfbxH6tm0+8WFZszlgb5OV+iYlB6lFUztCbDtyz5IpL47OA==", + "license": "MIT", + "peerDependencies": { + "@shopify/react-native-skia": "*", + "react": "*", + "react-native": "*", + "react-native-reanimated": "*", + "react-native-worklets-core": "*" + }, + "peerDependenciesMeta": { + "@shopify/react-native-skia": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + }, + "react-native-worklets-core": { + "optional": true + } + } + }, "node_modules/react-native-worklets": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.3.tgz", diff --git a/package.json b/package.json index 899fdf9c1..ad273ed50 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "react-native-tcp-socket": "^6.4.1", "react-native-url-polyfill": "^3.0.0", "react-native-vector-icons": "^10.3.0", + "react-native-vision-camera": "^4.7.3", "react-native-worklets": "^0.7.3", "react-native-zeroconf": "^0.14.0", "react-native-zip-archive": "7.1.0", diff --git a/pro b/pro index 7a7196902..8812431d1 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 7a719690245e174a0c81fe3397093a1a43a39d7a +Subproject commit 8812431d1cd58ba8181fedc380bdb0efa04a862d From 4b1c450cfcd2441c258525f037d95b92c676344b Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Thu, 20 Aug 2026 15:13:50 +0530 Subject: [PATCH 03/18] test(mcp): drop the vision-camera scanner mock + scan-button test The desktop token-QR scanner is gone (tools now arrive over the sync mesh), so remove its jest mock and replace the scan-button test with a guard that the button stays absent. Bumps the mobile-pro submodule to bfdb9eb. --- .../rntl/components/McpAddServerSheet.test.tsx | 17 +++++------------ jest.setup.ts | 9 --------- pro | 2 +- 3 files changed, 6 insertions(+), 22 deletions(-) diff --git a/__tests__/rntl/components/McpAddServerSheet.test.tsx b/__tests__/rntl/components/McpAddServerSheet.test.tsx index e27f7aa10..c9e177841 100644 --- a/__tests__/rntl/components/McpAddServerSheet.test.tsx +++ b/__tests__/rntl/components/McpAddServerSheet.test.tsx @@ -78,18 +78,11 @@ maybe('McpAddServerSheet', () => { expect(props.onAddCustom).toHaveBeenCalledTimes(1); }); - it('shows a "Scan a desktop QR" button that calls onScan (only when provided)', () => { - const withScan = render(); - expect(withScan.queryByTestId('scan-desktop-qr')).toBeTruthy(); - - const onScan = jest.fn(); - const r = render(); - fireEvent.press(r.getByTestId('scan-desktop-qr')); - expect(onScan).toHaveBeenCalledTimes(1); - - // No scan handler -> no scan button (older callers / manual-only). - const noScan = render(); - expect(noScan.queryByTestId('scan-desktop-qr')).toBeNull(); + it('has no "Scan a desktop QR" button - a paired desktop grants tools over the mesh', () => { + // The QR-scan pairing was removed: a paired desktop now hands its tools over + // the sync mesh, so there is nothing to scan from the add sheet. + const { queryByTestId } = render(); + expect(queryByTestId('scan-desktop-qr')).toBeNull(); }); it('lists the preset rows', () => { diff --git a/jest.setup.ts b/jest.setup.ts index d95d3e98f..17436bc3b 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -41,15 +41,6 @@ jest.mock('react-native-edge-to-edge', () => ({ NavigationBar: () => null, })); -// react-native-vision-camera is a native module (NitroModules); stub it so any -// screen that imports the QR scanner can render in jest without the native view. -jest.mock('react-native-vision-camera', () => ({ - Camera: () => null, - useCameraDevice: () => undefined, - useCameraPermission: () => ({ hasPermission: false, requestPermission: jest.fn() }), - useCodeScanner: (config: unknown) => config, -})); - // ============================================================================ // AsyncStorage Mock // ============================================================================ diff --git a/pro b/pro index 8812431d1..bfdb9ebc5 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 8812431d1cd58ba8181fedc380bdb0efa04a862d +Subproject commit bfdb9ebc5b621e8ba6e9782189f40859f8e0d65d From c256d0f8ac5ff54b21b8ed9a696c1db83479b612 Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Thu, 20 Aug 2026 15:43:11 +0530 Subject: [PATCH 04/18] test(sync): cover scan-to-pair + restore the vision-camera mock A decoded QR carrying a valid pairing code pairs via the same onPair as typing; a non-pairing QR is ignored. Restores the vision-camera jest stub for the new QrScanner and bumps the mobile-pro submodule. --- .../rntl/components/pairingCodeSheet.test.tsx | 110 ++++++++++++++++++ jest.setup.ts | 9 ++ pro | 2 +- 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 __tests__/rntl/components/pairingCodeSheet.test.tsx diff --git a/__tests__/rntl/components/pairingCodeSheet.test.tsx b/__tests__/rntl/components/pairingCodeSheet.test.tsx new file mode 100644 index 000000000..ec2c70811 --- /dev/null +++ b/__tests__/rntl/components/pairingCodeSheet.test.tsx @@ -0,0 +1,110 @@ +/** + * Integration (RNTL): PairingCodeSheet scan-to-pair. + * + * Guards the approved behavior change: a paired-code sheet can be filled by scanning + * the other device's QR, not just by typing. A decoded QR carrying a valid pairing + * code lands on the SAME onPair (syncService.pair) as the typed path, and a QR that + * is not a pairing code is ignored so the scanner keeps looking. + * + * Lives in the private pro/ submodule, loaded via a computed path so the suite skips + * in open-core CI where pro/ is absent. + */ + +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react-native'; + +jest.mock('react-native-vector-icons/Feather', () => { + const { Text } = require('react-native'); + return ({ name, ...props }: any) => {name}; +}); + +// The sheet is a modal wrapper; render its children inline so the test can drive the +// content without a navigation/provider host. +jest.mock('@offgrid/core/components/AppSheet', () => ({ + AppSheet: ({ children }: { children: React.ReactNode }) => children, +})); + +jest.mock('../../../src/theme', () => { + const colors = { + text: '#000', textMuted: '#999', primary: '#1DB954', error: '#F00', + background: '#FFF', surface: '#F5F5F5', border: '#E0E0E0', + }; + const shadows = { small: {}, medium: {}, large: {} }; + return { + useTheme: () => ({ colors, shadows, isDark: false }), + useThemedStyles: (fn: any) => fn(colors, shadows), + }; +}); + +// vision-camera is globally stubbed in jest.setup; capture the scan config here so +// the test can simulate a decoded QR frame. +const visionCamera = require('react-native-vision-camera'); +let scanConfig: { onCodeScanned: (codes: { value?: string }[]) => void } | null = null; + +type SheetModule = typeof import('../../../pro/ui/SyncScreen/PairingCodeSheet'); + +function load(): SheetModule | null { + try { + return require(['..', '..', '..', 'pro', 'ui', 'SyncScreen', 'PairingCodeSheet'].join('/')); + } catch { + return null; + } +} + +const mod = load(); +const maybe = mod ? describe : describe.skip; + +// A valid code: every character is in the pairing alphabet. +const VALID_QR = 'ABCD2345'; + +maybe('PairingCodeSheet scan-to-pair', () => { + const { PairingCodeSheet } = mod!; + + const baseProps = () => ({ + visible: true, + deviceName: 'Studio Mac', + confirmLabel: 'Pair', + testIDPrefix: 'sync-test', + onClose: jest.fn(), + onPair: jest.fn().mockResolvedValue(undefined), + }); + + beforeEach(() => { + scanConfig = null; + jest.spyOn(visionCamera, 'useCodeScanner').mockImplementation((cfg: any) => { + scanConfig = cfg; + return cfg; + }); + }); + + it('offers a Scan button that opens the camera scanner', () => { + const { getByTestId, queryByText, getByText } = render( + , + ); + expect(queryByText('Camera access needed')).toBeNull(); + fireEvent.press(getByTestId('sync-test-scan')); + // Global vision-camera mock reports no permission, so the scanner asks for it - + // proof the scanner surface mounted. + expect(getByText('Camera access needed')).toBeTruthy(); + }); + + it('pairs from a scanned QR via the same onPair as typing', async () => { + const props = baseProps(); + const { getByTestId } = render(); + fireEvent.press(getByTestId('sync-test-scan')); + await act(async () => { + scanConfig!.onCodeScanned([{ value: VALID_QR }]); + }); + expect(props.onPair).toHaveBeenCalledWith(VALID_QR); + }); + + it('ignores a QR that is not a pairing code', async () => { + const props = baseProps(); + const { getByTestId } = render(); + fireEvent.press(getByTestId('sync-test-scan')); + await act(async () => { + scanConfig!.onCodeScanned([{ value: 'https://example.com/not-a-code' }]); + }); + expect(props.onPair).not.toHaveBeenCalled(); + }); +}); diff --git a/jest.setup.ts b/jest.setup.ts index 17436bc3b..a2bc142f2 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -41,6 +41,15 @@ jest.mock('react-native-edge-to-edge', () => ({ NavigationBar: () => null, })); +// react-native-vision-camera is a native module (NitroModules); stub it so the +// QR scanner (mesh Scan-to-pair) can render in jest without the native view. +jest.mock('react-native-vision-camera', () => ({ + Camera: () => null, + useCameraDevice: () => undefined, + useCameraPermission: () => ({ hasPermission: false, requestPermission: jest.fn() }), + useCodeScanner: (config: unknown) => config, +})); + // ============================================================================ // AsyncStorage Mock // ============================================================================ diff --git a/pro b/pro index bfdb9ebc5..ee1ea526e 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit bfdb9ebc5b621e8ba6e9782189f40859f8e0d65d +Subproject commit ee1ea526eeb30c4b91af93ebe90dffb10b2fdf22 From ce0b1ee97f2363c3279e3aa81ffa08317abeb726 Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Thu, 20 Aug 2026 16:00:09 +0530 Subject: [PATCH 05/18] test(sync): guard that opening the scanner hides the pairing sheet Regression for the iOS one-modal-at-a-time fix: pressing Scan hides the sheet body so the scanner can present. Bumps mobile-pro. --- .../rntl/components/pairingCodeSheet.test.tsx | 16 +++++++++++++--- pro | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/__tests__/rntl/components/pairingCodeSheet.test.tsx b/__tests__/rntl/components/pairingCodeSheet.test.tsx index ec2c70811..1ce4dbcf9 100644 --- a/__tests__/rntl/components/pairingCodeSheet.test.tsx +++ b/__tests__/rntl/components/pairingCodeSheet.test.tsx @@ -18,10 +18,12 @@ jest.mock('react-native-vector-icons/Feather', () => { return ({ name, ...props }: any) => {name}; }); -// The sheet is a modal wrapper; render its children inline so the test can drive the -// content without a navigation/provider host. +// The sheet is a modal wrapper; render its children inline (respecting `visible`, as +// the real one does) so the test can drive the content and observe it hiding while +// the scanner is open. jest.mock('@offgrid/core/components/AppSheet', () => ({ - AppSheet: ({ children }: { children: React.ReactNode }) => children, + AppSheet: ({ visible, children }: { visible: boolean; children: React.ReactNode }) => + visible ? children : null, })); jest.mock('../../../src/theme', () => { @@ -88,6 +90,14 @@ maybe('PairingCodeSheet scan-to-pair', () => { expect(getByText('Camera access needed')).toBeTruthy(); }); + it('hides the pairing sheet while the scanner is open (one modal at a time)', () => { + // iOS presents one modal at a time; the sheet must yield so the scanner can show. + const { getByTestId, queryByTestId } = render(); + expect(queryByTestId('sync-test-input')).toBeTruthy(); + fireEvent.press(getByTestId('sync-test-scan')); + expect(queryByTestId('sync-test-input')).toBeNull(); + }); + it('pairs from a scanned QR via the same onPair as typing', async () => { const props = baseProps(); const { getByTestId } = render(); diff --git a/pro b/pro index ee1ea526e..01a838a05 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit ee1ea526eeb30c4b91af93ebe90dffb10b2fdf22 +Subproject commit 01a838a05a440ba9efc62a7607dbbf05f49f6aff From 81da65833e915a3e6698143c8dc42949b11c1c27 Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Thu, 20 Aug 2026 16:08:11 +0530 Subject: [PATCH 06/18] chore(pro): bump mobile-pro - QR scanner overlay fix --- pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pro b/pro index 01a838a05..7847444c7 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 01a838a05a440ba9efc62a7607dbbf05f49f6aff +Subproject commit 7847444c7fa2a3999fe98c4961a6b58aa714cd18 From 0445d6f2f93af98d535809b7f80b3cd34514a33e Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Thu, 20 Aug 2026 16:31:38 +0530 Subject: [PATCH 07/18] test(mcp): cover the Pro tools desktop-tools grant + bump mobile-pro CompanionToolsSection lists only desktop peers, reflects grantedByDeviceId, and toggles via requestTools. Bumps mobile-pro to the single-home refactor. --- .../components/companionToolsSection.test.tsx | 84 +++++++++++++++++++ pro | 2 +- 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 __tests__/rntl/components/companionToolsSection.test.tsx diff --git a/__tests__/rntl/components/companionToolsSection.test.tsx b/__tests__/rntl/components/companionToolsSection.test.tsx new file mode 100644 index 000000000..6a083d199 --- /dev/null +++ b/__tests__/rntl/components/companionToolsSection.test.tsx @@ -0,0 +1,84 @@ +/** + * Integration (RNTL): CompanionToolsSection - the single home for desktop tools. + * + * Proves the moved grant: paired desktops (only desktops) appear in Pro tools with a + * switch that reflects whether their tools are connected here (grantedByDeviceId), and + * flipping one calls requestTools(deviceId, next) - the same mesh request the old + * Devices-row toggle sent. Loaded via a computed path so it skips where pro/ is absent. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; + +jest.mock('react-native-vector-icons/Feather', () => { + const { Text } = require('react-native'); + return ({ name, ...props }: any) => {name}; +}); + +jest.mock('../../../src/theme', () => ({ + useTheme: () => ({ + colors: { + text: '#000', textMuted: '#999', primary: '#1DB954', surface: '#F5F5F5', border: '#E0E0E0', + }, + }), +})); + +const mockState: { knownDevices: unknown[]; servers: unknown[] } = { knownDevices: [], servers: [] }; +const mockRequestTools = jest.fn(); + +jest.mock('../../../pro/sync/syncStore', () => ({ + useSyncStore: (selector: (s: unknown) => unknown) => + selector({ knownDevices: mockState.knownDevices }), +})); +jest.mock('../../../pro/mcp/mcpStore', () => ({ + useMcpStore: (selector: (s: unknown) => unknown) => selector({ servers: mockState.servers }), +})); +jest.mock('../../../pro/mcp/mcpToolGrantService', () => ({ + requestTools: (...args: unknown[]) => mockRequestTools(...args), +})); + +type Mod = typeof import('../../../pro/ui/CompanionToolsSection'); +function load(): Mod | null { + try { + return require(['..', '..', '..', 'pro', 'ui', 'CompanionToolsSection'].join('/')); + } catch { + return null; + } +} + +const mod = load(); +const maybe = mod ? describe : describe.skip; + +maybe('CompanionToolsSection', () => { + const { CompanionToolsSection } = mod!; + + beforeEach(() => { + mockState.knownDevices = []; + mockState.servers = []; + mockRequestTools.mockClear(); + }); + + it('lists only desktop peers, reflects the grant, and toggles via requestTools', () => { + mockState.knownDevices = [ + { id: 'mac1', name: 'My Mac', platform: 'macos' }, + { id: 'phone1', name: 'My Phone', platform: 'ios' }, + ]; + mockState.servers = [{ id: 's1', grantedByDeviceId: 'mac1' }]; + + const { getByTestId, queryByTestId } = render(); + // Desktop shows; a phone peer (serves no tools) is filtered out. + expect(getByTestId('companion-tools-mac1')).toBeTruthy(); + expect(queryByTestId('companion-tools-phone1')).toBeNull(); + + const sw = getByTestId('companion-tools-switch-mac1'); + expect(sw.props.value).toBe(true); // granted -> on + fireEvent(sw, 'valueChange', false); + expect(mockRequestTools).toHaveBeenCalledWith('mac1', false); + }); + + it('renders nothing when there are no paired desktops', () => { + mockState.knownDevices = [{ id: 'phone1', name: 'Phone', platform: 'android' }]; + const { toJSON } = render(); + expect(toJSON()).toBeNull(); + }); +}); diff --git a/pro b/pro index 7847444c7..e04376ccb 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 7847444c7fa2a3999fe98c4961a6b58aa714cd18 +Subproject commit e04376ccbce06db2f2bb7554c6cd2d1fedb24d5f From c61a8e8cdfef5853987c404b50f1a47e23fe961e Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Thu, 20 Aug 2026 22:01:24 +0530 Subject: [PATCH 08/18] chore(pro): bump mobile-pro - offline-forget fix --- pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pro b/pro index e04376ccb..aeb861e58 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit e04376ccbce06db2f2bb7554c6cd2d1fedb24d5f +Subproject commit aeb861e5851fc56fbc95d81b74dac177a5ab03b1 From b1fb265147de0a87d41d452c9a58318955a70d6f Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Fri, 21 Aug 2026 10:57:04 +0530 Subject: [PATCH 09/18] test(mcp): drop the desktop-section sheet tests + bump mobile-pro The "Paired Off Grid desktops" add-sheet section is gone (mesh grant replaces it); remove its two render tests. Bumps mobile-pro to the removal. --- .../components/McpAddServerSheet.test.tsx | 27 ------------------- pro | 2 +- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/__tests__/rntl/components/McpAddServerSheet.test.tsx b/__tests__/rntl/components/McpAddServerSheet.test.tsx index c9e177841..ce29aca23 100644 --- a/__tests__/rntl/components/McpAddServerSheet.test.tsx +++ b/__tests__/rntl/components/McpAddServerSheet.test.tsx @@ -131,31 +131,4 @@ maybe('McpAddServerSheet', () => { expect(useMcpStore.getState().servers).toHaveLength(1); expect(props.onAddedPreset).not.toHaveBeenCalled(); }); - - it('lists a paired Off Grid desktop and calls onAddDesktop when tapped', () => { - const props = baseProps(); - const onAddDesktop = jest.fn(); - const desktops = [ - { id: 'pc-1', name: 'My Mac', endpoint: 'http://192.168.1.18:7878' }, - ] as any; - const { getByTestId } = render( - , - ); - fireEvent.press(getByTestId('mcp-desktop-pc-1')); - expect(onAddDesktop).toHaveBeenCalledWith(desktops[0]); - }); - - it('marks a desktop whose /mcp companion is already added as connected', () => { - useMcpStore.setState({ - servers: [{ id: 'c', name: 'My Mac tools', url: 'http://192.168.1.18:7878/mcp', authMode: 'header' }], - }); - const desktops = [ - { id: 'pc-1', name: 'My Mac', endpoint: 'http://192.168.1.18:7878' }, - ] as any; - const { getByText } = render( - , - ); - // Feather icons render as their name via the mock; the connected row shows check-circle. - expect(getByText('check-circle')).toBeTruthy(); - }); }); diff --git a/pro b/pro index aeb861e58..c235e6fa9 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit aeb861e5851fc56fbc95d81b74dac177a5ab03b1 +Subproject commit c235e6fa98e4c16bfec9049c90b52d531e2716cc From ca2b0e205ed2f7c70141c2f3200e5dd993a528e8 Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Fri, 21 Aug 2026 11:41:02 +0530 Subject: [PATCH 10/18] chore(pro): bump mobile-pro - revert offline-forget (conflicts with intended behavior) --- pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pro b/pro index c235e6fa9..aab1e6798 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit c235e6fa98e4c16bfec9049c90b52d531e2716cc +Subproject commit aab1e6798661b7cf241d2b046cdd645868584372 From 61f6a2879193373bf78dc3c0a7ea1a9f07bc0bcd Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Fri, 21 Aug 2026 11:55:18 +0530 Subject: [PATCH 11/18] test(mcp): stub CompanionToolsSection in the McpServersScreen suite The paired-desktops tools section pulls the sync store + grant service (and thus syncService, which doesn't load under jest) into McpServersScreen's module, so the suite could no longer require the screen. Stub it to null - this suite covers the MCP server cards, not the companion section. --- __tests__/rntl/components/McpServersScreen.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/__tests__/rntl/components/McpServersScreen.test.tsx b/__tests__/rntl/components/McpServersScreen.test.tsx index ecc3fd90e..7252655b5 100644 --- a/__tests__/rntl/components/McpServersScreen.test.tsx +++ b/__tests__/rntl/components/McpServersScreen.test.tsx @@ -55,6 +55,10 @@ jest.mock('../../../pro/mcp/mcpService', () => ({ connectServer: jest.fn(), disconnectServer: jest.fn(), signOutServer: jest.fn(), })); +// The paired-desktops tools section pulls in the sync store + grant service (→ syncService, +// which does not load under jest). This suite is about the MCP server cards, so stub it out. +jest.mock('../../../pro/ui/CompanionToolsSection', () => ({ CompanionToolsSection: () => null })); + type ScreenModule = typeof import('../../../pro/ui/McpServersScreen'); type StoreModule = typeof import('../../../pro/mcp/mcpStore'); From 87adccb2f3c5485b4309e1564f373f0da381c352 Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Fri, 21 Aug 2026 17:05:58 +0530 Subject: [PATCH 12/18] feat(chat): chatOverlay slot for the pending computer-use approval card Add a chat.overlay slot rendered below the header, above the messages, and wire ChatScreen to render whatever pro registers into it. Pro fills it with the computer-use approval card (a paired desktop's forwarded task); free builds register nothing and the slot is inert. src never imports pro - the slot seam keeps that boundary. Co-Authored-By: Claude Opus 4.8 --- src/bootstrap/slotRegistry.ts | 4 ++++ src/screens/ChatScreen/index.tsx | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/src/bootstrap/slotRegistry.ts b/src/bootstrap/slotRegistry.ts index fa90db726..2da6e9b79 100644 --- a/src/bootstrap/slotRegistry.ts +++ b/src/bootstrap/slotRegistry.ts @@ -85,4 +85,8 @@ export const SLOTS = { * download/management). The tab itself only appears when this is * registered, so free builds show just Text/Image. */ modelsScreenVoiceTab: 'modelsScreen.voiceTab', + /** Full-width overlay pinned above the chat messages (below the header) - e.g. a + * pending computer-use approval forwarded from a paired desktop, answered here. + * Renders nothing (and takes no space) when there is nothing pending. */ + chatOverlay: 'chat.overlay', } as const; diff --git a/src/screens/ChatScreen/index.tsx b/src/screens/ChatScreen/index.tsx index 6cebc0eac..e51e9ba6f 100644 --- a/src/screens/ChatScreen/index.tsx +++ b/src/screens/ChatScreen/index.tsx @@ -27,6 +27,7 @@ import { WhisperPickerSheet } from '../../components/models/WhisperPickerSheet'; import { VoiceModelsSheet } from '../../components/models/VoiceModelsSheet'; import { useWhisperStore } from '../../stores/whisperStore'; import { WHISPER_MODELS } from '../../services'; +import { getSlot, SLOTS } from '../../bootstrap/slotRegistry'; function countConversationImages(conv: Conversation | undefined): number { return (conv?.messages || []).reduce((n: number, m: Message) => @@ -215,6 +216,12 @@ export const ChatScreen: React.FC = () => { setShowProjectSelector={chat.setShowProjectSelector} isRemote={chat.activeModelInfo?.isRemote} /> + {/* Pro-registered overlay pinned above the messages: a paired desktop's pending + computer-use approval, answered here. Self-hides when nothing is pending. */} + {(() => { + const ChatOverlay = getSlot(SLOTS.chatOverlay); + return ChatOverlay ? : null; + })()} setModelsManagerOpen(false)} From dc967575d6ebffdf8fcc8f66aabfc1c950609cbe Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Mon, 24 Aug 2026 17:13:38 +0530 Subject: [PATCH 13/18] chore: bump mobile-pro (desktop tools controlled entirely by device sync) Points mobile-pro at e1e6f3e: drop desktop-tool grants on un-pair and reconcile orphaned grants on launch/roster change, so a granted "Desktop tools" companion never outlives its pairing. Co-Authored-By: Claude Opus 4.8 --- pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pro b/pro index 9d9cea384..e1e6f3eb2 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 9d9cea384633e6420eb83d4bf962cafd7060b562 +Subproject commit e1e6f3eb2267755953729fc1c71ad6b4dc5d89cf From 7de2a97ef859b3f42cbb4faf88cca5d70a8f1cbf Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Wed, 26 Aug 2026 13:34:48 +0530 Subject: [PATCH 14/18] feat(remote): surface the gateway's image models in the pickers Discovery tags every remote model with its modality (kind:'image' from the desktop gateway; name heuristics never promote to image, so Ollama/LM Studio stay text-only) - one owner of that rule, both pickers split on the tag. The active remote image model gets its own server field so picking it can no longer re-route text generation, and selecting one no longer creates/loads the shared chat provider (that overwrote the text model id). Selecting a remote image model frees the local diffusion weights, mirroring remote text. Co-Authored-By: Claude Fable 5 --- .../unit/hooks/useEjectAllModels.test.ts | 4 +- .../unit/services/remoteServerManager.test.ts | 75 +++++++------------ .../unit/stores/remoteServerStore.test.ts | 10 +-- .../ModelSelectorModal/ImageTab.tsx | 4 +- src/components/ModelSelectorModal/index.tsx | 52 +++++++++---- src/screens/HomeScreen/hooks/useHomeScreen.ts | 18 ++--- src/services/remoteServerManager.ts | 2 +- src/services/remoteServerManagerUtils.ts | 24 ++---- src/stores/remoteServerHelpers.ts | 33 +++++++- src/stores/remoteServerStore.ts | 31 ++++++-- src/types/remoteServer.ts | 3 + 11 files changed, 146 insertions(+), 110 deletions(-) diff --git a/__tests__/unit/hooks/useEjectAllModels.test.ts b/__tests__/unit/hooks/useEjectAllModels.test.ts index b1e3482ae..09b7d29d0 100644 --- a/__tests__/unit/hooks/useEjectAllModels.test.ts +++ b/__tests__/unit/hooks/useEjectAllModels.test.ts @@ -41,7 +41,7 @@ const nothingActive = (): void => { app.setActiveImageModelId(null); const remote = useRemoteServerStore.getState(); remote.setActiveRemoteTextModelId(null); - remote.setActiveRemoteImageModelId(null); + remote.setActiveRemoteImageModel(null, null); }; beforeEach(() => { @@ -63,7 +63,7 @@ describe('useEjectAllModels', () => { ], [ 'a remote image model', - (): void => useRemoteServerStore.getState().setActiveRemoteImageModelId('r2'), + (): void => useRemoteServerStore.getState().setActiveRemoteImageModel('srv-1', 'r2'), ], ])('offers the eject when the only thing loaded is %s', (_what, load) => { // Each of the four enables it independently. An `||` chain that dropped one would silently strand the user diff --git a/__tests__/unit/services/remoteServerManager.test.ts b/__tests__/unit/services/remoteServerManager.test.ts index 99856a3d9..01ea5c914 100644 --- a/__tests__/unit/services/remoteServerManager.test.ts +++ b/__tests__/unit/services/remoteServerManager.test.ts @@ -254,7 +254,7 @@ describe('remoteServerManager', () => { (useRemoteServerStore.getState as jest.Mock).mockReturnValue({ setActiveServerId: jest.fn(), setActiveRemoteTextModelId: jest.fn(), - setActiveRemoteImageModelId: jest.fn(), + setActiveRemoteImageModel: jest.fn(), getServerById: jest.fn().mockReturnValue(null), getModelById: jest.fn().mockReturnValue(null), }); @@ -272,7 +272,7 @@ describe('remoteServerManager', () => { (useRemoteServerStore.getState as jest.Mock).mockReturnValue({ setActiveServerId: jest.fn(), setActiveRemoteTextModelId: jest.fn(), - setActiveRemoteImageModelId: jest.fn(), + setActiveRemoteImageModel: jest.fn(), getServerById: jest.fn().mockReturnValue(null), }); @@ -298,15 +298,20 @@ describe('remoteServerManager', () => { (useRemoteServerStore.getState as jest.Mock).mockReturnValue({ setActiveServerId: jest.fn(), setActiveRemoteTextModelId: jest.fn(), - setActiveRemoteImageModelId: jest.fn(), + setActiveRemoteImageModel: jest.fn(), getServerById: jest.fn().mockReturnValue(null), }); await remoteServerManager.setActiveRemoteImageModel('server-123', 'llava'); - expect(useRemoteServerStore.getState().setActiveServerId).toHaveBeenCalledWith('server-123'); - expect(useRemoteServerStore.getState().setActiveRemoteImageModelId).toHaveBeenCalledWith('llava'); - expect(mockLoadModel).toHaveBeenCalledWith('llava'); + // Image selection must not re-route text: no shared activeServerId write, and + // no loadModel on the shared chat provider (that overwrote the text model id). + expect(useRemoteServerStore.getState().setActiveServerId).not.toHaveBeenCalled(); + expect(useRemoteServerStore.getState().setActiveRemoteImageModel).toHaveBeenCalledWith( + 'server-123', + 'llava', + ); + expect(mockLoadModel).not.toHaveBeenCalled(); }); }); @@ -316,7 +321,7 @@ describe('remoteServerManager', () => { (useRemoteServerStore.getState as jest.Mock).mockReturnValue({ setActiveServerId: jest.fn(), setActiveRemoteTextModelId: jest.fn(), - setActiveRemoteImageModelId: jest.fn(), + setActiveRemoteImageModel: jest.fn(), getServerById: jest.fn().mockReturnValue(null), }); @@ -324,7 +329,7 @@ describe('remoteServerManager', () => { expect(useRemoteServerStore.getState().setActiveServerId).toHaveBeenCalledWith(null); expect(useRemoteServerStore.getState().setActiveRemoteTextModelId).toHaveBeenCalledWith(null); - expect(useRemoteServerStore.getState().setActiveRemoteImageModelId).toHaveBeenCalledWith(null); + expect(useRemoteServerStore.getState().setActiveRemoteImageModel).toHaveBeenCalledWith(null, null); expect(providerRegistry.setActiveProvider).toHaveBeenCalledWith('local'); }); }); @@ -684,7 +689,7 @@ describe('remoteServerManager', () => { (useRemoteServerStore.getState as jest.Mock).mockReturnValue({ setActiveServerId: jest.fn(), setActiveRemoteTextModelId: jest.fn(), - setActiveRemoteImageModelId: jest.fn(), + setActiveRemoteImageModel: jest.fn(), getServerById: jest.fn().mockReturnValue(mockServer), getModelById: jest.fn().mockReturnValue(null), }); @@ -697,52 +702,30 @@ describe('remoteServerManager', () => { }); }); - describe('setActiveRemoteImageModel - provider creation', () => { - it('should create provider when it does not exist', async () => { - const mockLoadModel = jest.fn().mockResolvedValue(undefined); - const mockProvider = { - loadModel: mockLoadModel, - unloadModel: jest.fn(), - isModelLoaded: jest.fn().mockReturnValue(true), - isReady: jest.fn().mockResolvedValue(true), - }; - const mockServer = { id: 'server-1', name: 'Test', endpoint: 'http://localhost:11434' }; - - (providerRegistry.getProvider as jest.Mock) - .mockReturnValueOnce(null) - .mockReturnValueOnce(mockProvider); - (providerRegistry.registerProvider as jest.Mock).mockReturnValue(undefined); - (useRemoteServerStore.getState as jest.Mock).mockReturnValue({ - setActiveServerId: jest.fn(), - setActiveRemoteTextModelId: jest.fn(), - setActiveRemoteImageModelId: jest.fn(), - getServerById: jest.fn().mockReturnValue(mockServer), - }); - (Keychain.getGenericPassword as jest.Mock).mockResolvedValue(null); - - await remoteServerManager.setActiveRemoteImageModel('server-1', 'llava'); - - expect(providerRegistry.registerProvider).toHaveBeenCalled(); - expect(mockLoadModel).toHaveBeenCalledWith('llava'); - }); - - it('should warn when provider cannot be created', async () => { - const _mockServer = { id: 'server-1', name: 'Test', endpoint: 'http://localhost:11434' }; - const _mockLogger = { warn: jest.fn() }; - jest.spyOn(console, 'warn').mockImplementation(() => {}); - + describe('setActiveRemoteImageModel - no provider involvement', () => { + // Image selection is store-only: the chat provider is shared with the text + // model, so creating or loading it here overwrote the text model id (the + // clobber bug). The diffusion engine reads the server record at generation + // time instead. + it('never creates or loads the shared chat provider', async () => { (providerRegistry.getProvider as jest.Mock).mockReturnValue(null); + const setActiveRemoteImageModel = jest.fn(); (useRemoteServerStore.getState as jest.Mock).mockReturnValue({ setActiveServerId: jest.fn(), setActiveRemoteTextModelId: jest.fn(), - setActiveRemoteImageModelId: jest.fn(), - getServerById: jest.fn().mockReturnValue(null), // No server found + setActiveRemoteImageModel, + getServerById: jest.fn().mockReturnValue({ + id: 'server-1', + name: 'Test', + endpoint: 'http://localhost:11434', + }), }); await remoteServerManager.setActiveRemoteImageModel('server-1', 'llava'); - // No provider created because server not found + expect(setActiveRemoteImageModel).toHaveBeenCalledWith('server-1', 'llava'); expect(providerRegistry.registerProvider).not.toHaveBeenCalled(); + expect(providerRegistry.getProvider).not.toHaveBeenCalled(); }); }); }); \ No newline at end of file diff --git a/__tests__/unit/stores/remoteServerStore.test.ts b/__tests__/unit/stores/remoteServerStore.test.ts index f494c31de..ecd466f58 100644 --- a/__tests__/unit/stores/remoteServerStore.test.ts +++ b/__tests__/unit/stores/remoteServerStore.test.ts @@ -410,7 +410,7 @@ describe('remoteServerStore', () => { describe('activeRemoteImageModelId', () => { it('should set active remote image model ID', () => { actStoreUpdate(() => { - useRemoteServerStore.getState().setActiveRemoteImageModelId('vision-model-456'); + useRemoteServerStore.getState().setActiveRemoteImageModel('server-1', 'vision-model-456'); }); expect(useRemoteServerStore.getState().activeRemoteImageModelId).toBe('vision-model-456'); @@ -418,13 +418,13 @@ describe('remoteServerStore', () => { it('should clear active remote image model ID', () => { actStoreUpdate(() => { - useRemoteServerStore.getState().setActiveRemoteImageModelId('vision-model-456'); + useRemoteServerStore.getState().setActiveRemoteImageModel('server-1', 'vision-model-456'); }); expect(useRemoteServerStore.getState().activeRemoteImageModelId).toBe('vision-model-456'); actStoreUpdate(() => { - useRemoteServerStore.getState().setActiveRemoteImageModelId(null); + useRemoteServerStore.getState().setActiveRemoteImageModel(null, null); }); expect(useRemoteServerStore.getState().activeRemoteImageModelId).toBeNull(); @@ -496,7 +496,7 @@ describe('remoteServerStore', () => { { id: 'llava', name: 'LLaVA', serverId, capabilities: { supportsVision: true, supportsToolCalling: false, supportsThinking: false }, lastUpdated: new Date().toISOString() }, ]); useRemoteServerStore.getState().setActiveServerId(serverId); - useRemoteServerStore.getState().setActiveRemoteImageModelId('llava'); + useRemoteServerStore.getState().setActiveRemoteImageModel(serverId, 'llava'); }); const model = useRemoteServerStore.getState().getActiveRemoteImageModel(); @@ -522,7 +522,7 @@ describe('remoteServerStore', () => { providerType: 'openai-compatible', }); useRemoteServerStore.getState().setActiveRemoteTextModelId('model-1'); - useRemoteServerStore.getState().setActiveRemoteImageModelId('vision-1'); + useRemoteServerStore.getState().setActiveRemoteImageModel('srv-1', 'vision-1'); }); expect(useRemoteServerStore.getState().activeRemoteTextModelId).toBe('model-1'); diff --git a/src/components/ModelSelectorModal/ImageTab.tsx b/src/components/ModelSelectorModal/ImageTab.tsx index 10624b72a..a11029be4 100644 --- a/src/components/ModelSelectorModal/ImageTab.tsx +++ b/src/components/ModelSelectorModal/ImageTab.tsx @@ -163,8 +163,8 @@ export const ImageTab: React.FC = ({ Remote - - Vision + + Image diff --git a/src/components/ModelSelectorModal/index.tsx b/src/components/ModelSelectorModal/index.tsx index 0d8b7da87..62286e4d9 100644 --- a/src/components/ModelSelectorModal/index.tsx +++ b/src/components/ModelSelectorModal/index.tsx @@ -75,7 +75,7 @@ export const ModelSelectorModal: React.FC = ({ serverHealth, activeRemoteTextModelId, activeRemoteImageModelId, - setActiveRemoteImageModelId, + setActiveRemoteImageModel, } = useRemoteServerStore(); const [activeTab, setActiveTab] = useState(initialTab); @@ -108,20 +108,39 @@ export const ModelSelectorModal: React.FC = ({ if (visible) setActiveTab(initialTab); }, [visible, initialTab]); - // Group remote models by server for TextTab — exclude servers known to be offline - const remoteTextModels = useMemo(() => { + // Group remote models by server — exclude servers known to be offline. One pass, + // split by the modality the discovery layer tagged (the single owner of that rule). + const remoteModelGroups = useMemo(() => { return servers .filter(server => serverHealth[server.id]?.isHealthy !== false) - .map(server => ({ - serverId: server.id, - serverName: server.name, - models: discoveredModels[server.id] || [], - })).filter(group => group.models.length > 0); + .map(server => { + const models = discoveredModels[server.id] || []; + return { + serverId: server.id, + serverName: server.name, + text: models.filter(model => model.modality !== 'image'), + image: models.filter(model => model.modality === 'image'), + }; + }); }, [servers, discoveredModels, serverHealth]); - // Remote image generation models — Ollama/LM Studio don't serve image gen models. - // Vision-language models (supportsVision) are text models and belong in the text tab. - const remoteVisionModels = useMemo(() => [], []); + const remoteTextModels = useMemo( + () => + remoteModelGroups + .map(({ serverId, serverName, text }) => ({ serverId, serverName, models: text })) + .filter(group => group.models.length > 0), + [remoteModelGroups], + ); + + // Remote image-generation models (the desktop gateway tags them kind:'image'); + // generation offloads to that server, the phone receives the file. + const remoteVisionModels = useMemo( + () => + remoteModelGroups + .map(({ serverId, serverName, image }) => ({ serverId, serverName, models: image })) + .filter(group => group.models.length > 0), + [remoteModelGroups], + ); const handleSelectImageModel = async (model: ONNXImageModel) => { if (activeImageModelId === model.id) return; @@ -134,7 +153,7 @@ export const ModelSelectorModal: React.FC = ({ onAttemptStart: () => { setIsLoadingImage(true); setLoadingImageModelId(model.id); }, onAttemptEnd: () => { setIsLoadingImage(false); setLoadingImageModelId(null); }, onSuccess: () => { - setActiveRemoteImageModelId(null); // clear remote selection when selecting local + setActiveRemoteImageModel(null, null); // clear remote selection when selecting local onSelectImageModel?.(model); onSelectionComplete?.(); }, @@ -147,7 +166,7 @@ export const ModelSelectorModal: React.FC = ({ setIsLoadingImage(true); try { await activeModelService.unloadImageModel(); - setActiveRemoteImageModelId(null); + setActiveRemoteImageModel(null, null); onUnloadImageModel?.(); } catch (error) { logger.error('Failed to unload image model:', error); @@ -171,9 +190,14 @@ export const ModelSelectorModal: React.FC = ({ } }; - // Handle selecting a remote vision model + // Handle selecting a remote image model. Mirrors the remote-text flow: free the + // local image model first - generation now runs on the server, so keeping local + // diffusion weights resident only costs RAM. const handleSelectRemoteVisionModel = async (model: RemoteModel, serverId: string) => { try { + if (activeImageModelId) { + await activeModelService.unloadImageModel(); + } await remoteServerManager.setActiveRemoteImageModel(serverId, model.id); onSelectionComplete?.(); } catch (error) { diff --git a/src/screens/HomeScreen/hooks/useHomeScreen.ts b/src/screens/HomeScreen/hooks/useHomeScreen.ts index 97057e2c6..0df6b3775 100644 --- a/src/screens/HomeScreen/hooks/useHomeScreen.ts +++ b/src/screens/HomeScreen/hooks/useHomeScreen.ts @@ -78,7 +78,7 @@ export const useHomeScreen = (navigation: HomeScreenNavigationProp) => { discoveredModels: remoteDiscoveredModels, activeRemoteTextModelId, activeRemoteImageModelId, - activeServerId, + activeRemoteImageServerId, } = useRemoteServerStore(); const { @@ -235,8 +235,8 @@ export const useHomeScreen = (navigation: HomeScreenNavigationProp) => { const handleDeleteConversation = (conversation: Conversation) => deleteConversationWithAlert(conversation, setAlertState, deleteConversation); - const activeRemoteImageModel = activeRemoteImageModelId && activeServerId - ? (remoteDiscoveredModels[activeServerId] || []).find((m) => m.id === activeRemoteImageModelId) + const activeRemoteImageModel = activeRemoteImageModelId && activeRemoteImageServerId + ? (remoteDiscoveredModels[activeRemoteImageServerId] || []).find((m) => m.id === activeRemoteImageModelId) : null; const activeImageModel = activeRemoteImageModel || downloadedImageModels.find((m) => m.id === activeImageModelId) || null; @@ -244,14 +244,14 @@ export const useHomeScreen = (navigation: HomeScreenNavigationProp) => { // the ones just used, and disagrees with the Chats list and desktop. const recentConversations = mostRecentConversations(conversations, 4); - // Get all remote text models — includes vision-language models since they do text generation too - const remoteTextModels: RemoteModel[] = remoteServers.flatMap(server => + // All discovered remote models, split by the modality tagged at discovery. + // Vision-language models are text (they generate text); modality:'image' means + // the server generates images (the desktop gateway tags these via kind). + const allRemoteModels: RemoteModel[] = remoteServers.flatMap(server => remoteDiscoveredModels[server.id] || [] ); - - // Remote image generation models — Ollama/LM Studio don't serve image gen models, - // so this is intentionally empty. Vision-language models belong in remoteTextModels. - const remoteImageModels: RemoteModel[] = []; + const remoteTextModels: RemoteModel[] = allRemoteModels.filter(m => m.modality !== 'image'); + const remoteImageModels: RemoteModel[] = allRemoteModels.filter(m => m.modality === 'image'); return { pickerType, diff --git a/src/services/remoteServerManager.ts b/src/services/remoteServerManager.ts index 0369ee603..ecdf938d1 100644 --- a/src/services/remoteServerManager.ts +++ b/src/services/remoteServerManager.ts @@ -173,7 +173,7 @@ class RemoteServerManager { const store = useRemoteServerStore.getState(); store.setActiveServerId(null); store.setActiveRemoteTextModelId(null); - store.setActiveRemoteImageModelId(null); + store.setActiveRemoteImageModel(null, null); providerRegistry.setActiveProvider('local'); logger.log('[RemoteServerManager] Cleared active remote model'); } diff --git a/src/services/remoteServerManagerUtils.ts b/src/services/remoteServerManagerUtils.ts index 62f57753b..0824285cc 100644 --- a/src/services/remoteServerManagerUtils.ts +++ b/src/services/remoteServerManagerUtils.ts @@ -136,25 +136,11 @@ export async function setActiveRemoteImageModelImpl( modelId: string, ): Promise { const store = useRemoteServerStore.getState(); - store.setActiveServerId(serverId); - store.setActiveRemoteImageModelId(modelId); - - let provider = providerRegistry.getProvider(serverId); - if (!provider) { - const server = store.getServerById(serverId); - if (server) { - logger.log('[RemoteServerManager] Creating provider for server:', serverId); - await createProviderForServerImpl(server); - provider = providerRegistry.getProvider(serverId); - } - } - - if (provider) { - await provider.loadModel(modelId); - } else { - logger.warn('[RemoteServerManager] Could not create provider for server:', serverId); - } - + // Only the image selection changes. Never touch activeServerId (that would + // re-route TEXT generation to this server) and never provider.loadModel (the + // chat provider is shared - loading here overwrote the text model id). The + // diffusion engine reads the server record itself at generation time. + store.setActiveRemoteImageModel(serverId, modelId); logger.log('[RemoteServerManager] Active remote image model set:', serverId, modelId); } diff --git a/src/stores/remoteServerHelpers.ts b/src/stores/remoteServerHelpers.ts index 4b31bbd56..1ad8deba4 100644 --- a/src/stores/remoteServerHelpers.ts +++ b/src/stores/remoteServerHelpers.ts @@ -34,6 +34,21 @@ function isTextModel(model: { id?: string; name?: string; kind?: unknown }): boo return isGenerativeModel(model.id ?? model.name ?? ''); } +/** + * The single owner of "what does this remote model generate". 'image' only when the + * gateway says so via kind - name heuristics never promote a model to image, so + * Ollama/LM Studio entries (no kind) stay text. null = not servable from the picker + * (speech/transcription/embedding). + */ +function remoteModelModality(model: { + id?: string; + name?: string; + kind?: unknown; +}): 'text' | 'image' | null { + if (model.kind === 'image') return 'image'; + return isTextModel(model) ? 'text' : null; +} + const MODEL_FILE_EXT = /\.(gguf|bin|safetensors|task|litertlm|pte)$/i; /** @@ -170,16 +185,26 @@ export async function fetchModelsFromServer(server: RemoteServer): Promise isTextModel(model)); + const servable = data.data + .map((model: { id: string; kind?: unknown }) => ({ + model, + modality: remoteModelModality(model), + })) + .filter((entry: { modality: 'text' | 'image' | null }) => entry.modality !== null); + // Capability probing is a chat-endpoint conversation - meaningless for a + // diffusion model, so image entries skip it (empty caps, modality tells all). const modelInfos = await Promise.all( - generativeModels.map((model: { id: string }) => - fetchModelCapabilities(url, model.id, nameDetect) + servable.map(({ model, modality }: { model: { id: string }; modality: string }) => + modality === 'image' + ? Promise.resolve({} as Awaited>) + : fetchModelCapabilities(url, model.id, nameDetect) ) ); - return generativeModels.map((model: { id: string; kind?: unknown; owned_by?: string; max_context_length?: number }, i: number) => ({ + return servable.map(({ model, modality }: { model: { id: string; kind?: unknown }; modality: 'text' | 'image' }, i: number) => ({ id: model.id, name: displayModelName(model.id), serverId: server.id, + modality, capabilities: { // The gateway declares each model's kind authoritatively; trust kind:'vision' // for vision support. The name/probe-based fallback (modelInfos) can't detect a diff --git a/src/stores/remoteServerStore.ts b/src/stores/remoteServerStore.ts index 7ace7ac97..cd9872c42 100644 --- a/src/stores/remoteServerStore.ts +++ b/src/stores/remoteServerStore.ts @@ -38,8 +38,12 @@ interface RemoteServerState { /** Active remote text model ID (when using remote for text generation) */ activeRemoteTextModelId: string | null; - /** Active remote image/vision model ID (when using remote for vision) */ + /** Active remote image-generation model ID (offloads image gen to that server) */ activeRemoteImageModelId: string | null; + /** The server the active remote image model lives on. Its own field - the image + * model must not ride the shared activeServerId, or picking an image model on + * one server would silently re-route text generation on another. */ + activeRemoteImageServerId: string | null; // Server CRUD addServer: (server: Omit) => string; @@ -52,7 +56,8 @@ interface RemoteServerState { // Active remote model selection setActiveRemoteTextModelId: (id: string | null) => void; - setActiveRemoteImageModelId: (id: string | null) => void; + /** Set (or clear, with nulls) the remote image model + the server it lives on. */ + setActiveRemoteImageModel: (serverId: string | null, modelId: string | null) => void; getActiveRemoteTextModel: () => RemoteModel | null; getActiveRemoteImageModel: () => RemoteModel | null; @@ -85,6 +90,7 @@ export const useRemoteServerStore = create()( discoveringServerId: null, activeRemoteTextModelId: null, activeRemoteImageModelId: null, + activeRemoteImageServerId: null, // Server CRUD addServer: (serverData) => { @@ -118,6 +124,7 @@ export const useRemoteServerStore = create()( activeServerId: null, activeRemoteTextModelId: null, activeRemoteImageModelId: null, + activeRemoteImageServerId: null, }); } set((prev) => ({ @@ -149,9 +156,15 @@ export const useRemoteServerStore = create()( logger.log('[RemoteServer] Active remote text model set to:', id || 'none'); }, - setActiveRemoteImageModelId: (id) => { - set({ activeRemoteImageModelId: id }); - logger.log('[RemoteServer] Active remote image model set to:', id || 'none'); + setActiveRemoteImageModel: (serverId, modelId) => { + set({ + activeRemoteImageServerId: modelId ? serverId : null, + activeRemoteImageModelId: modelId, + }); + logger.log( + '[RemoteServer] Active remote image model set to:', + modelId ? `${serverId ?? 'unknown-server'}/${modelId}` : 'none', + ); }, getActiveRemoteTextModel: () => { @@ -162,9 +175,9 @@ export const useRemoteServerStore = create()( }, getActiveRemoteImageModel: () => { - const { activeRemoteImageModelId, activeServerId, discoveredModels } = get(); - if (!activeRemoteImageModelId || !activeServerId) return null; - const models = discoveredModels[activeServerId] || []; + const { activeRemoteImageModelId, activeRemoteImageServerId, discoveredModels } = get(); + if (!activeRemoteImageModelId || !activeRemoteImageServerId) return null; + const models = discoveredModels[activeRemoteImageServerId] || []; return models.find((m) => m.id === activeRemoteImageModelId) || null; }, @@ -305,6 +318,7 @@ export const useRemoteServerStore = create()( serverHealth: {}, activeRemoteTextModelId: null, activeRemoteImageModelId: null, + activeRemoteImageServerId: null, }); }, }), @@ -316,6 +330,7 @@ export const useRemoteServerStore = create()( activeServerId: state.activeServerId, activeRemoteTextModelId: state.activeRemoteTextModelId, activeRemoteImageModelId: state.activeRemoteImageModelId, + activeRemoteImageServerId: state.activeRemoteImageServerId, discoveredModels: state.discoveredModels, // Don't persist health status - it should be refreshed }), diff --git a/src/types/remoteServer.ts b/src/types/remoteServer.ts index f2ac32f13..d9e547da8 100644 --- a/src/types/remoteServer.ts +++ b/src/types/remoteServer.ts @@ -40,6 +40,9 @@ export interface RemoteModel { serverId: string; /** Model capabilities */ capabilities: RemoteModelCapabilities; + /** What the model generates. Absent = 'text' (the historical default, so + * persisted records from before image support keep working). */ + modality?: 'text' | 'image'; /** Model details from provider */ details?: Record; /** When this model info was last refreshed */ From 39976f47dba2feb7eb7f420ad3b42a3efbb6291c Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Wed, 26 Aug 2026 13:42:59 +0530 Subject: [PATCH 15/18] feat(imagegen): remote engine - offload generation to a gateway server remoteImageGenerator POSTs /v1/images/generations in async mode, polls for stage/step (a Wi-Fi blip or app backgrounding costs nothing - the result waits at the poll URL), maps 429/401/501 to plain-language failures, and writes the returned image to a local file named by the server's sync_id so a paired Mac's synced copy is the same image, not a duplicate. Engine selection lives with the store's remote-image selection; everything above the seam (phases, progress card, cancel, chat attach, gallery, share) is unchanged. Local-only structural moves (ensureImageModelLoaded, generation numbers) keep the service inside its line/complexity budgets. Co-Authored-By: Claude Fable 5 --- src/services/imageGenerationHelpers.ts | 24 +++ src/services/imageGenerationLocalLoad.ts | 59 +++++++ src/services/imageGenerationRemote.ts | 117 +++++++++++++ src/services/imageGenerationService.ts | 121 ++++++-------- src/services/remoteImageGenerator.ts | 202 +++++++++++++++++++++++ 5 files changed, 453 insertions(+), 70 deletions(-) create mode 100644 src/services/imageGenerationLocalLoad.ts create mode 100644 src/services/imageGenerationRemote.ts create mode 100644 src/services/remoteImageGenerator.ts diff --git a/src/services/imageGenerationHelpers.ts b/src/services/imageGenerationHelpers.ts index 34fb50acd..7ca7d614b 100644 --- a/src/services/imageGenerationHelpers.ts +++ b/src/services/imageGenerationHelpers.ts @@ -10,6 +10,7 @@ import { maybeScheduleSharePrompt } from '../utils/sharePrompt'; import { reportModelFailure } from './modelFailureHandler'; import { checkProPromptForImage } from './proPrompt'; import type { ImageGenerationState } from './imageGenerationTypes'; +import { SWEET_SPOT_SIZE, DEFAULT_IMAGE_GUIDANCE, defaultImageSteps } from '../utils/imageGenAdvice'; export function imagePhaseTransitionLog( previous: ImageGenerationState['phase'], @@ -205,3 +206,26 @@ export function buildImageGenMeta( resolution: `${opts.result.width}x${opts.result.height}`, }; } + +/** Resolve the effective generation numbers from the request + persisted settings. + * Width/height floor to 256: SD-class models render garbage (incoherent, not + * "smaller") below it, so a stale sub-256 setting must never reach a pipeline - + * local or remote. One owner for both engines. */ +export function resolveGenerationNumbers( + params: { steps?: number; guidanceScale?: number }, + settings: { + imageSteps?: number; + imageGuidanceScale?: number; + imageWidth?: number; + imageHeight?: number; + }, + platform: string, +): { steps: number; guidanceScale: number; imageWidth: number; imageHeight: number } { + return { + steps: params.steps || settings.imageSteps || defaultImageSteps(platform), + guidanceScale: + params.guidanceScale || settings.imageGuidanceScale || DEFAULT_IMAGE_GUIDANCE, + imageWidth: Math.max(SWEET_SPOT_SIZE, settings.imageWidth || SWEET_SPOT_SIZE), + imageHeight: Math.max(SWEET_SPOT_SIZE, settings.imageHeight || SWEET_SPOT_SIZE), + }; +} diff --git a/src/services/imageGenerationLocalLoad.ts b/src/services/imageGenerationLocalLoad.ts new file mode 100644 index 000000000..0cc005fcd --- /dev/null +++ b/src/services/imageGenerationLocalLoad.ts @@ -0,0 +1,59 @@ +import { activeModelService } from './activeModelService'; +import { localDreamGeneratorService as onnxImageGeneratorService } from './localDreamGenerator'; +import type { ActiveImageModel, ImageGenerationState } from './imageGenerationTypes'; + +/** The local engine's load step, extracted whole from imageGenerationService (the + * service is at its structural line budget). Behavior unchanged. */ +export interface LocalLoadHost { + updateState: (partial: Partial) => void; + fail: (message: string, opts?: { cause?: unknown }) => void; +} + +export interface LocalLoadRequest { + activeImageModelId: string | null; + activeImageModel: ActiveImageModel; + desiredThreads: number; + override?: boolean; +} + +export async function ensureImageModelLoaded( + host: LocalLoadHost, + request: LocalLoadRequest, +): Promise { + const { activeImageModelId, activeImageModel } = request; + const opts = { desiredThreads: request.desiredThreads, override: request.override }; + const isImageModelLoaded = await onnxImageGeneratorService.isModelLoaded(); + const loadedPath = await onnxImageGeneratorService.getLoadedModelPath(); + const loadedThreads = onnxImageGeneratorService.getLoadedThreads(); + const needsThreadReload = + loadedThreads == null || loadedThreads !== opts.desiredThreads; + if ( + isImageModelLoaded && + loadedPath === activeImageModel.modelPath && + !needsThreadReload + ) + return true; + if (!activeImageModelId) { + host.fail('No image model selected'); + return false; + } + try { + host.updateState({ + phase: 'loading', + status: `Loading ${activeImageModel.name}...`, + }); + await activeModelService.loadImageModel( + activeImageModelId, + undefined, + opts.override ? { override: true } : undefined, + ); + return true; + } catch (error: any) { + // Pass the TYPED error as `cause` — an OverridableMemoryError here is what lets + // the failure card offer "Load Anyway". Stringifying it (as before) hid it. + host.fail(`Failed to load image model: ${error?.message || 'Unknown error'}`, { + cause: error, + }); + return false; + } +} diff --git a/src/services/imageGenerationRemote.ts b/src/services/imageGenerationRemote.ts new file mode 100644 index 000000000..02987bf20 --- /dev/null +++ b/src/services/imageGenerationRemote.ts @@ -0,0 +1,117 @@ +import { useRemoteServerStore } from '../stores'; +import type { GeneratedImage, RemoteModel, RemoteServer } from '../types'; +import logger from '../utils/logger'; +import { + completedImageGenerationState, + saveImageGenerationResult, +} from './imageGenerationResult'; +import type { GenerateImageParams, ImageGenerationState } from './imageGenerationTypes'; +import { remoteImageGeneratorService } from './remoteImageGenerator'; + +/** + * The remote half of image generation, kept out of imageGenerationService so the + * service stays within its structural budget: resolving whether a remote engine + * is selected, and running a generation against it through the same phase + * machine, save path and chat attach as the local engine. + */ + +export interface RemoteImageEngine { + model: RemoteModel; + server: RemoteServer; +} + +/** The store's selection is the single owner of which engine runs a request. + * null = no remote image model selected (or its server record is gone). */ +export function resolveRemoteImageEngine(): RemoteImageEngine | null { + const store = useRemoteServerStore.getState(); + const model = store.getActiveRemoteImageModel(); + if (!model) return null; + const server = store.getServerById(model.serverId); + return server ? { model, server } : null; +} + +export interface RemoteRunHost { + updateState: (partial: Partial) => void; + resetState: () => void; + fail: (message: string) => void; + isCancelRequested: () => boolean; +} + +export interface RemoteRunOptions { + params: GenerateImageParams; + enhancedPrompt: string; + remote: RemoteImageEngine; + messageId: string | null; + steps: number; + guidanceScale: number; + imageWidth: number; + imageHeight: number; +} + +/** Same contract as the local runner: resolves the saved GeneratedImage, or null + * after cancel/failure (the host's state already reflects which). The server may + * run its own prompt-enhancement pass per its settings. */ +export async function runRemoteGenerationAndSave( + host: RemoteRunHost, + opts: RemoteRunOptions, +): Promise { + const { params, enhancedPrompt, remote, steps, guidanceScale, imageWidth, imageHeight } = opts; + host.updateState({ + phase: 'generating', + status: `Generating on ${remote.server.name}...`, + }); + const startTime = Date.now(); + try { + const result = await remoteImageGeneratorService.generateImage( + { + endpoint: remote.server.endpoint, + apiKey: remote.server.apiKey, + model: remote.model.id, + prompt: enhancedPrompt, + negativePrompt: params.negativePrompt, + steps, + guidanceScale, + seed: params.seed, + width: imageWidth, + height: imageHeight, + }, + progress => { + if (host.isCancelRequested()) return; + if (typeof progress.step === 'number' && typeof progress.total === 'number') { + const displayStep = Math.min(progress.step, progress.total); + host.updateState({ + progress: { step: displayStep, totalSteps: progress.total }, + status: `Generating on ${remote.server.name} (${displayStep}/${progress.total})...`, + }); + } + }, + ); + if (host.isCancelRequested() || !result?.imagePath) { + host.resetState(); + return null; + } + host.updateState(completedImageGenerationState(result)); + return saveImageGenerationResult(result, { + params, + activeImageModel: { + id: remote.model.id, + name: remote.model.name, + modelPath: '', + }, + messageId: opts.messageId, + steps, + guidanceScale, + useOpenCL: false, + startTime, + }); + } catch (error: any) { + const message = error?.message || 'Image generation failed'; + if (message.includes('cancelled')) { + host.resetState(); + } else { + logger.error('[ImageGeneration] Remote generation error:', error); + host.fail(message); + } + return null; + } +} diff --git a/src/services/imageGenerationService.ts b/src/services/imageGenerationService.ts index 382a9b329..5f6015a21 100644 --- a/src/services/imageGenerationService.ts +++ b/src/services/imageGenerationService.ts @@ -1,18 +1,20 @@ import { localDreamGeneratorService as onnxImageGeneratorService } from './localDreamGenerator'; +import { remoteImageGeneratorService } from './remoteImageGenerator'; +import { + resolveRemoteImageEngine, + runRemoteGenerationAndSave, +} from './imageGenerationRemote'; +import { ensureImageModelLoaded } from './imageGenerationLocalLoad'; import { activeModelService } from './activeModelService'; import { useAppStore } from '../stores'; import { GeneratedImage } from '../types'; import logger from '../utils/logger'; import { generateId } from '../utils/generateId'; -import { - SWEET_SPOT_SIZE, - DEFAULT_IMAGE_GUIDANCE, - defaultImageSteps, -} from '../utils/imageGenAdvice'; import { Platform } from 'react-native'; import { generationProgressStatus, imagePhaseTransitionLog, + resolveGenerationNumbers, } from './imageGenerationHelpers'; import { enhanceImagePrompt } from './imagePromptEnhancement'; import { @@ -27,7 +29,6 @@ import { ImageGenerationState, ImageGenerationListener, GenerateImageParams, - ActiveImageModel, RunGenerationOptions, } from './imageGenerationTypes'; @@ -190,48 +191,6 @@ class ImageGenerationService { ); } - private async _ensureImageModelLoaded( - activeImageModelId: string | null, - activeImageModel: ActiveImageModel, - opts: { desiredThreads: number; override?: boolean }, - ): Promise { - const isImageModelLoaded = await onnxImageGeneratorService.isModelLoaded(); - const loadedPath = await onnxImageGeneratorService.getLoadedModelPath(); - const loadedThreads = onnxImageGeneratorService.getLoadedThreads(); - const needsThreadReload = - loadedThreads == null || loadedThreads !== opts.desiredThreads; - if ( - isImageModelLoaded && - loadedPath === activeImageModel.modelPath && - !needsThreadReload - ) - return true; - if (!activeImageModelId) { - this._fail('No image model selected'); - return false; - } - try { - this.updateState({ - phase: 'loading', - status: `Loading ${activeImageModel.name}...`, - }); - await activeModelService.loadImageModel( - activeImageModelId, - undefined, - opts.override ? { override: true } : undefined, - ); - return true; - } catch (error: any) { - // Pass the TYPED error as `cause` — an OverridableMemoryError here is what lets - // the failure card offer "Load Anyway". Stringifying it (as before) hid it. - this._fail( - `Failed to load image model: ${error?.message || 'Unknown error'}`, - { cause: error }, - ); - return false; - } - } - private async _runGenerationAndSave( opts: RunGenerationOptions, ): Promise { @@ -371,27 +330,14 @@ class ImageGenerationService { const activeImageModel = downloadedImageModels.find( m => m.id === activeImageModelId, ); - if (!activeImageModel) return this._fail('No image model selected'); + // A remote image model (the desktop gateway) is an engine like the local one. + const remote = resolveRemoteImageEngine(); + if (!activeImageModel && !remote) return this._fail('No image model selected'); const messageId = params.conversationId ? generateId() : null; - const steps = - params.steps || settings.imageSteps || defaultImageSteps(Platform.OS); - const guidanceScale = - params.guidanceScale || - settings.imageGuidanceScale || - DEFAULT_IMAGE_GUIDANCE; - // Floor to 256: SD-class models render garbage (incoherent, not "smaller") below 256, - // so a stale sub-256 setting must never reach the pipeline. The slider min is also 256; - // this guards the persisted-value + programmatic paths so the user never sees garbage. - const imageWidth = Math.max( - SWEET_SPOT_SIZE, - settings.imageWidth || SWEET_SPOT_SIZE, - ); - const imageHeight = Math.max( - SWEET_SPOT_SIZE, - settings.imageHeight || SWEET_SPOT_SIZE, - ); + const { steps, guidanceScale, imageWidth, imageHeight } = + resolveGenerationNumbers(params, settings, Platform.OS); this.updateState({ phase: settings.enhanceImagePrompts ? 'enhancing' : 'loading', @@ -436,10 +382,43 @@ class ImageGenerationService { result: null, }); - const loaded = await this._ensureImageModelLoaded( - activeImageModelId, - activeImageModel, - { desiredThreads: settings.imageThreads ?? 4, override: opts?.override }, + if (remote) { + return runRemoteGenerationAndSave( + { + updateState: partial => this.updateState(partial), + resetState: () => this.resetState(), + fail: message => { + this._fail(message); + }, + isCancelRequested: () => this.cancelRequested, + }, + { + params, + enhancedPrompt, + remote, + messageId: this.state.messageId, + steps, + guidanceScale, + imageWidth, + imageHeight, + }, + ); + } + + if (!activeImageModel) return this._fail('No image model selected'); + const loaded = await ensureImageModelLoaded( + { + updateState: partial => this.updateState(partial), + fail: (message, failOpts) => { + this._fail(message, failOpts); + }, + }, + { + activeImageModelId, + activeImageModel, + desiredThreads: settings.imageThreads ?? 4, + override: opts?.override, + }, ); if (!loaded) return null; if (this.cancelRequested) { @@ -472,6 +451,8 @@ class ImageGenerationService { error: null, }); try { + // Both engines: the local native pipeline and any in-flight remote poll. + await remoteImageGeneratorService.cancelGeneration(); await onnxImageGeneratorService.cancelGeneration(); } catch { /* Ignore */ diff --git a/src/services/remoteImageGenerator.ts b/src/services/remoteImageGenerator.ts new file mode 100644 index 000000000..7c2b9457b --- /dev/null +++ b/src/services/remoteImageGenerator.ts @@ -0,0 +1,202 @@ +import RNFS from 'react-native-fs'; +import type { GeneratedImage } from '../types'; +import { generateId } from '../utils/generateId'; +import logger from '../utils/logger'; + +/** + * Remote image generation: offload a diffusion run to an OpenAI-compatible + * server (the Off Grid AI Desktop gateway serves POST /v1/images/generations) + * and land the result as a local file, so everything above this engine - the + * progress card, chat attach, gallery record, mesh publication - keeps working + * exactly as it does for on-device generation. + * + * The request opts into the gateway's async mode: a 202 + poll_url comes back + * immediately and the phone polls for phase/step. Because the finished result + * is held server-side at the poll URL, a dropped connection mid-run costs + * nothing - polling resumes and still finds the image. + */ + +export interface RemoteImageRequest { + /** Server base endpoint, e.g. http://192.168.1.50:7878 */ + endpoint: string; + /** Optional bearer (the per-device token when the server is a paired Mac). */ + apiKey?: string; + /** Model id on the server. */ + model: string; + prompt: string; + negativePrompt?: string; + steps: number; + guidanceScale: number; + seed?: number; + width: number; + height: number; +} + +export interface RemoteImageProgress { + stage?: string; + step?: number; + total?: number; +} + +const POLL_INTERVAL_MS = 1000; +/** A desktop diffusion run is O(minutes); past this the job is presumed lost. */ +const OVERALL_DEADLINE_MS = 10 * 60_000; +/** Transient poll failures tolerated before giving up (Wi-Fi blips). */ +const MAX_POLL_FAILURES = 30; +const REQUEST_TIMEOUT_MS = 15_000; + +const OUTPUT_DIR = `${RNFS.DocumentDirectoryPath}/generated-images`; + +function baseUrl(endpoint: string): string { + let url = endpoint; + while (url.endsWith('/')) url = url.slice(0, -1); + return url; +} + +async function fetchJson( + url: string, + init: RequestInit, +): Promise<{ status: number; body: Record }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + let body: Record = {}; + try { + body = await response.json(); + } catch { + /* non-JSON error body - status carries the meaning */ + } + return { status: response.status, body }; + } finally { + clearTimeout(timeout); + } +} + +/** Map an HTTP failure to the message the failure card shows. */ +function requestError(status: number, body: Record): Error { + const serverMessage = body?.error?.message; + if (status === 429) { + return new Error( + serverMessage || 'The server is generating another image - try again in a moment.', + ); + } + if (status === 401) { + return new Error('The server rejected this device. Re-pair it and try again.'); + } + if (status === 501) { + return new Error(serverMessage || 'The server has no image model installed.'); + } + return new Error(serverMessage || `Image request failed (HTTP ${status}).`); +} + +class RemoteImageGeneratorService { + private cancelled = false; + + /** Stop polling. The server finishes its run either way; if it is a paired + * Mac the image still syncs over when done. */ + async cancelGeneration(): Promise { + this.cancelled = true; + return true; + } + + async generateImage( + request: RemoteImageRequest, + onProgress?: (progress: RemoteImageProgress) => void, + ): Promise { + this.cancelled = false; + const base = baseUrl(request.endpoint); + const headers: Record = { 'Content-Type': 'application/json' }; + if (request.apiKey) headers.Authorization = `Bearer ${request.apiKey}`; + + const submit = await fetchJson(`${base}/v1/images/generations`, { + method: 'POST', + headers, + body: JSON.stringify({ + prompt: request.prompt, + negative_prompt: request.negativePrompt || undefined, + model: request.model, + width: request.width, + height: request.height, + steps: request.steps, + cfg_scale: request.guidanceScale, + seed: request.seed, + response_format: 'b64_json', + async: true, + }), + }); + if (submit.status !== 202) throw requestError(submit.status, submit.body); + const pollPath = String(submit.body.poll_url || ''); + if (!pollPath) throw new Error('The server accepted the job but returned no poll URL.'); + logger.log('[RemoteImage] job accepted:', submit.body.request_id); + + const result = await this.poll(`${base}${pollPath}`, headers, onProgress); + return this.writeResult(request, result); + } + + private async poll( + pollUrl: string, + headers: Record, + onProgress?: (progress: RemoteImageProgress) => void, + ): Promise> { + const deadline = Date.now() + OVERALL_DEADLINE_MS; + let failures = 0; + for (;;) { + if (this.cancelled) throw new Error('cancelled'); + if (Date.now() > deadline) throw new Error('The server took too long to respond.'); + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); + if (this.cancelled) throw new Error('cancelled'); + let status = 0; + let body: Record = {}; + try { + ({ status, body } = await fetchJson(pollUrl, { method: 'GET', headers })); + failures = 0; + } catch (error) { + // A Wi-Fi blip must not lose a job the server is still running. + failures += 1; + if (failures > MAX_POLL_FAILURES) throw error; + continue; + } + if (status === 404) throw new Error('The server no longer knows this job.'); + if (status >= 400) throw requestError(status, body); + if (body.status === 'failed') { + throw new Error(body?.error?.message || 'The server failed to generate the image.'); + } + if (body.status === 'completed') return body.result || {}; + if (body.progress && onProgress) onProgress(body.progress as RemoteImageProgress); + } + } + + private async writeResult( + request: RemoteImageRequest, + result: Record, + ): Promise { + const datum = Array.isArray(result.data) ? result.data[0] : null; + const b64 = datum?.b64_json; + if (typeof b64 !== 'string' || b64.length === 0) { + throw new Error('The server returned no image data.'); + } + await RNFS.mkdir(OUTPUT_DIR); + // The mesh identity when the server minted one - using it as the local id + // means the synced copy arriving from a paired Mac is the SAME image, not a + // duplicate gallery entry. + const id = typeof datum.sync_id === 'string' && datum.sync_id ? datum.sync_id : generateId(); + const imagePath = `${OUTPUT_DIR}/img-${id}.png`; + await RNFS.writeFile(imagePath, b64, 'base64'); + logger.log('[RemoteImage] saved:', imagePath); + return { + id, + prompt: request.prompt, + negativePrompt: request.negativePrompt, + imagePath, + width: request.width, + height: request.height, + steps: request.steps, + seed: typeof datum.seed === 'number' ? datum.seed : request.seed ?? 0, + modelId: typeof datum.model === 'string' ? datum.model : request.model, + createdAt: new Date().toISOString(), + }; + } +} + +export const remoteImageGeneratorService = new RemoteImageGeneratorService(); From 19126e38a002e1a9380a3856fec8e713b97d266c Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Wed, 26 Aug 2026 16:34:01 +0530 Subject: [PATCH 16/18] fix(chat): route image turns to the remote image model too The router derived 'is an image model selected' from downloaded LOCAL models only, while the generation service also counts the remote desktop selection - two owners of one fact, so a remote-only image model sent image prompts down the text route ('I am a text model'). The chat's activeImageModel now composes from the same store selection the engine routes on; found live on-device (iPhone, first offload attempt). Co-Authored-By: Claude Fable 5 --- src/screens/ChatScreen/useChatScreen.ts | 30 +++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/screens/ChatScreen/useChatScreen.ts b/src/screens/ChatScreen/useChatScreen.ts index 4c94187a5..f57c848f3 100644 --- a/src/screens/ChatScreen/useChatScreen.ts +++ b/src/screens/ChatScreen/useChatScreen.ts @@ -53,6 +53,30 @@ export { computePendingSettings } from './pendingSettings'; type ChatScreenRouteProp = RouteProp; +type RemoteImageModelSlice = ReturnType< + typeof useRemoteServerStore.getState +>; + +const selectActiveRemoteImageModel = ( + state: RemoteImageModelSlice, +): { id: string; name: string } | null => + state.activeRemoteImageModelId && state.activeRemoteImageServerId + ? (state.discoveredModels[state.activeRemoteImageServerId] || []).find( + m => m.id === state.activeRemoteImageModelId, + ) ?? null + : null; + +/** One answer to "is an image engine selected": the remote desktop model counts + * exactly like a local one - the same store selection the generation service + * routes on, so the chat router and the engine can never disagree. */ +function composeActiveImageModel( + local: { id: string; name: string } | undefined, + remote: { id: string; name: string } | null, +): { id: string; name: string; isRemote?: true } | undefined { + if (local) return local; + return remote ? { id: remote.id, name: remote.name, isRemote: true } : undefined; +} + export const useChatScreen = () => { const navigation = useNavigation>(); const route = useRoute(); @@ -189,8 +213,10 @@ export const useChatScreen = () => { const activeProject = effectiveProjectId ? getProject(effectiveProjectId) : null; - const activeImageModel = downloadedImageModels.find( - m => m.id === activeImageModelId, + const activeRemoteImageModel = useRemoteServerStore(selectActiveRemoteImageModel); + const activeImageModel = composeActiveImageModel( + downloadedImageModels.find(m => m.id === activeImageModelId), + activeRemoteImageModel, ); const imageModelLoaded = !!activeImageModel; const isGeneratingImage = imageGenState.isGenerating; From d5af873bbe633909b97b80eca90873f10e90d1b3 Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Wed, 26 Aug 2026 16:55:12 +0530 Subject: [PATCH 17/18] test: update remote-model contracts for image modality Discovery now keeps kind:'image' models (tagged) instead of dropping them; image selection is store-only with its own server field (no provider load, no shared activeServerId write); the home hook reads the image server field. Co-Authored-By: Claude Fable 5 --- .../integration/generation/unifiedModelSelection.test.ts | 8 ++++++-- .../integration/stores/remoteServerDiscovery.test.ts | 8 +++++--- __tests__/unit/hooks/useHomeScreen.test.ts | 3 ++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/__tests__/integration/generation/unifiedModelSelection.test.ts b/__tests__/integration/generation/unifiedModelSelection.test.ts index 7449fdac9..9ae856461 100644 --- a/__tests__/integration/generation/unifiedModelSelection.test.ts +++ b/__tests__/integration/generation/unifiedModelSelection.test.ts @@ -198,8 +198,12 @@ describe('Unified Model Selection', () => { await remoteServerManager.setActiveRemoteImageModel(serverId, 'llava'); expect(useRemoteServerStore.getState().activeRemoteImageModelId).toBe('llava'); - expect(useRemoteServerStore.getState().activeServerId).toBe(serverId); - expect(mockLoadModel).toHaveBeenCalledWith('llava'); + // The image selection carries its OWN server field and never re-routes text: + // the shared activeServerId stays untouched, and the shared chat provider is + // never loaded (doing so overwrote the text model id - the clobber bug). + expect(useRemoteServerStore.getState().activeRemoteImageServerId).toBe(serverId); + expect(useRemoteServerStore.getState().activeServerId).not.toBe(serverId); + expect(mockLoadModel).not.toHaveBeenCalled(); }); }); diff --git a/__tests__/integration/stores/remoteServerDiscovery.test.ts b/__tests__/integration/stores/remoteServerDiscovery.test.ts index 102151371..74b87fe68 100644 --- a/__tests__/integration/stores/remoteServerDiscovery.test.ts +++ b/__tests__/integration/stores/remoteServerDiscovery.test.ts @@ -537,7 +537,7 @@ describe('remoteServerDiscovery integration', () => { // ========================================================================= describe('gateway kind filtering', () => { - it('keeps only chat/vision models and drops image, speech, and transcription', async () => { + it('keeps chat/vision as text and image as image; drops speech and transcription', async () => { addServer({ id: 'srv-gw', endpoint: 'http://192.168.1.44:7878' }); // NOSONAR mockFetch.mockImplementation((url: string) => { @@ -562,8 +562,10 @@ describe('remoteServerDiscovery integration', () => { const models = await useRemoteServerStore.getState().discoverModels('srv-gw'); const ids = models.map((m) => m.id).sort((a, b) => a.localeCompare(b)); - expect(ids).toEqual(['gemma-3', 'qwen3-vl']); - expect(models.some((m) => m.id === 'sdxl')).toBe(false); + expect(ids).toEqual(['gemma-3', 'qwen3-vl', 'sdxl']); + // Image models ride the same discovery, tagged so the pickers split on modality. + expect(models.find((m) => m.id === 'sdxl')?.modality).toBe('image'); + expect(models.find((m) => m.id === 'gemma-3')?.modality).toBe('text'); expect(models.some((m) => m.id === 'kokoro')).toBe(false); expect(models.some((m) => m.id === 'whisper-base')).toBe(false); }); diff --git a/__tests__/unit/hooks/useHomeScreen.test.ts b/__tests__/unit/hooks/useHomeScreen.test.ts index 2d4956faa..6317e07fa 100644 --- a/__tests__/unit/hooks/useHomeScreen.test.ts +++ b/__tests__/unit/hooks/useHomeScreen.test.ts @@ -415,7 +415,8 @@ describe('useHomeScreen', () => { discoveredModels: { 'server-1': [remoteImgModel] }, activeRemoteTextModelId: null, activeRemoteImageModelId: 'img-remote-1', - activeServerId: 'server-1', + activeRemoteImageServerId: 'server-1', + activeServerId: null, }; return sel ? sel(st) : st; }); const { result } = renderHook(() => useHomeScreen(mockNavigation)); expect(result.current.activeImageModel).toEqual(remoteImgModel); From 5c8481ad022b2fcd14687df73215e8b5ff05cebe Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Wed, 26 Aug 2026 17:08:04 +0530 Subject: [PATCH 18/18] chore: unexport module-internal remote image types (knip gate) Co-Authored-By: Claude Fable 5 --- src/services/remoteImageGenerator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/remoteImageGenerator.ts b/src/services/remoteImageGenerator.ts index 7c2b9457b..2977664f2 100644 --- a/src/services/remoteImageGenerator.ts +++ b/src/services/remoteImageGenerator.ts @@ -16,7 +16,7 @@ import logger from '../utils/logger'; * nothing - polling resumes and still finds the image. */ -export interface RemoteImageRequest { +interface RemoteImageRequest { /** Server base endpoint, e.g. http://192.168.1.50:7878 */ endpoint: string; /** Optional bearer (the per-device token when the server is a paired Mac). */ @@ -32,7 +32,7 @@ export interface RemoteImageRequest { height: number; } -export interface RemoteImageProgress { +interface RemoteImageProgress { stage?: string; step?: number; total?: number;