diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6abfafee3..29d3773cf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -193,11 +193,22 @@ jobs:
- name: Run Jest tests
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
env:
- # The serial RN + jsdom suite retains more than Node's default 2 GB heap before
- # coverage is written. The macOS runner has 14 GB; keep enough headroom for native
- # tooling while allowing the complete suite to finish without weakening the gate.
- NODE_OPTIONS: --max-old-space-size=8192
- run: npx jest --coverage --forceExit --runInBand
+ # Serial, but in ONE recycled worker instead of --runInBand.
+ #
+ # In band, every suite runs in the same process and the heap left after each suite only
+ # grows: measured locally on 2dbf3e75, it climbs from ~0.4 GB after the first suite to
+ # ~6.3 GB after the last, with a 7.1 GB peak RSS. The macos-26-arm64 runner has 7 GB of
+ # RAM, so the back half of the run swaps. That is why CI throughput collapses (100
+ # suites took ~3 min early in run 37503803398 and ~19 min at the end) and why a
+ # different set of late, otherwise fast tests times out on every run, on main too.
+ #
+ # --maxWorkers=1 keeps the run serial (the parallel run flaked on CPU contention, see
+ # b18e65f5). --workerIdleMemoryLimit replaces that worker once its heap passes 1 GB
+ # after a suite, so memory stays bounded for the whole run. The worker heap cap only
+ # has to fit one worker's lifetime now, not the whole suite, so it sits well below
+ # the runner's RAM and V8 collects before the box starts swapping.
+ NODE_OPTIONS: --max-old-space-size=4096
+ run: npx jest --coverage --forceExit --maxWorkers=1 --workerIdleMemoryLimit=1GB
- name: Install Android NDK
# Configuring the native modules (op-sqlite etc.) needs the pinned NDK; the macOS
@@ -212,15 +223,20 @@ jobs:
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: cd android && ./gradlew :app:testDebugUnitTest --rerun-tasks
+ - name: Setup Ruby for iOS dependencies
+ if: ${{ !cancelled() && steps.install.outcome == 'success' }}
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '3.3'
+ bundler-cache: true
+
+ - name: Install iOS dependencies
+ if: ${{ !cancelled() && steps.install.outcome == 'success' }}
+ run: bundle exec pod install --project-directory=ios
+
- name: Run iOS tests
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
- run: |
- cd ios && xcodebuild test \
- -workspace OffgridMobile.xcworkspace \
- -scheme OffgridMobile \
- -destination 'platform=iOS Simulator,name=iPhone 16' \
- -only-testing:OffgridMobileTests \
- 2>&1 | (xcpretty 2>/dev/null || cat)
+ run: npm run test:ios
- name: Upload coverage to Codecov
if: always()
diff --git a/.github/workflows/release-ios.yml b/.github/workflows/release-ios.yml
index 1c5a295dc..2207bc63a 100644
--- a/.github/workflows/release-ios.yml
+++ b/.github/workflows/release-ios.yml
@@ -31,12 +31,45 @@ jobs:
VERSION=$(node -p "require('./package.json').version")
echo "VERSION=$VERSION" >> $GITHUB_ENV
+ - name: Read pinned Pro revision
+ id: pro_revision
+ run: echo "sha=$(git rev-parse HEAD:pro)" >> "$GITHUB_OUTPUT"
+
+ - name: Check out pinned Pro source
+ uses: actions/checkout@v4
+ with:
+ repository: off-grid-ai/mobile-pro
+ token: ${{ secrets.PRO_SUBMODULE_PAT }}
+ path: pro
+ ref: ${{ steps.pro_revision.outputs.sha }}
+ persist-credentials: false
+
+ - name: Check out shared packages
+ uses: actions/checkout@v4
+ with:
+ repository: off-grid-ai/shared
+ token: ${{ secrets.PRO_SUBMODULE_PAT }}
+ path: _shared
+ ref: main
+ persist-credentials: false
+
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
+ - name: Build shared packages
+ run: |
+ mv _shared ../shared
+ npm --prefix ../shared ci
+ npm --prefix ../shared/packages/automation run build
+ npm --prefix ../shared/packages/sync run build
+ npm --prefix ../shared/packages/rag run build
+ npm --prefix ../shared/packages/models run build
+ npm --prefix ../shared/packages/speech run build
+ npm --prefix ../shared/packages/ui run build
+
- name: Install dependencies
run: npm ci
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5107a8e2b..9f47e76c7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,6 +27,28 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # Fetch all history for changelog
+ - name: Read pinned Pro revision
+ id: pro_revision
+ run: echo "sha=$(git rev-parse HEAD:pro)" >> "$GITHUB_OUTPUT"
+
+ - name: Check out pinned Pro source
+ uses: actions/checkout@v4
+ with:
+ repository: off-grid-ai/mobile-pro
+ token: ${{ secrets.PRO_SUBMODULE_PAT }}
+ path: pro
+ ref: ${{ steps.pro_revision.outputs.sha }}
+ persist-credentials: false
+
+ - name: Check out shared packages
+ uses: actions/checkout@v4
+ with:
+ repository: off-grid-ai/shared
+ token: ${{ secrets.PRO_SUBMODULE_PAT }}
+ path: _shared
+ ref: main
+ persist-credentials: false
+
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -57,6 +79,17 @@ jobs:
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-
+ - name: Build shared packages
+ run: |
+ mv _shared ../shared
+ npm --prefix ../shared ci
+ npm --prefix ../shared/packages/automation run build
+ npm --prefix ../shared/packages/sync run build
+ npm --prefix ../shared/packages/rag run build
+ npm --prefix ../shared/packages/models run build
+ npm --prefix ../shared/packages/speech run build
+ npm --prefix ../shared/packages/ui run build
+
- name: Install dependencies
run: npm ci
diff --git a/.github/workflows/uat-dev.yml b/.github/workflows/uat-dev.yml
index 09a96ff73..6fbb5eb7c 100644
--- a/.github/workflows/uat-dev.yml
+++ b/.github/workflows/uat-dev.yml
@@ -62,8 +62,26 @@ jobs:
git config --global url."https://x-access-token:${PRO_PAT}@github.com/".insteadOf "https://github.com/"
git submodule update --init --recursive pro
git config --global --unset url."https://x-access-token:${PRO_PAT}@github.com/".insteadOf || true
+ - name: Check out shared packages
+ uses: actions/checkout@v4
+ with:
+ repository: off-grid-ai/shared
+ token: ${{ secrets.PRO_SUBMODULE_PAT }}
+ path: _shared
+ ref: main
+ persist-credentials: false
- uses: actions/setup-node@v4
with: { node-version: '20', cache: npm }
+ - name: Build shared packages
+ run: |
+ mv _shared ../shared
+ npm --prefix ../shared ci
+ npm --prefix ../shared/packages/automation run build
+ npm --prefix ../shared/packages/sync run build
+ npm --prefix ../shared/packages/rag run build
+ npm --prefix ../shared/packages/models run build
+ npm --prefix ../shared/packages/speech run build
+ npm --prefix ../shared/packages/ui run build
- run: npm ci
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '17' }
diff --git a/App.tsx b/App.tsx
index 862b92582..32a2114b9 100644
--- a/App.tsx
+++ b/App.tsx
@@ -136,6 +136,7 @@ function App() {
const applyPendingProRedirect = useProExpiryRedirect();
const [isInitializing, setIsInitializing] = useState(true);
const startupGeneration = useRef(0);
+ const showSyncStatusBar = useAppStore(s => s.showSyncStatusBar);
const setDeviceInfo = useAppStore((s) => s.setDeviceInfo);
const setModelRecommendation = useAppStore((s) => s.setModelRecommendation);
const setDownloadedModels = useAppStore((s) => s.setDownloadedModels);
@@ -406,7 +407,7 @@ function App() {
- {__DEV__ ? : null}
+ {__DEV__ && showSyncStatusBar ? : null}
);
@@ -419,7 +420,7 @@ function App() {
- {__DEV__ ? : null}
+ {__DEV__ && showSyncStatusBar ? : null}
);
@@ -465,7 +466,7 @@ function App() {
>
- {__DEV__ ? : null}
+ {__DEV__ && showSyncStatusBar ? : null}
);
diff --git a/README.md b/README.md
index 893668322..2e60eba0c 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
[](#install)
[](https://codecov.io/gh/off-grid-ai/OGAM)
[](https://join.slack.com/t/off-grid-mobile/shared_invite/zt-411pbtz7r-lcOK4YCeY40vh_~FUdcvLA)
-[](https://offgridmobileai.co/pay/)
+[](https://offgridmobileai.co/pay/)
@@ -37,7 +37,7 @@
## Off Grid AI Pro
-**A voice, personas, and actions. $69 for life, or $49/year.**
+**A voice, personas, and actions. $119 for life, or $7.99/month.**
@@ -52,7 +52,7 @@ Pro is an optional, additive tier. It gives the assistant a voice that talks bac
- **Draft, then approve** - connect Calendar, email, and MCP servers like Linear, Notion, and GitHub. It drafts the reply or files the ticket and waits. Nothing sends without your tap.
- **Personal Mesh sync** - your phone and your Mac share chats, files, and settings over your own network. There is no relay. See the [Personal Mesh guide](docs/PERSONAL_MESH.md).
-**[→ Get Pro access](https://offgridmobileai.co/pay/)** - $69 once and it is yours forever (the price climbs as more people join, never down), or $49/year.
+**[→ Get Pro access](https://offgridmobileai.co/pay/)** - $119 once and it is yours forever (the price climbs as more people join, never down), or $7.99/month.
Pair it with **[Off Grid AI Desktop](https://github.com/off-grid-ai/desktop/releases/latest)** on your Mac. One Pro license covers both.
diff --git a/__tests__/hardening/batch2-chatslist.test.tsx b/__tests__/hardening/batch2-chatslist.test.tsx
index 7ed35e2c3..198eacc9c 100644
--- a/__tests__/hardening/batch2-chatslist.test.tsx
+++ b/__tests__/hardening/batch2-chatslist.test.tsx
@@ -23,7 +23,7 @@ import {
formatWeekday,
} from '../../src/utils/localTime';
import React from 'react';
-import { render, fireEvent, within } from '@testing-library/react-native';
+import { render, fireEvent, within, act } from '@testing-library/react-native';
import { useAppStore } from '../../src/stores/appStore';
import { useChatStore } from '../../src/stores/chatStore';
import { resetStores } from '../utils/testHelpers';
@@ -189,7 +189,7 @@ describe('batch2 ChatsListScreen — sort, timestamp format, delete, empty', ()
// Case 31 (through the UI): the swipe-delete action opens a confirm alert whose
// Delete button removes exactly that conversation from the real store, leaving the
// rest intact.
- it('case31: confirming the swipe-delete alert removes exactly that conversation', () => {
+ it('case31: confirming the swipe-delete alert removes exactly that conversation', async () => {
const a = createConversation({ title: 'Alpha', updatedAt: new Date('2024-06-02T10:00:00Z').toISOString() });
const b = createConversation({ title: 'Bravo', updatedAt: new Date('2024-06-01T10:00:00Z').toISOString() });
useChatStore.setState({ conversations: [a, b] });
@@ -207,7 +207,8 @@ describe('batch2 ChatsListScreen — sort, timestamp format, delete, empty', ()
const call = mockShowAlert.mock.calls.find(c => c[0] === 'Delete Chat')!;
const buttons = call[2] as any[];
const del = buttons.find(btn => btn.text === 'Delete')!;
- del.onPress();
+ // The delete removes the chat's image files first, then the chat.
+ await act(async () => { await del.onPress(); });
const remaining = useChatStore.getState().conversations;
expect(remaining).toHaveLength(1);
diff --git a/__tests__/harness/chatHarness.ts b/__tests__/harness/chatHarness.ts
index ef1168ad1..447b6c067 100644
--- a/__tests__/harness/chatHarness.ts
+++ b/__tests__/harness/chatHarness.ts
@@ -730,11 +730,16 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
/** Mount the real ChatScreen (plus the real app.root slot when pro is active, so the TTS EngineBridge
* mounts and the voice engine can load over the executorch fake — the same slot App.tsx renders). */
render() {
+ this.view = rtl.render(this.screenTree());
+ return this.view;
+ },
+
+ /** The mounted tree: ChatScreen, plus the app.root slot when pro is active. Fresh elements each call. */
+ screenTree(): React.ReactElement {
const { ChatScreen } = require('../../src/screens/ChatScreen');
const { getSlot, SLOTS } = require('../../src/bootstrap/slotRegistry');
-
const AppRoot = opts.pro ? getSlot(SLOTS.appRoot) : undefined;
- const tree = AppRoot
+ return AppRoot
? React.createElement(
React.Fragment,
null,
@@ -742,8 +747,23 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
React.createElement(ChatScreen, {}),
)
: React.createElement(ChatScreen, {});
- this.view = rtl.render(tree);
- return this.view;
+ },
+
+ /**
+ * Open another chat on the SAME mounted ChatScreen, as navigating to a chat from the list does: the
+ * native stack keeps the screen and hands it new route params. No id opens a new chat. Anything
+ * still in flight in the composer (a transcription, a send) stays alive across the switch.
+ */
+ async openConversation(conversationId?: string) {
+ // The screen reads the route through the module registry the harness reset into, which holds its
+ // own copy of this module; set the params there.
+ require('./chatHarness').routeHolder.params = conversationId ? { conversationId } : {};
+ await rtl.act(async () => {
+ this.view!.rerender(this.screenTree());
+ });
+ await rtl.waitFor(() => {
+ expect(useChatStore.getState().activeConversationId).toBe(conversationId ?? null);
+ });
},
/**
diff --git a/__tests__/harness/mcpHttpFake.ts b/__tests__/harness/mcpHttpFake.ts
new file mode 100644
index 000000000..73b9c39b6
--- /dev/null
+++ b/__tests__/harness/mcpHttpFake.ts
@@ -0,0 +1,83 @@
+/**
+ * Behavior-faithful fake of remote MCP servers at the network boundary (XMLHttpRequest, which
+ * McpClient uses). Each registered URL is an in-memory Streamable-HTTP MCP server: it answers
+ * initialize, tools/list and tools/call with JSON-RPC bodies the way a real server does, and it
+ * runs a called tool by producing text that names the server that ran it. Everything above the
+ * transport (McpClient, mcpService, the store, the screens) runs for real.
+ */
+import type { McpTool } from '@offgrid/pro/mcp/types';
+
+export interface FakeMcpServer {
+ label: string;
+ tools: McpTool[];
+}
+
+export interface McpHttpFake {
+ /** Every tools/call that reached a server, in order. */
+ calls: Array<{ url: string; tool: string }>;
+ /** A down server drops every request with a network error, as an unreachable host does. */
+ setDown: (url: string, down: boolean) => void;
+ /** Park the next request to `url` until the returned release() is called. */
+ holdNext: (url: string) => { release: () => void };
+ restore: () => void;
+}
+
+export function installMcpHttpFake(servers: Record): McpHttpFake {
+ const calls: McpHttpFake['calls'] = [];
+ const down = new Set();
+ const holds = new Map>();
+ const original = (global as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest;
+
+ class FakeMcpXHR {
+ status = 0;
+ responseText = '';
+ timeout = 0;
+ onload: null | (() => void) = null;
+ onerror: null | (() => void) = null;
+ ontimeout: null | (() => void) = null;
+ onabort: null | (() => void) = null;
+ private url = '';
+ private headers: Record = {};
+
+ open(_method: string, url: string): void { this.url = url; }
+ setRequestHeader(): void { /* headers are not part of this server's behavior */ }
+ getResponseHeader(name: string): string | null { return this.headers[name.toLowerCase()] ?? null; }
+ abort(): void { this.onabort?.(); }
+
+ send(payload: string): void {
+ const server = servers[this.url];
+ const hold = holds.get(this.url);
+ holds.delete(this.url);
+ const answer = () => setTimeout(() => {
+ if (!server || down.has(this.url)) { this.onerror?.(); return; }
+ const req = JSON.parse(payload) as { id: number; method: string; params: { name?: string } };
+ this.status = 200;
+ this.headers = { 'content-type': 'application/json' };
+ let result: unknown;
+ if (req.method === 'initialize') {
+ result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: server.label } };
+ } else if (req.method === 'tools/list') {
+ result = { tools: server.tools };
+ } else if (req.method === 'tools/call') {
+ calls.push({ url: this.url, tool: req.params.name ?? '' });
+ result = { content: [{ type: 'text', text: `${server.label} ran ${req.params.name}` }] };
+ }
+ this.responseText = result === undefined ? '' : JSON.stringify({ jsonrpc: '2.0', id: req.id, result });
+ this.onload?.();
+ }, 0);
+ if (hold) hold.then(answer); else answer();
+ }
+ }
+
+ (global as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = FakeMcpXHR;
+ return {
+ calls,
+ setDown: (url, isDown) => { if (isDown) down.add(url); else down.delete(url); },
+ holdNext: url => {
+ let release = () => {};
+ holds.set(url, new Promise(resolve => { release = resolve; }));
+ return { release: () => release() };
+ },
+ restore: () => { (global as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = original; },
+ };
+}
diff --git a/__tests__/harness/nativeBoundary.ts b/__tests__/harness/nativeBoundary.ts
index fe03c10f0..efd918e3f 100644
--- a/__tests__/harness/nativeBoundary.ts
+++ b/__tests__/harness/nativeBoundary.ts
@@ -888,6 +888,11 @@ export interface WhisperFake {
holdNextLoad(): void;
/** Release a load held via holdNextLoad(). No-op if not held. */
releaseLoad(): void;
+ /** HOLD the next file transcription (the voice-mode path) open until releaseTranscription() - the
+ * seconds a real whisper pass over a recording takes on device. One-shot. */
+ holdNextTranscription(): void;
+ /** Release a transcription held via holdNextTranscription(). No-op if not held. */
+ releaseTranscription(): void;
}
function makeWhisperFake(): WhisperFake {
@@ -897,16 +902,26 @@ function makeWhisperFake(): WhisperFake {
// Load hold: opens the in-flight model-load window a real (seconds-long) ggml init has.
let loadHoldPending = false;
let loadHoldRelease: (() => void) | null = null;
+ // Transcription hold: the in-flight window of a real whisper pass over a recorded file.
+ let transcriptionHoldPending = false;
+ let transcriptionHoldRelease: (() => void) | null = null;
const context: Record = {
// Faithful to whisper.rn: transcribe(path, opts) returns { stop, promise }, the promise resolving to
// { result, segments } — this is the method whisperService.transcribeFile (the voice-mode file path) drives.
- transcribe: jest.fn((_path: string) => ({
- stop: jest.fn(async () => {}),
- promise: Promise.resolve({
- result: fileTranscript,
- segments: [{ text: fileTranscript, t0: 0, t1: 100 }],
- }),
- })),
+ transcribe: jest.fn((_path: string) => {
+ const text = fileTranscript;
+ const held = transcriptionHoldPending
+ ? new Promise(res => { transcriptionHoldRelease = res; })
+ : Promise.resolve();
+ transcriptionHoldPending = false;
+ return {
+ stop: jest.fn(async () => {}),
+ promise: held.then(() => ({
+ result: text,
+ segments: [{ text, t0: 0, t1: 100 }],
+ })),
+ };
+ }),
transcribeFile: jest.fn(async () => ({
result: fileTranscript,
segments: [{ text: fileTranscript, t0: 0, t1: 100 }],
@@ -974,6 +989,14 @@ function makeWhisperFake(): WhisperFake {
loadHoldRelease = null;
f?.();
},
+ holdNextTranscription: () => {
+ transcriptionHoldPending = true;
+ },
+ releaseTranscription: () => {
+ const f = transcriptionHoldRelease;
+ transcriptionHoldRelease = null;
+ f?.();
+ },
};
}
@@ -1043,6 +1066,13 @@ export interface NativeBoundary {
setRam(profile: RamProfile): void;
/** Fire the OS 'memoryWarning' AppState event the app's residency manager listens to (auto-eviction). */
emitMemoryWarning(): void;
+ /** Fire an OS AppState 'change' (e.g. 'active' when the person comes back from Settings). */
+ emitAppStateChange(state: 'active' | 'background' | 'inactive'): void;
+ /** What the OS answers when the app reads the microphone permission (no prompt). Default 'Granted'.
+ * 'Unreadable' makes the read fail, as a native module error would. */
+ setMicPermission(status: 'Granted' | 'Denied' | 'Undetermined' | 'Unreadable'): void;
+ /** How many times the app asked the OS to open its Settings page. */
+ settingsOpenedCount(): number;
}
/**
@@ -1103,14 +1133,20 @@ export function installNativeBoundary(opts: InstallOpts = {}): NativeBoundary {
RN.NativeModules.CoreMLDiffusionModule = diffusion.module;
if (downloadFake)
RN.NativeModules.DownloadManagerModule = downloadFake.module;
+ let micPermission: 'Granted' | 'Denied' | 'Undetermined' | 'Unreadable' = 'Granted';
// Mic permission is a device boundary: whisper STT refuses to start recording without RECORD_AUDIO
// granted (whisperService.requestPermissions → PermissionsAndroid.request). Grant it when whisper is
// installed so the real STT flow runs; the default jest PermissionsAndroid returns undefined (= denied).
+ // The answer follows the permission the test set (setMicPermission): granted stays granted, and
+ // anything else answers as a "Don't ask again" denial, the case Android reads back as Undetermined.
if (whisperFake && RN.PermissionsAndroid) {
- RN.PermissionsAndroid.request = jest
- .fn()
- .mockResolvedValue(RN.PermissionsAndroid.RESULTS?.GRANTED ?? 'granted');
- RN.PermissionsAndroid.check = jest.fn().mockResolvedValue(true);
+ RN.PermissionsAndroid.request = () =>
+ Promise.resolve(
+ micPermission === 'Granted'
+ ? RN.PermissionsAndroid.RESULTS?.GRANTED ?? 'granted'
+ : RN.PermissionsAndroid.RESULTS?.NEVER_ASK_AGAIN ?? 'never_ask_again',
+ );
+ RN.PermissionsAndroid.check = () => Promise.resolve(micPermission === 'Granted');
}
RN.NativeModules.DeviceMemoryModule = {
// Live read from memState so a context release (freeModelMemory) is reflected — the reclaim barrier
@@ -1150,6 +1186,20 @@ export function installNativeBoundary(opts: InstallOpts = {}): NativeBoundary {
// mock swallows the callback, so replace AppState with a capturing emitter and expose emitMemoryWarning()
// to fire the OS memory-warning faithfully (OS event → the app's real listener → real handleMemoryWarning).
const appState = makeEmitterRegistry();
+
+ // Microphone permission leaf: react-native-audio-api reads the OS record permission without a
+ // prompt. Stateful so a test can deny it, then grant it as if from Settings.
+ require('react-native-audio-api').AudioManager.checkRecordingPermissions = () =>
+ micPermission === 'Unreadable'
+ ? Promise.reject(new Error('record permission unavailable'))
+ : Promise.resolve(micPermission);
+ // Settings leaf: Linking.openSettings hands off to the OS; count the hand-offs.
+ let settingsOpened = 0;
+ RN.Linking.openSettings = () => {
+ settingsOpened += 1;
+ return Promise.resolve();
+ };
+
Object.defineProperty(RN, 'AppState', {
configurable: true,
value: {
@@ -1194,5 +1244,10 @@ export function installNativeBoundary(opts: InstallOpts = {}): NativeBoundary {
whisper: whisperFake,
setRam,
emitMemoryWarning: () => appState.handle.emit('memoryWarning'),
+ emitAppStateChange: state => appState.handle.emit('change', state),
+ setMicPermission: status => {
+ micPermission = status;
+ },
+ settingsOpenedCount: () => settingsOpened,
};
}
diff --git a/__tests__/harness/nativeFileSystem.ts b/__tests__/harness/nativeFileSystem.ts
index 14f02ef2c..e33944095 100644
--- a/__tests__/harness/nativeFileSystem.ts
+++ b/__tests__/harness/nativeFileSystem.ts
@@ -20,6 +20,28 @@ export interface NativeFileSystemBoundary {
setReportedFileSize(path: string, size: number | string): void;
readAscii(path: string, length: number, position?: number): Promise;
exists(path: string): Promise;
+ /** Serve `url` to RNFS.downloadFile, the way a remote host answers a real transfer. */
+ serveRemoteFile(url: string, file: RemoteFile): RemoteTransfer;
+ /** Paths of the files currently inside `directory` (empty when it does not exist). */
+ listFiles(directory: string): string[];
+}
+
+/** What a remote host answers to a download. */
+export interface RemoteFile {
+ statusCode?: number;
+ headers?: Record;
+ body?: string;
+ /** Park the transfer after the first half of the body is on disk, until release() or stop. */
+ hold?: boolean;
+ /** false = the transfer finishes even after stopDownload (it was already complete natively). */
+ honorsStop?: boolean;
+}
+
+export interface RemoteTransfer {
+ /** True while a transfer of this file is parked mid-body. */
+ held(): boolean;
+ /** Let a parked transfer finish. */
+ release(): void;
}
interface NativeFileSystemEntry {
@@ -80,6 +102,9 @@ export function createNativeFileSystemBoundary(
const MainBundlePath = options.mainBundlePath ?? '/bundle';
let volume = Volume.fromJSON({});
const reportedFileSizes = new Map();
+ const remoteFiles = new Map void) | null }>();
+ const activeDownloads = new Map void>();
+ let nextJobId = 1;
let restoreModuleMocks = (): void => {};
function normalize(path: string): string {
@@ -94,6 +119,8 @@ export function createNativeFileSystemBoundary(
function reset(): void {
volume = Volume.fromJSON({});
reportedFileSizes.clear();
+ remoteFiles.clear();
+ activeDownloads.clear();
for (const directory of [
DocumentDirectoryPath,
CachesDirectoryPath,
@@ -261,11 +288,40 @@ export function createNativeFileSystemBoundary(
freeSpace: 100 * 1024 * 1024 * 1024,
totalSpace: 128 * 1024 * 1024 * 1024,
})),
- downloadFile: jest.fn(() => ({
- jobId: 1,
- promise: Promise.resolve({ statusCode: 200, bytesWritten: 0 }),
- })),
- stopDownload: jest.fn(),
+ downloadFile: jest.fn((request?: Record) => {
+ const served = remoteFiles.get(String(request?.fromUrl ?? ''));
+ if (!served) {
+ return { jobId: 1, promise: Promise.resolve({ statusCode: 200, bytesWritten: 0 }) };
+ }
+ const jobId = nextJobId++;
+ const target = normalize(String(request?.toFile));
+ const body = Buffer.from(served.body ?? '', 'utf8');
+ const promise = (async () => {
+ volume.mkdirSync(parent(target), { recursive: true });
+ // Bytes land on disk as they arrive, so a stopped or failed transfer leaves a partial file.
+ volume.writeFileSync(target, body.subarray(0, Math.ceil(body.length / 2)));
+ if (served.hold) {
+ const stopped = await new Promise(resolve => {
+ served.parked = () => resolve(false);
+ if (served.honorsStop !== false) activeDownloads.set(jobId, () => resolve(true));
+ });
+ served.parked = null;
+ activeDownloads.delete(jobId);
+ if (stopped) throw new Error('Download has been aborted');
+ }
+ volume.writeFileSync(target, body);
+ return {
+ jobId,
+ statusCode: served.statusCode ?? 200,
+ headers: served.headers ?? {},
+ bytesWritten: body.length,
+ };
+ })();
+ return { jobId, promise };
+ }),
+ stopDownload: jest.fn((jobId?: number) => {
+ if (jobId !== undefined) activeDownloads.get(jobId)?.();
+ }),
};
const baseMockImplementations = [
@@ -339,6 +395,19 @@ export function createNativeFileSystemBoundary(
readAscii: (path: string, length: number, position = 0) =>
module.read(path, length, position, 'ascii'),
exists: (path: string) => module.exists(path),
+ serveRemoteFile: (url: string, file: RemoteFile) => {
+ const entry = { ...file, parked: null as (() => void) | null };
+ remoteFiles.set(url, entry);
+ return {
+ held: () => entry.parked !== null,
+ release: () => entry.parked?.(),
+ };
+ },
+ listFiles: (directory: string) => {
+ const normalized = normalize(directory);
+ if (!volume.existsSync(normalized)) return [];
+ return (volume.readdirSync(normalized) as string[]).map(name => `${normalized}/${name}`);
+ },
};
}
diff --git a/__tests__/harness/remoteImageServer.ts b/__tests__/harness/remoteImageServer.ts
new file mode 100644
index 000000000..888938eb0
--- /dev/null
+++ b/__tests__/harness/remoteImageServer.ts
@@ -0,0 +1,91 @@
+/**
+ * A remote image server at the network boundary (fetch), plus the real gestures that make it the
+ * chat's image model. The server answers an image request with a URL to transfer, as OpenAI-style
+ * servers do; the transfer itself is served by the RNFS fake (nativeFileSystem.serveRemoteFile).
+ */
+import React from 'react';
+import type { setupChatScreen } from './chatHarness';
+
+type Harness = Awaited>;
+
+const ENDPOINT = 'http://192.168.1.60:7878'; // NOSONAR - private LAN test fixture
+export const REMOTE_IMAGE_URL = 'https://images.example.test/out/lighthouse.png';
+
+export function serveImageGeneration(): { requests: number; restore: () => void } {
+ const original = globalThis.fetch;
+ const state = { requests: 0, restore: () => { globalThis.fetch = original; } };
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (url === `${ENDPOINT}/v1/images/generations`) {
+ state.requests += 1;
+ return new Response(JSON.stringify({ data: [{ url: REMOTE_IMAGE_URL }] }), {
+ status: 200, headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ return original(input);
+ }) as typeof globalThis.fetch;
+ return state;
+}
+
+/**
+ * Like serveImageGeneration, but the answer waits until `release()`, so the image stays in progress
+ * while the test acts. The request stops when the app aborts it, as a real fetch does.
+ */
+export function holdImageGeneration(): {
+ requests: number;
+ aborted: number;
+ release: () => void;
+ restore: () => void;
+} {
+ const original = globalThis.fetch;
+ const waiting: Array<() => void> = [];
+ const state = {
+ requests: 0,
+ aborted: 0,
+ release: () => { waiting.splice(0).forEach(answer => answer()); },
+ restore: () => { globalThis.fetch = original; },
+ };
+ globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (url !== `${ENDPOINT}/v1/images/generations`) return original(input, init);
+ state.requests += 1;
+ return new Promise((resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => {
+ state.aborted += 1;
+ reject(new Error('Aborted'));
+ }, { once: true });
+ waiting.push(() => resolve(new Response(JSON.stringify({ data: [{ url: REMOTE_IMAGE_URL }] }), {
+ status: 200, headers: { 'Content-Type': 'application/json' },
+ })));
+ });
+ }) as typeof globalThis.fetch;
+ return state;
+}
+
+/** Pick the remote image model in the real picker, then switch the chat into image mode. */
+export async function chooseRemoteImageModel(h: Harness): Promise {
+ const { useRemoteServerStore } = require('../../src/stores/remoteServerStore');
+ const { RemoteModelOptionsSection } = require('../../src/components/models/RemoteModelOptionsSection');
+ // A saved server with its discovered image catalog is what the add-server flow leaves behind.
+ const serverId = useRemoteServerStore.getState().addServer({
+ name: 'Studio Mac',
+ endpoint: ENDPOINT,
+ providerType: 'openai-compatible',
+ modelCatalog: { image: [{ id: 'flux-schnell', name: 'Flux Schnell' }] },
+ });
+ const picker = h.rtl.render(React.createElement(RemoteModelOptionsSection, { category: 'image' }));
+ h.rtl.fireEvent.press(picker.getByTestId(`remote-image-model-${serverId}:flux-schnell`));
+ await h.rtl.waitFor(() => {
+ expect(useRemoteServerStore.getState().activeRemoteMediaServerIds.image).toBe(serverId);
+ });
+ picker.unmount();
+
+ h.render();
+ await h.cycleImageMode();
+ await h.rtl.waitFor(() => { expect(h.view!.queryByTestId('image-mode-force-badge')).not.toBeNull(); });
+}
+
+/** Clear the saved remote servers this harness added. */
+export function clearRemoteServers(): void {
+ require('../../src/stores/remoteServerStore').useRemoteServerStore.getState().clearAllServers();
+}
diff --git a/__tests__/harness/sqliteFake.ts b/__tests__/harness/sqliteFake.ts
index b53104804..173bbd864 100644
--- a/__tests__/harness/sqliteFake.ts
+++ b/__tests__/harness/sqliteFake.ts
@@ -18,56 +18,79 @@ export function installRealSqlite(setupSql?: string): void {
* before requiring the rag modules. installRealSqlite = resetModules + this.
*/
export function doMockRealSqlite(setupSql?: string): void {
- jest.doMock('@op-engineering/op-sqlite', () => {
- const { DatabaseSync } = require('node:sqlite');
+ jest.doMock('@op-engineering/op-sqlite', () =>
+ realSqliteModule({ setupSql }),
+ );
+}
- const wrap = (db: any) => ({
- executeSync: (sql: string, params: unknown[] = []) => {
- const bind = (params ?? []).map(p =>
- // op-sqlite accepts ArrayBuffer for BLOBs; node:sqlite wants a Uint8Array/Buffer. Use a
- // realm-safe check (Object.prototype.toString) because a composed harness (installNativeBoundary
- // + doMockRealSqlite) can hand us an ArrayBuffer from a different realm where `instanceof` fails.
- p instanceof ArrayBuffer ||
- Object.prototype.toString.call(p) === '[object ArrayBuffer]'
- ? new Uint8Array(p as ArrayBuffer)
- : p,
- );
- // Transaction / DDL control statements: no params, run via exec.
- if (
- /^\s*(BEGIN|COMMIT|ROLLBACK|CREATE|PRAGMA|DROP)/i.test(sql) &&
- bind.length === 0
- ) {
- db.exec(sql);
- return { rows: [], insertId: undefined, rowsAffected: 0 };
- }
- const stmt = db.prepare(sql);
- if (/^\s*(SELECT|WITH)\b/i.test(sql)) {
- const rows = stmt.all(...bind);
- return { rows, insertId: undefined, rowsAffected: 0 };
- }
- const info = stmt.run(...bind);
- return {
- rows: [],
- insertId:
- info.lastInsertRowid != null
- ? Number(info.lastInsertRowid)
- : undefined,
- rowsAffected: Number(info.changes ?? 0),
- };
- },
- execute: async function (this: any, sql: string, params: unknown[] = []) {
- return this.executeSync(sql, params);
- },
- close: () => db.close(),
- delete: () => {},
- });
+/**
+ * The op-sqlite module itself, backed by real :memory: databases, for a hoisted
+ * `jest.mock('@op-engineering/op-sqlite', () => require().realSqliteModule(...))` in a test
+ * that imports the app statically. `only` names the databases that get a real engine; any other open()
+ * gets the same inert database as the global mock, so code a test is not about keeps its old boundary.
+ */
+export function realSqliteModule(
+ options: { setupSql?: string; only?: readonly string[] } = {},
+): { open: (config?: { name?: string }) => unknown } {
+ const { DatabaseSync } = require('node:sqlite');
+ const wrap = (db: any) => ({
+ executeSync: (sql: string, params: unknown[] = []) => {
+ const bind = (params ?? []).map(p =>
+ // op-sqlite accepts ArrayBuffer for BLOBs; node:sqlite wants a Uint8Array/Buffer. Use a
+ // realm-safe check (Object.prototype.toString) because a composed harness (installNativeBoundary
+ // + doMockRealSqlite) can hand us an ArrayBuffer from a different realm where `instanceof` fails.
+ p instanceof ArrayBuffer ||
+ Object.prototype.toString.call(p) === '[object ArrayBuffer]'
+ ? new Uint8Array(p as ArrayBuffer)
+ : p,
+ );
+ // Transaction / DDL control statements: no params, run via exec.
+ if (
+ /^\s*(BEGIN|COMMIT|ROLLBACK|CREATE|PRAGMA|DROP)/i.test(sql) &&
+ bind.length === 0
+ ) {
+ db.exec(sql);
+ return { rows: [], insertId: undefined, rowsAffected: 0 };
+ }
+ const stmt = db.prepare(sql);
+ if (/^\s*(SELECT|WITH)\b/i.test(sql)) {
+ const rows = stmt.all(...bind);
+ return { rows, insertId: undefined, rowsAffected: 0 };
+ }
+ const info = stmt.run(...bind);
+ return {
+ rows: [],
+ insertId:
+ info.lastInsertRowid != null
+ ? Number(info.lastInsertRowid)
+ : undefined,
+ rowsAffected: Number(info.changes ?? 0),
+ };
+ },
+ execute: async function (this: any, sql: string, params: unknown[] = []) {
+ return this.executeSync(sql, params);
+ },
+ close: () => db.close(),
+ delete: () => {},
+ });
+
+ const inert = () => {
+ const result = { rows: [], insertId: 0, rowsAffected: 0 };
return {
- open: () => {
- const db = new DatabaseSync(':memory:');
- if (setupSql) db.exec(setupSql);
- return wrap(db);
- },
+ executeSync: () => result,
+ execute: () => Promise.resolve(result),
+ close: () => {},
+ delete: () => {},
};
- });
+ };
+ return {
+ open: (config?: { name?: string }) => {
+ if (options.only && !options.only.includes(config?.name ?? ''))
+ return inert();
+ const db = new DatabaseSync(':memory:');
+ if (options.setupSql) db.exec(options.setupSql);
+ return wrap(db);
+ },
+ };
}
diff --git a/__tests__/integration/audio/micPermissionDenied.rendered.test.tsx b/__tests__/integration/audio/micPermissionDenied.rendered.test.tsx
new file mode 100644
index 000000000..2cbd04565
--- /dev/null
+++ b/__tests__/integration/audio/micPermissionDenied.rendered.test.tsx
@@ -0,0 +1,87 @@
+/**
+ * The chat mic must not look usable when the microphone permission is denied. It shows the
+ * unavailable mic, explains why on tap, opens Settings, and becomes a normal mic again when the
+ * person allows access and comes back. Mounts the real chat, composer, mic button and Whisper
+ * service; only the OS permission, Settings hand-off and AppState are faked, at the native leaf.
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({
+ navigate: () => {},
+ goBack: () => {},
+ setOptions: () => {},
+ addListener: () => () => {},
+ }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {},
+ useIsFocused: () => true,
+}));
+
+describe.each(['ios', 'android'] as const)('chat mic with the microphone denied (%s)', platform => {
+ it('shows the mic as unavailable, opens Settings, and recovers once access is allowed', async () => {
+ const h = await setupChatScreen({ engine: 'llama', platform, whisper: true });
+ await h.setupWhisperModel('tiny.en');
+ h.boundary.setMicPermission('Denied');
+ h.render();
+ const view = h.view!;
+
+ const deniedMic = await h.rtl.waitFor(() =>
+ view.getByTestId('voice-record-button-mic-denied'),
+ );
+ expect(view.queryByTestId('voice-record-button')).toBeNull();
+
+ h.rtl.fireEvent.press(deniedMic);
+ await h.rtl.waitFor(() => {
+ expect(view.getByText('Microphone Access Is Off')).toBeTruthy();
+ });
+ h.rtl.fireEvent.press(view.getByText('Open Settings'));
+ expect(h.boundary.settingsOpenedCount()).toBe(1);
+
+ // The person allows the mic in Settings. Nothing changes until they come back to the app.
+ h.boundary.setMicPermission('Granted');
+ await h.rtl.act(async () => {
+ h.boundary.emitAppStateChange('background');
+ });
+ expect(view.getByTestId('voice-record-button-mic-denied')).toBeTruthy();
+ await h.rtl.act(async () => {
+ h.boundary.emitAppStateChange('active');
+ });
+ await h.rtl.waitFor(() => {
+ expect(view.getByTestId('voice-record-button')).toBeTruthy();
+ expect(view.queryByTestId('voice-record-button-mic-denied')).toBeNull();
+ });
+ }, 30000);
+});
+
+describe('chat mic after an Android "Don\'t ask again" denial', () => {
+ it('turns into the unavailable mic as soon as the request is refused', async () => {
+ // Android reads a permanent denial back as Undetermined, so the mic starts out normal.
+ const h = await setupChatScreen({ engine: 'llama', platform: 'android', whisper: true });
+ await h.setupWhisperModel('tiny.en');
+ h.boundary.setMicPermission('Undetermined');
+ h.render();
+ const view = h.view!;
+ await h.rtl.waitFor(() => view.getByTestId('voice-record-button'));
+
+ await h.tapMicOnce();
+
+ await h.rtl.waitFor(() => {
+ expect(view.getByTestId('voice-record-button-mic-denied')).toBeTruthy();
+ });
+ expect(h.boundary.whisper!.realtimeActive()).toBe(false);
+ }, 30000);
+});
+
+describe('chat mic when the permission cannot be read', () => {
+ it('stays a normal mic rather than claiming access is off', async () => {
+ const h = await setupChatScreen({ engine: 'llama', platform: 'ios', whisper: true });
+ await h.setupWhisperModel('tiny.en');
+ h.boundary.setMicPermission('Unreadable');
+ h.render();
+ const view = h.view!;
+
+ await h.rtl.waitFor(() => view.getByTestId('voice-record-button'));
+ expect(view.queryByTestId('voice-record-button-mic-denied')).toBeNull();
+ }, 30000);
+});
diff --git a/__tests__/integration/audio/ttsStopDuringModelLoad.redflow.test.ts b/__tests__/integration/audio/ttsStopDuringModelLoad.redflow.test.ts
new file mode 100644
index 000000000..c5ee12076
--- /dev/null
+++ b/__tests__/integration/audio/ttsStopDuringModelLoad.redflow.test.ts
@@ -0,0 +1,78 @@
+/**
+ * Stop pressed while the voice model is still loading must stop the playback for good. The voice
+ * model is downloaded but not in memory, so tapping play on a message first loads it. If the person
+ * presses Stop during that load, nothing may be synthesized or played once the load finishes.
+ *
+ * Real ttsStore + playback orchestration + residency, driven by the same store actions the message's
+ * play and stop controls call. The TTS engine is the device boundary: idle and fully downloaded, its
+ * initialize() (the native model load) held open until the test releases it, and every speak()
+ * recorded.
+ */
+import { ttsRegistry } from '../../../pro/audio/engine';
+import { useTTSStore } from '../../../pro/audio/ttsStore';
+
+function makeHeldLoadEngine() {
+ let phase: 'idle' | 'loading' | 'ready' = 'idle';
+ let releaseLoad: () => void = () => {};
+ const spoken: string[] = [];
+ const engine = {
+ id: 'heldtts',
+ displayName: 'Held TTS',
+ capabilities: { peakRamMB: 300, generateAndSave: false },
+ getPhase: () => phase,
+ isSupported: () => true,
+ isFullyDownloaded: () => true,
+ getRequiredAssets: () => [{ id: 'model', sizeBytes: 300 * 1024 * 1024 }],
+ checkAssetStatus: async () => [],
+ getOverallDownloadProgress: () => 1,
+ getVoices: () => [],
+ getActiveVoice: () => null,
+ setVoice: async () => {},
+ getLastDownloadError: () => null,
+ getBridgeComponent: () => null,
+ hydrateDownloaded: () => {},
+ on: () => () => {},
+ off: () => {},
+ once: () => () => {},
+ // The native load: in flight until the test releases it, then the model is ready.
+ initialize: () => {
+ phase = 'loading';
+ return new Promise(resolve => {
+ releaseLoad = () => { phase = 'ready'; resolve(); };
+ });
+ },
+ release: async () => { phase = 'idle'; },
+ destroy: async () => { phase = 'idle'; },
+ speak: async (text: string) => { spoken.push(text); },
+ stop: () => {},
+ pause: () => {},
+ resume: () => {},
+ setSpeed: () => {},
+ };
+ return { engine, spoken, releaseLoad: () => releaseLoad() };
+}
+
+describe('TTS: Stop during the voice model load', () => {
+ it('synthesizes and plays nothing once the load finishes', async () => {
+ const held = makeHeldLoadEngine();
+ ttsRegistry.register('heldtts', () => held.engine as never);
+ await useTTSStore.getState().setEngine('heldtts');
+
+ // The person taps play on a message: the voice model starts loading.
+ const playing = useTTSStore.getState().speak('Here is the summary you asked for.', 'msg-1');
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(held.engine.getPhase()).toBe('loading');
+ expect(useTTSStore.getState().playbackStatus).toBe('preparing');
+
+ // They press Stop while it is still loading.
+ useTTSStore.getState().stop();
+ expect(useTTSStore.getState().playbackStatus).toBe('idle');
+
+ // The load finishes. Nothing is spoken and playback stays stopped.
+ held.releaseLoad();
+ await playing;
+ expect(held.spoken).toEqual([]);
+ expect(useTTSStore.getState().playbackStatus).toBe('idle');
+ expect(useTTSStore.getState().currentMessageId).toBeNull();
+ });
+});
diff --git a/__tests__/integration/audio/voiceTranscriptStaysInItsChat.rendered.test.tsx b/__tests__/integration/audio/voiceTranscriptStaysInItsChat.rendered.test.tsx
new file mode 100644
index 000000000..b9a28ad8c
--- /dev/null
+++ b/__tests__/integration/audio/voiceTranscriptStaysInItsChat.rendered.test.tsx
@@ -0,0 +1,54 @@
+/**
+ * A voice note recorded in one chat must never land in another. In voice mode the recording is
+ * transcribed after the mic stops, which takes seconds on device. If the person opens a different
+ * chat while that runs, the transcript belongs to the chat it was spoken in: nothing may be sent
+ * or attached in the chat that happens to be open when it finishes.
+ *
+ * Real ChatScreen in voice mode on the real record button, real chat switch on the same mounted
+ * screen (new route params, as navigating from the chat list does). Fakes only at the device
+ * boundary: whisper.rn (its file transcription held open), the LiteRT engine and the recorder.
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {}, useIsFocused: () => true,
+}));
+
+describe('a voice transcript stays in the chat it was spoken in', () => {
+ it('sends nothing into the chat opened while the recording was being transcribed', async () => {
+ const h = await setupChatScreen({ engine: 'litert', platform: 'android', whisper: true, pro: true });
+ await h.setupWhisperModel();
+ h.render();
+
+ // Chat A exists because the person wrote in it; they have it open from the chat list.
+ await h.send('hello from the first chat', { content: 'First chat reply.' });
+ await h.rtl.waitFor(() => { expect(h.view!.queryAllByText(/First chat reply\./).length).toBeGreaterThan(0); });
+ const chatA = h.conversationId!;
+ await h.openConversation(chatA);
+ await h.enterVoiceMode();
+ const chatsBefore = h.useChatStore.getState().conversations.length;
+ const turnsSent = () => {
+ const calls = h.boundary.litert.calls;
+ return calls.sendMessage.length + calls.sendMessageWithMedia.length + calls.sendMessageWithImages.length;
+ };
+ const turnsBefore = turnsSent();
+
+ // The person records a note in A. Its transcription is still running when they open a new chat.
+ h.boundary.whisper!.holdNextTranscription();
+ await h.voiceSend('book the meeting room for friday');
+ await h.rtl.waitFor(() => { expect(h.view!.getByTestId('voice-record-button-audio')).toBeTruthy(); });
+ await h.openConversation();
+ expect(h.view!.queryAllByText(/First chat reply\./)).toHaveLength(0);
+
+ // The transcription finishes while the new chat is open.
+ await h.rtl.act(async () => { h.boundary.whisper!.releaseTranscription(); });
+ await h.rtl.act(async () => { await h.settle(300); });
+
+ // Nothing from A was sent or attached here: no message, no new chat, no turn for the engine.
+ expect(h.view!.queryAllByText(/book the meeting room for friday/)).toHaveLength(0);
+ expect(h.useChatStore.getState().conversations).toHaveLength(chatsBefore);
+ expect(turnsSent()).toBe(turnsBefore);
+ });
+});
diff --git a/__tests__/integration/chat/remoteEnhanceSkipped.redflow.test.ts b/__tests__/integration/chat/remoteEnhanceSkipped.redflow.test.ts
index 545e9c549..f38c77aca 100644
--- a/__tests__/integration/chat/remoteEnhanceSkipped.redflow.test.ts
+++ b/__tests__/integration/chat/remoteEnhanceSkipped.redflow.test.ts
@@ -40,17 +40,16 @@ describe('B30/Q8 — prompt enhancement runs on the remote text model (red-flow)
const { useAppStore } = require('../../../src/stores');
- const { imageGenerationService } = require('../../../src/services/imageGenerationService');
+ const { enhanceImagePrompt } = require('../../../src/services/imagePromptEnhancement');
// Enhancement is opt-in — turn it on the way the user does via settings.
useAppStore.getState().updateSettings({ enhanceImagePrompts: true });
installRemoteStream(ENHANCED_SSE); // fake ONLY the XHR transport with the device-shaped SSE
- // Drive the REAL enhancement seam (the exact B30 surface). _enhancePrompt is the private owner of the
- // enhance step; call it through the service instance so the whole real gate + generateStandalone run.
- const enhance = (imageGenerationService as any)._enhancePrompt.bind(imageGenerationService);
- const enhanced: string = await enhance({ prompt: 'a cat' }, 20);
+ // Drive the REAL enhancement seam (the exact B30 surface): the image service's enhance step, so the
+ // whole real gate + generateStandalone run.
+ const enhanced: string = await enhanceImagePrompt({ prompt: 'a cat' }, () => {});
// Terminal artifact: the remote model's rewritten prompt reached the caller (would be fed downstream to
// image generation). On HEAD the gate skips remote → returns the original "a cat".
diff --git a/__tests__/integration/chats/chatDeleteImageFailure.rendered.test.tsx b/__tests__/integration/chats/chatDeleteImageFailure.rendered.test.tsx
new file mode 100644
index 000000000..46fbfe6c2
--- /dev/null
+++ b/__tests__/integration/chats/chatDeleteImageFailure.rendered.test.tsx
@@ -0,0 +1,121 @@
+/**
+ * Deleting a chat whose image file cannot be removed must say so, and must keep that image in the
+ * Gallery so the user can delete it again. Records go only after their file is gone.
+ *
+ * Real ChatsListScreen and GalleryScreen, real chat and app stores. The device filesystem and the
+ * diffusion module are the in-memory native boundary; the filesystem refuses one unlink, as a
+ * locked or permission-protected file does on device.
+ */
+import {
+ installNativeBoundary,
+ requireRTL,
+} from '../../harness/nativeBoundary';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({
+ navigate: () => {},
+ goBack: () => {},
+ setOptions: () => {},
+ addListener: () => () => {},
+ }),
+ useRoute: () => ({ params: {} }),
+ useFocusEffect: () => {},
+ useIsFocused: () => true,
+}));
+
+type DeleteGesture = 'swipe delete' | 'select and delete';
+
+describe.each(['swipe delete', 'select and delete'])('Deleting a chat (%s) when an image file cannot be removed', gesture => {
+ it('reports the image that stayed, keeps it in the Gallery, and the Gallery can delete it', async () => {
+ const boundary = installNativeBoundary({ fs: true });
+ const fs = boundary.fs!;
+ const AsyncStorage = require('@react-native-async-storage/async-storage');
+ await AsyncStorage.clear();
+ const updatedAt = '2026-09-15T12:00:00.000Z';
+ await AsyncStorage.setItem(
+ 'local-llm-chat-storage',
+ JSON.stringify({
+ state: {
+ conversations: [{
+ id: 'chat-trip',
+ title: 'Trip photos',
+ modelId: 'remote-model',
+ messages: [{ id: 'm1', role: 'user', content: 'Trip photos', timestamp: Date.parse(updatedAt) }],
+ createdAt: updatedAt,
+ updatedAt,
+ }],
+ activeConversationId: null,
+ },
+ version: 2,
+ }),
+ );
+
+ const React = require('react');
+ const rtl = requireRTL();
+ const { useChatStore } = require('../../../src/stores/chatStore');
+ const { useAppStore } = require('../../../src/stores/appStore');
+ await useChatStore.persist.rehydrate();
+
+ const imagesDir = `${fs.DocumentDirectoryPath}/generated_images`;
+ const saveImage = (id: string) => {
+ const imagePath = `${imagesDir}/${id}.jpg`;
+ fs.seedFile(imagePath, 2048);
+ useAppStore.getState().addGeneratedImage({
+ id, prompt: `a lake at dawn ${id}`, imagePath, width: 512, height: 512, steps: 1, seed: 1,
+ modelId: 'remote-model', createdAt: updatedAt, conversationId: 'chat-trip',
+ });
+ return imagePath;
+ };
+ const lockedImage = saveImage('img-locked');
+ const freeImage = saveImage('img-free');
+
+ // The device refuses to remove one file.
+ const unlink = fs.module.unlink.getMockImplementation()!;
+ let locked = true;
+ fs.module.unlink.mockImplementation(async (path: string) => {
+ if (locked && path === lockedImage) throw new Error('EACCES: permission denied');
+ return unlink(path);
+ });
+
+ const { ChatsListScreen } = require('../../../src/screens/ChatsListScreen');
+ const chats = rtl.render(React.createElement(ChatsListScreen));
+ expect(chats.getByText('Trip photos')).toBeTruthy();
+
+ if (gesture === 'swipe delete') {
+ rtl.fireEvent.press(chats.getByLabelText('Delete Trip photos'));
+ } else {
+ rtl.fireEvent.press(chats.getByLabelText('Select chats'));
+ rtl.fireEvent.press(chats.getByText('Trip photos'));
+ rtl.fireEvent.press(chats.getByLabelText('Delete selected chats'));
+ }
+ await rtl.act(() => new Promise(resolve => setTimeout(resolve, 350)));
+ await rtl.act(async () => { rtl.fireEvent.press(chats.getByText('Delete')); });
+
+ // The chat is gone, but the user is told one image stayed and where to find it.
+ expect(await chats.findByText('Some images were not deleted')).toBeTruthy();
+ expect(chats.getByText(/1 image from the deleted chat could not be removed/)).toBeTruthy();
+ expect(chats.queryByText('Trip photos')).toBeNull();
+ expect(await fs.exists(freeImage)).toBe(false);
+ expect(await fs.exists(lockedImage)).toBe(true);
+ chats.unmount();
+
+ // Retry from the Gallery: the image that stayed is listed, and deleting it now succeeds.
+ locked = false;
+ const { GalleryScreen } = require('../../../src/screens/GalleryScreen');
+ const gallery = rtl.render(React.createElement(GalleryScreen));
+ rtl.fireEvent.press(await gallery.findByTestId('gallery-image-img-locked'));
+ expect(gallery.queryByTestId('gallery-image-img-free')).toBeNull();
+ rtl.fireEvent.press(gallery.getByText('Delete'));
+ await gallery.findByText('Delete Image');
+ await rtl.act(async () => {
+ rtl.fireEvent.press(gallery.getAllByText('Delete')[1]);
+ });
+
+ await rtl.waitFor(() => {
+ expect(gallery.queryByTestId('gallery-image-img-locked')).toBeNull();
+ expect(gallery.getByText('No generated images yet')).toBeTruthy();
+ });
+ expect(await fs.exists(lockedImage)).toBe(false);
+ await rtl.act(() => new Promise(resolve => setTimeout(resolve, 250)));
+ });
+});
diff --git a/__tests__/integration/chats/chatScreenDeleteImageFailure.rendered.test.tsx b/__tests__/integration/chats/chatScreenDeleteImageFailure.rendered.test.tsx
new file mode 100644
index 000000000..eef61f43a
--- /dev/null
+++ b/__tests__/integration/chats/chatScreenDeleteImageFailure.rendered.test.tsx
@@ -0,0 +1,96 @@
+/**
+ * Deleting the open chat from its own menu when one of its image files cannot be removed. The user
+ * is told the image stayed, before the chat screen closes, and the image is still in the Gallery
+ * where deleting it again works.
+ *
+ * Real ChatScreen (image drawn through the real image-mode toggle and send), real chat menu and
+ * confirm alert, real GalleryScreen. Fakes only at the network (fetch) and the native file system,
+ * which refuses one unlink as a locked or permission-protected file does on device.
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+import {
+ REMOTE_IMAGE_URL,
+ chooseRemoteImageModel,
+ clearRemoteServers,
+ serveImageGeneration,
+} from '../../harness/remoteImageServer';
+
+let mockGoBacks = 0;
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({
+ navigate: () => {},
+ goBack: () => { mockGoBacks += 1; },
+ setOptions: () => {},
+ addListener: () => () => {},
+ }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {},
+ useIsFocused: () => true,
+}));
+
+describe('deleting the open chat when an image file cannot be removed', () => {
+ let server: ReturnType;
+
+ afterEach(() => {
+ server.restore();
+ clearRemoteServers();
+ });
+
+ it('says the image stayed before leaving, and the Gallery can delete it', async () => {
+ const h = await setupChatScreen({ engine: 'llama' });
+ server = serveImageGeneration();
+ const fs = h.boundary.fs!;
+ fs.serveRemoteFile(REMOTE_IMAGE_URL, { body: 'synthetic-png-bytes', headers: { 'Content-Type': 'image/png' } });
+
+ // Draw an image in this chat through the real gestures.
+ await chooseRemoteImageModel(h);
+ await h.tapSend('draw a lighthouse at dusk');
+ await h.rtl.waitFor(() => { expect(h.view!.queryByTestId('generated-image')).not.toBeNull(); });
+ const imagesDir = `${fs.DocumentDirectoryPath}/generated_images`;
+ const [imageFile] = fs.listFiles(imagesDir);
+ expect(imageFile).toBeDefined();
+
+ // The device refuses to remove that file.
+ const unlink = fs.module.unlink.getMockImplementation()!;
+ let locked = true;
+ fs.module.unlink.mockImplementation(async (path: string) => {
+ if (locked && path === imageFile) throw new Error('EACCES: permission denied');
+ return unlink(path);
+ });
+
+ // Delete the chat from its own menu and confirm.
+ h.rtl.fireEvent.press(await h.rtl.waitFor(() => h.view!.getByTestId('chat-settings-icon')));
+ h.rtl.fireEvent.press(await h.rtl.waitFor(() => h.view!.getByText(/Delete Chat|Delete Conversation/)));
+ const confirm = await h.rtl.waitFor(() => h.view!.getByText(/^Delete$/));
+ await h.rtl.act(async () => { h.rtl.fireEvent.press(confirm); });
+
+ // The user is told before the screen closes.
+ expect(await h.view!.findByText('Some images were not deleted')).toBeTruthy();
+ expect(h.view!.getByText(/1 image from the deleted chat could not be removed/)).toBeTruthy();
+ expect(mockGoBacks).toBe(0);
+ expect(await fs.exists(imageFile)).toBe(true);
+
+ await h.rtl.act(async () => { h.rtl.fireEvent.press(h.view!.getByText('OK')); });
+ expect(mockGoBacks).toBe(1);
+ expect(h.view!.queryByText('Some images were not deleted')).toBeNull();
+ h.view!.unmount();
+
+ // Retry from the Gallery: the image that stayed is listed, and deleting it now succeeds.
+ locked = false;
+ const React = require('react');
+ const { GalleryScreen } = require('../../../src/screens/GalleryScreen');
+ const gallery = h.rtl.render(React.createElement(GalleryScreen));
+ const tiles = await gallery.findAllByTestId(/^gallery-image-/);
+ expect(tiles).toHaveLength(1);
+ h.rtl.fireEvent.press(tiles[0]);
+ h.rtl.fireEvent.press(gallery.getByText('Delete'));
+ await gallery.findByText('Delete Image');
+ await h.rtl.act(async () => { h.rtl.fireEvent.press(gallery.getAllByText('Delete')[1]); });
+
+ await h.rtl.waitFor(() => {
+ expect(gallery.queryAllByTestId(/^gallery-image-/)).toHaveLength(0);
+ expect(gallery.getByText('No generated images yet')).toBeTruthy();
+ });
+ expect(await fs.exists(imageFile)).toBe(false);
+ });
+});
diff --git a/__tests__/integration/chats/chatSearchBulkDelete.rendered.test.tsx b/__tests__/integration/chats/chatSearchBulkDelete.rendered.test.tsx
index 96c7c91cc..c12f65744 100644
--- a/__tests__/integration/chats/chatSearchBulkDelete.rendered.test.tsx
+++ b/__tests__/integration/chats/chatSearchBulkDelete.rendered.test.tsx
@@ -81,7 +81,8 @@ describe('Chats list search and bulk delete', () => {
rtl.fireEvent.press(chats.getByLabelText('Delete selected chats'));
expect(chats.getByText('Delete Chats')).toBeTruthy();
await rtl.act(() => new Promise(resolve => setTimeout(resolve, 350)));
- rtl.fireEvent.press(chats.getByText('Delete'));
+ // Deleting waits for each chat's image files to be removed first.
+ await rtl.act(async () => { rtl.fireEvent.press(chats.getByText('Delete')); });
await rtl.waitFor(() => expect(chats.queryByText('Aurora notes 1')).toBeNull());
rtl.fireEvent.press(chats.getByLabelText('Clear chat search'));
diff --git a/__tests__/integration/chats/deleteChatDuringImageGeneration.rendered.test.tsx b/__tests__/integration/chats/deleteChatDuringImageGeneration.rendered.test.tsx
new file mode 100644
index 000000000..ea9416618
--- /dev/null
+++ b/__tests__/integration/chats/deleteChatDuringImageGeneration.rendered.test.tsx
@@ -0,0 +1,120 @@
+/**
+ * Deleting a chat while it is still drawing an image. The drawing stops, and nothing from it lands
+ * in the Gallery or on disk afterwards, whether the chat is deleted from its own menu or from the
+ * chats list. A result that still arrives for a chat that is gone is not saved either.
+ *
+ * Real ChatScreen (image drawn through the real image-mode toggle and send), real chat menu, real
+ * ChatsListScreen and real GalleryScreen. Fakes only at the network (fetch, held open until the
+ * test releases it) and the native file system.
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+import {
+ REMOTE_IMAGE_URL,
+ chooseRemoteImageModel,
+ clearRemoteServers,
+ holdImageGeneration,
+} from '../../harness/remoteImageServer';
+
+let mockGoBacks = 0;
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({
+ navigate: () => {},
+ goBack: () => { mockGoBacks += 1; },
+ setOptions: () => {},
+ addListener: () => () => {},
+ }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {},
+ useIsFocused: () => true,
+}));
+
+type Harness = Awaited>;
+
+describe('deleting a chat while its image is being drawn', () => {
+ let server: ReturnType;
+
+ afterEach(() => {
+ server.release();
+ server.restore();
+ clearRemoteServers();
+ });
+
+ /** Start drawing an image in a new chat and leave it in progress. */
+ async function startDrawing(): Promise {
+ const h = await setupChatScreen({ engine: 'llama' });
+ server = holdImageGeneration();
+ h.boundary.fs!.serveRemoteFile(REMOTE_IMAGE_URL, {
+ body: 'synthetic-png-bytes',
+ headers: { 'Content-Type': 'image/png' },
+ });
+ await chooseRemoteImageModel(h);
+ await h.tapSend('draw a lighthouse at dusk');
+ expect(await h.view!.findByText('Generating Image')).toBeTruthy();
+ expect(h.view!.queryByTestId('generated-image')).toBeNull();
+ return h;
+ }
+
+ /** What the user finds afterwards: no image in the Gallery and no image file on disk. */
+ async function expectNoImageLeft(h: Harness): Promise {
+ const React = require('react');
+ const { GalleryScreen } = require('../../../src/screens/GalleryScreen');
+ const gallery = h.rtl.render(React.createElement(GalleryScreen));
+ expect(await gallery.findByText('No generated images yet')).toBeTruthy();
+ expect(gallery.queryAllByTestId(/^gallery-image-/)).toHaveLength(0);
+ const fs = h.boundary.fs!;
+ expect(fs.listFiles(`${fs.DocumentDirectoryPath}/generated_images`)).toHaveLength(0);
+ gallery.unmount();
+ }
+
+ it('from its own menu: the drawing stops and no image is saved', async () => {
+ const h = await startDrawing();
+
+ h.rtl.fireEvent.press(await h.rtl.waitFor(() => h.view!.getByTestId('chat-settings-icon')));
+ h.rtl.fireEvent.press(await h.rtl.waitFor(() => h.view!.getByText(/Delete Chat|Delete Conversation/)));
+ const confirm = await h.rtl.waitFor(() => h.view!.getByText(/^Delete$/));
+ await h.rtl.act(async () => { h.rtl.fireEvent.press(confirm); });
+
+ // The drawing ended with the chat, without waiting for the server to answer.
+ await h.rtl.waitFor(() => { expect(mockGoBacks).toBe(1); });
+ expect(server.aborted).toBe(1);
+ expect(h.view!.queryByText('Generating Image')).toBeNull();
+ h.view!.unmount();
+
+ server.release();
+ await expectNoImageLeft(h);
+ });
+
+ it('from the chats list: the drawing stops and no image is saved', async () => {
+ const h = await startDrawing();
+ h.view!.unmount();
+
+ const React = require('react');
+ const { ChatsListScreen } = require('../../../src/screens/ChatsListScreen');
+ const chats = h.rtl.render(React.createElement(ChatsListScreen));
+ h.rtl.fireEvent.press(await chats.findByLabelText(/^Delete /));
+ await chats.findByText('Delete Chat');
+ await h.rtl.act(async () => { h.rtl.fireEvent.press(chats.getByText(/^Delete$/)); });
+
+ await h.rtl.waitFor(() => { expect(chats.queryAllByTestId(/^conversation-item-/)).toHaveLength(0); });
+ expect(server.aborted).toBe(1);
+ chats.unmount();
+
+ server.release();
+ await expectNoImageLeft(h);
+ });
+
+ it('a result that arrives after the chat is gone is not saved', async () => {
+ const h = await startDrawing();
+ const { useChatStore } = require('../../../src/stores');
+ const conversationId = useChatStore.getState().activeConversationId;
+
+ // The chat goes away without the chat screen's cleanup, and the server answers afterwards.
+ await h.rtl.act(async () => { useChatStore.getState().deleteConversation(conversationId); });
+ expect(server.aborted).toBe(0);
+ await h.rtl.act(async () => { server.release(); });
+ await h.rtl.waitFor(() => { expect(h.view!.queryByText('Generating Image')).toBeNull(); });
+ h.view!.unmount();
+
+ await expectNoImageLeft(h);
+ });
+});
diff --git a/__tests__/integration/chats/deleteChatDuringPromptEnhancement.rendered.test.tsx b/__tests__/integration/chats/deleteChatDuringPromptEnhancement.rendered.test.tsx
new file mode 100644
index 000000000..bdfdb9d4b
--- /dev/null
+++ b/__tests__/integration/chats/deleteChatDuringPromptEnhancement.rendered.test.tsx
@@ -0,0 +1,79 @@
+/**
+ * Deleting a chat while its image prompt is still being enhanced. The text model never answers, yet
+ * the chat is deleted promptly: the enhancement request is stopped, the image is never requested,
+ * and nothing lands in the Gallery or on disk.
+ *
+ * Real ChatScreen (image drawn through the real image-mode toggle and send), real chat menu and real
+ * GalleryScreen. Fakes only at the device boundary: the llama.rn completion, held open before its
+ * first token; the image server's fetch; and the native file system.
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+import {
+ REMOTE_IMAGE_URL,
+ chooseRemoteImageModel,
+ clearRemoteServers,
+ holdImageGeneration,
+} from '../../harness/remoteImageServer';
+
+let mockGoBacks = 0;
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({
+ navigate: () => {},
+ goBack: () => { mockGoBacks += 1; },
+ setOptions: () => {},
+ addListener: () => () => {},
+ }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {},
+ useIsFocused: () => true,
+}));
+
+describe('deleting a chat while its image prompt is being enhanced', () => {
+ let server: ReturnType;
+
+ afterEach(() => {
+ server.release();
+ server.restore();
+ clearRemoteServers();
+ });
+
+ it('stops the enhancement, deletes the chat promptly and saves no image', async () => {
+ const h = await setupChatScreen({ engine: 'llama' });
+ server = holdImageGeneration();
+ h.boundary.fs!.serveRemoteFile(REMOTE_IMAGE_URL, {
+ body: 'synthetic-png-bytes',
+ headers: { 'Content-Type': 'image/png' },
+ });
+ await chooseRemoteImageModel(h);
+ h.useAppStore.getState().updateSettings({ enhanceImagePrompts: true });
+ // The text model takes the enhancement request and never answers it.
+ h.boundary.llama!.scriptCompletion({
+ text: 'a lighthouse at dusk, warm light over a calm sea',
+ holdBeforeStream: true,
+ });
+ await h.tapSend('draw a lighthouse at dusk');
+ await h.rtl.waitFor(() => { expect(h.view!.queryAllByText(/Enhancing your prompt/i).length).toBeGreaterThan(0); });
+
+ h.rtl.fireEvent.press(await h.rtl.waitFor(() => h.view!.getByTestId('chat-settings-icon')));
+ h.rtl.fireEvent.press(await h.rtl.waitFor(() => h.view!.getByText(/Delete Chat|Delete Conversation/)));
+ const confirm = await h.rtl.waitFor(() => h.view!.getByText(/^Delete$/));
+ await h.rtl.act(async () => { h.rtl.fireEvent.press(confirm); });
+
+ // The chat is gone without the text model ever answering, because its request was stopped.
+ await h.rtl.waitFor(() => { expect(mockGoBacks).toBe(1); });
+ const context = await h.boundary.llama!.module.initLlama.mock.results.at(-1)!.value;
+ expect(context.stopCompletion).toHaveBeenCalled();
+ expect(server.requests).toBe(0);
+ expect(h.view!.queryAllByText(/Enhancing your prompt/i)).toHaveLength(0);
+ h.view!.unmount();
+
+ const React = require('react');
+ const { GalleryScreen } = require('../../../src/screens/GalleryScreen');
+ const gallery = h.rtl.render(React.createElement(GalleryScreen));
+ expect(await gallery.findByText('No generated images yet')).toBeTruthy();
+ expect(gallery.queryAllByTestId(/^gallery-image-/)).toHaveLength(0);
+ const fs = h.boundary.fs!;
+ expect(fs.listFiles(`${fs.DocumentDirectoryPath}/generated_images`)).toHaveLength(0);
+ gallery.unmount();
+ });
+});
diff --git a/__tests__/integration/image/cancelledImageDoesNotOverlapNext.rendered.test.tsx b/__tests__/integration/image/cancelledImageDoesNotOverlapNext.rendered.test.tsx
new file mode 100644
index 000000000..50fe3eb20
--- /dev/null
+++ b/__tests__/integration/image/cancelledImageDoesNotOverlapNext.rendered.test.tsx
@@ -0,0 +1,93 @@
+/**
+ * An image the user cancelled while the text model was still loading for prompt enhancement must
+ * stay cancelled. When the user asks for a second image before that load finishes, the first one
+ * must not wake up once the load completes: it must not enhance, request an image or take over the
+ * second image's progress. Stopping the second image then stops the second image's own request.
+ *
+ * Real ChatScreen (image drawn through the real image-mode toggle and send, stopped through the
+ * image card's real X). Fakes only at the device boundary: llama.rn (its load held in the post-init
+ * window, its completion held before the first token), the image server's fetch and the native file
+ * system.
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+import {
+ REMOTE_IMAGE_URL,
+ chooseRemoteImageModel,
+ clearRemoteServers,
+ holdImageGeneration,
+} from '../../harness/remoteImageServer';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {},
+ useIsFocused: () => true,
+}));
+
+describe('a cancelled image does not overlap the next one', () => {
+ let server: ReturnType;
+
+ afterEach(() => {
+ server.release();
+ server.restore();
+ clearRemoteServers();
+ });
+
+ it('keeps the first image stopped after its text model loads, and stops the second on its own X', async () => {
+ const h = await setupChatScreen({ engine: 'llama' });
+ server = holdImageGeneration();
+ h.boundary.fs!.serveRemoteFile(REMOTE_IMAGE_URL, {
+ body: 'synthetic-png-bytes',
+ headers: { 'Content-Type': 'image/png' },
+ });
+ await chooseRemoteImageModel(h);
+ h.useAppStore.getState().updateSettings({ enhanceImagePrompts: true });
+ const llama = h.boundary.llama!;
+
+ // The user ejects the text model from the In Memory list, so the next enhancement has to load it.
+ const React = require('react');
+ const { ModelsManagerSheet } = require('../../../src/components/models/ModelsManagerSheet');
+ const sheet = h.rtl.render(React.createElement(ModelsManagerSheet, {
+ visible: true, onClose: () => {}, labels: { text: '-', image: '-', voice: '-', speech: '-' },
+ loadingState: { isLoading: false }, isEjecting: false, hasActiveModel: false,
+ onOpenRow: () => {}, onEject: () => {},
+ }));
+ await h.rtl.waitFor(() => { expect(sheet.queryByTestId('models-row-text-ram')).not.toBeNull(); });
+ h.rtl.fireEvent.press(sheet.getByTestId('models-row-text-eject'));
+ // The sheet re-reads residency on a 300 ms poll.
+ await h.rtl.act(async () => { await h.settle(400); });
+ expect(sheet.queryByTestId('models-row-text-ram')).toBeNull();
+ sheet.unmount();
+
+ // Image A: the text model starts loading for enhancement and stays inside its load window.
+ llama.scriptMultimodalHold();
+ await h.tapSend('draw a lighthouse at dusk');
+ await h.rtl.waitFor(() => { expect(llama.multimodalHoldActive()).toBe(true); });
+ await h.rtl.waitFor(() => { expect(h.view!.queryAllByText(/Loading text model/i).length).toBeGreaterThan(0); });
+
+ // The user stops A while the model is still loading.
+ await h.pressImageCardStop();
+ await h.rtl.waitFor(() => { expect(h.view!.queryAllByText(/Loading text model/i)).toHaveLength(0); });
+
+ // Image B: its enhancement request will be held before the first token.
+ llama.scriptCompletion({ text: 'a pine forest in morning fog, soft light', holdBeforeStream: true });
+ await h.tapSend('draw a pine forest');
+
+ // A's load completes. Only B goes on to enhance.
+ await h.rtl.act(async () => { llama.releaseMultimodalHold(); });
+ await h.rtl.waitFor(() => { expect(h.view!.queryAllByText(/Enhancing your prompt/i).length).toBeGreaterThan(0); });
+ await h.rtl.act(async () => { await h.settle(300); });
+ expect(llama.calls.completion).toHaveLength(1);
+ expect(server.requests).toBe(0);
+
+ // Stopping B stops B's own enhancement request, and no image is requested for either.
+ await h.pressImageCardStop();
+ const context = await llama.module.initLlama.mock.results.at(-1)!.value;
+ await h.rtl.waitFor(() => { expect(context.stopCompletion).toHaveBeenCalled(); });
+ await h.rtl.waitFor(() => { expect(h.view!.queryAllByText(/Enhancing your prompt/i)).toHaveLength(0); });
+ expect(llama.calls.completion).toHaveLength(1);
+ expect(server.requests).toBe(0);
+ const fs = h.boundary.fs!;
+ expect(fs.listFiles(`${fs.DocumentDirectoryPath}/generated_images`)).toHaveLength(0);
+ });
+});
diff --git a/__tests__/integration/image/remoteImageTransfer.rendered.test.tsx b/__tests__/integration/image/remoteImageTransfer.rendered.test.tsx
new file mode 100644
index 000000000..3dc87fc42
--- /dev/null
+++ b/__tests__/integration/image/remoteImageTransfer.rendered.test.tsx
@@ -0,0 +1,122 @@
+/**
+ * Remote image generation: the image is created on a remote server, then the phone transfers the
+ * file from the URL the server returns.
+ *
+ * - Cancel during that transfer stops it and never saves the image, even when the transfer
+ * finishes as the cancel lands.
+ * - A failed transfer leaves no file behind and offers Retry, which then draws the image.
+ *
+ * Real ChatScreen, real image-mode toggle, real remote model picker, real imageGenerationService
+ * and remote image runner. Fakes only at the network (fetch) and native file system (RNFS) edges.
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+import type { RemoteTransfer } from '../../harness/nativeFileSystem';
+import { REMOTE_IMAGE_URL, chooseRemoteImageModel, serveImageGeneration } from '../../harness/remoteImageServer';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {},
+ useIsFocused: () => true,
+}));
+
+const IMAGE_URL = REMOTE_IMAGE_URL;
+const PNG_BODY = 'synthetic-png-bytes';
+
+type Harness = Awaited>;
+
+describe('remote image transfer', () => {
+ let h: Harness;
+ let server: ReturnType;
+ const imagesDir = () => `${h.boundary.fs!.DocumentDirectoryPath}/generated_images`;
+
+ beforeEach(async () => {
+ h = await setupChatScreen({ engine: 'llama' });
+ server = serveImageGeneration();
+ });
+
+ afterEach(() => {
+ server.restore();
+ require('../../../src/stores/remoteServerStore').useRemoteServerStore.getState().clearAllServers();
+ });
+
+ async function startHeldTransfer(honorsStop: boolean): Promise {
+ const transfer = h.boundary.fs!.serveRemoteFile(IMAGE_URL, {
+ body: PNG_BODY, headers: { 'Content-Type': 'image/png' }, hold: true, honorsStop,
+ });
+ await chooseRemoteImageModel(h);
+ await h.tapSend('draw a lighthouse at dusk');
+ await h.rtl.waitFor(() => { expect(transfer.held()).toBe(true); });
+ // BEFORE: the transfer is under way. Part of the file is on disk and no image is shown.
+ expect(h.boundary.fs!.listFiles(imagesDir())).toHaveLength(1);
+ expect(h.view!.queryByTestId('generated-image')).toBeNull();
+ return transfer;
+ }
+
+ it('cancel during the transfer stops it and leaves no image and no file', async () => {
+ await startHeldTransfer(true);
+
+ await h.pressImageCardStop();
+ await h.settle(200);
+
+ expect(h.view!.queryByTestId('generated-image')).toBeNull();
+ expect(h.boundary.fs!.listFiles(imagesDir())).toEqual([]);
+ });
+
+ it('a transfer that finishes as cancel lands is not saved', async () => {
+ const transfer = await startHeldTransfer(false);
+
+ await h.pressImageCardStop();
+ await h.rtl.act(async () => { transfer.release(); });
+ await h.settle(200);
+
+ expect(h.view!.queryByTestId('generated-image')).toBeNull();
+ expect(h.boundary.fs!.listFiles(imagesDir())).toEqual([]);
+ });
+
+ it('cancel while the image folder is being made never starts the transfer', async () => {
+ // Review finding: an abort that landed before the transfer began was never heard.
+ h.boundary.fs!.serveRemoteFile(IMAGE_URL, { body: PNG_BODY, headers: { 'Content-Type': 'image/png' } });
+ const fs = h.boundary.fs!;
+ const rnfs = fs.module;
+ let folderMade: () => void = () => {};
+ let making = false;
+ const make = rnfs.mkdir.getMockImplementation()!;
+ rnfs.mkdir.mockImplementation(async (path: string) => {
+ if (path === imagesDir() && !making) {
+ making = true;
+ await new Promise((resolve) => { folderMade = resolve; });
+ }
+ return make(path);
+ });
+ await chooseRemoteImageModel(h);
+ await h.tapSend('draw a lighthouse at dusk');
+ await h.rtl.waitFor(() => { expect(making).toBe(true); });
+
+ await h.pressImageCardStop();
+ await h.rtl.act(async () => { folderMade(); });
+ await h.settle(200);
+
+ expect(rnfs.downloadFile).not.toHaveBeenCalled();
+ expect(h.view!.queryByTestId('generated-image')).toBeNull();
+ expect(fs.listFiles(imagesDir())).toEqual([]);
+ });
+
+ it('a failed transfer leaves no file and Retry draws the image', async () => {
+ h.boundary.fs!.serveRemoteFile(IMAGE_URL, { statusCode: 503, body: 'busy' });
+ await chooseRemoteImageModel(h);
+ await h.tapSend('draw a lighthouse at dusk');
+
+ const retry = await h.rtl.waitFor(() => h.view!.getByTestId('model-failure-retry-image'));
+ expect(h.view!.queryByTestId('generated-image')).toBeNull();
+ expect(h.boundary.fs!.listFiles(imagesDir())).toEqual([]);
+
+ h.boundary.fs!.serveRemoteFile(IMAGE_URL, { body: PNG_BODY, headers: { 'Content-Type': 'image/png' } });
+ await h.rtl.act(async () => { h.rtl.fireEvent.press(retry); });
+
+ await h.rtl.waitFor(() => { expect(h.view!.queryByTestId('generated-image')).not.toBeNull(); });
+ expect(h.view!.queryByTestId('model-failure-image')).toBeNull();
+ expect(h.boundary.fs!.listFiles(imagesDir())).toHaveLength(1);
+ expect(server.requests).toBe(2);
+ });
+});
diff --git a/__tests__/integration/pro/mcpSameNameToolRouting.rendered.test.tsx b/__tests__/integration/pro/mcpSameNameToolRouting.rendered.test.tsx
new file mode 100644
index 000000000..0d072b09d
--- /dev/null
+++ b/__tests__/integration/pro/mcpSameNameToolRouting.rendered.test.tsx
@@ -0,0 +1,100 @@
+/**
+ * Two MCP servers publish a tool with the same name. The user turns that tool on for the second
+ * server. The switch must stay on that server only, and running the tool must reach that server.
+ *
+ * Real McpServersScreen + McpToolsScreen on a real native stack, real connect switch, real
+ * mcpService/McpClient/store. Only the HTTP transport is faked (harness/mcpHttpFake).
+ */
+import React from 'react';
+import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
+import { NavigationContainer } from '@react-navigation/native';
+import { createNativeStackNavigator } from '@react-navigation/native-stack';
+import { McpServersScreen } from '@offgrid/pro/ui/McpServersScreen';
+import { McpToolsScreen } from '@offgrid/pro/ui/McpToolsScreen';
+import { useMcpStore } from '@offgrid/pro/mcp/mcpStore';
+import { disconnectServer } from '@offgrid/pro/mcp/mcpService';
+import { McpToolExtension } from '@offgrid/pro/mcp/McpToolExtension';
+import { installMcpHttpFake, type McpHttpFake } from '../../harness/mcpHttpFake';
+
+jest.unmock('@react-navigation/native');
+jest.mock('react-native-tcp-socket', () => {
+ const { createNativeTcpBoundary } = require('../../utils/nativeSyncBoundaries');
+ return { __esModule: true, default: createNativeTcpBoundary() };
+});
+jest.mock('react-native-zeroconf', () => {
+ const { createNativeDiscoveryBoundary } = require('../../utils/nativeSyncBoundaries');
+ return { __esModule: true, default: createNativeDiscoveryBoundary() };
+});
+
+const Stack = createNativeStackNavigator();
+const tool = (name: string) => ({ name, description: `${name} things`, inputSchema: { type: 'object', properties: {} } });
+
+const ALPHA = { id: 'srv-alpha', name: 'Alpha Docs', url: 'https://alpha.example.test/mcp' };
+const BETA = { id: 'srv-beta', name: 'Beta Wiki', url: 'https://beta.example.test/mcp' };
+
+describe('same-named MCP tools stay on the server the user picked', () => {
+ let fake: McpHttpFake;
+
+ beforeEach(() => {
+ fake = installMcpHttpFake({
+ [ALPHA.url]: { label: 'Alpha', tools: [tool('search')] },
+ [BETA.url]: { label: 'Beta', tools: [tool('search'), tool('fetch')] },
+ });
+ });
+
+ afterEach(() => {
+ [ALPHA.id, BETA.id].forEach(id => {
+ disconnectServer(id);
+ useMcpStore.getState().removeServer(id);
+ });
+ fake.restore();
+ });
+
+ it('enabling search on Beta turns it off on Alpha and runs it on Beta', async () => {
+ // Saved server configs are the precondition; connecting happens through the real switch.
+ useMcpStore.getState().addServer(ALPHA);
+ useMcpStore.getState().addServer(BETA);
+
+ const ui = render(
+
+
+
+
+
+ ,
+ );
+
+ expect(ui.getByTestId(`mcp-server-status-${ALPHA.id}`)).toHaveTextContent('Inactive');
+ fireEvent(ui.getByTestId(`mcp-server-toggle-${ALPHA.id}`), 'valueChange', true);
+ await waitFor(() => expect(ui.getByTestId(`mcp-server-status-${ALPHA.id}`)).toHaveTextContent('Active'));
+ fireEvent(ui.getByTestId(`mcp-server-toggle-${BETA.id}`), 'valueChange', true);
+ await waitFor(() => expect(ui.getByTestId(`mcp-server-status-${BETA.id}`)).toHaveTextContent('Active'));
+
+ // Alpha connected first, so search runs there. Beta's copy is off; its new tool is on.
+ expect(ui.getByTestId(`mcp-server-tool-count-${ALPHA.id}`)).toHaveTextContent('1/1 tools');
+ expect(ui.getByTestId(`mcp-server-tool-count-${BETA.id}`)).toHaveTextContent('1/2 tools');
+
+ // Open Beta's tools and turn search on there.
+ fireEvent.press(ui.getAllByText('Edit Tools')[1]);
+ expect(await ui.findByText('Beta Wiki')).toBeTruthy();
+ const { Switch } = require('react-native');
+ const betaSwitches = () => ui.UNSAFE_getAllByType(Switch).filter(sw => sw.props.testID === undefined);
+ expect(betaSwitches().map(sw => sw.props.value)).toEqual([false, true]);
+ fireEvent(betaSwitches()[0], 'valueChange', true);
+ expect(betaSwitches().map(sw => sw.props.value)).toEqual([true, true]);
+
+ // Back on the list, only Beta claims search now.
+ fireEvent.press(ui.getByLabelText('Back'));
+ await waitFor(() => expect(ui.getByTestId(`mcp-server-tool-count-${ALPHA.id}`)).toHaveTextContent('0/1 tools'));
+ expect(ui.getByTestId(`mcp-server-tool-count-${BETA.id}`)).toHaveTextContent('2/2 tools');
+
+ // The tool loop's call reaches Beta, the server the user picked.
+ let result: Awaited> | undefined;
+ await act(async () => {
+ result = await McpToolExtension.execute({ id: 'call-1', name: 'search', arguments: {} });
+ });
+ expect(result?.error).toBeUndefined();
+ expect(result?.content).toBe('Beta ran search');
+ expect(fake.calls).toEqual([{ url: BETA.url, tool: 'search' }]);
+ });
+});
diff --git a/__tests__/integration/pro/mcpToolOwnerLost.rendered.test.tsx b/__tests__/integration/pro/mcpToolOwnerLost.rendered.test.tsx
new file mode 100644
index 000000000..569e69e25
--- /dev/null
+++ b/__tests__/integration/pro/mcpToolOwnerLost.rendered.test.tsx
@@ -0,0 +1,131 @@
+/**
+ * A tool the user turned on for one MCP server must never switch on for a different server (a
+ * different account) by itself. Two ways that used to happen:
+ * 1. The chosen server stops listing the tool while no other server lists it. When another
+ * server later connects with a tool of the same name, that server's copy came up on.
+ * 2. The chosen server is removed while another server lists the tool. The other server's copy
+ * was off, but its next refresh treated it as a brand-new tool and turned it on.
+ * In both, the other server's copy must stay off until the user turns it on.
+ *
+ * Real McpServersScreen on a real native stack, real connect switch, real mcpService/McpClient/store.
+ * Only the HTTP transport (harness/mcpHttpFake) and the native confirm dialog are faked.
+ */
+import React from 'react';
+import { Alert } from 'react-native';
+import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
+import { NavigationContainer } from '@react-navigation/native';
+import { createNativeStackNavigator } from '@react-navigation/native-stack';
+import { McpServersScreen } from '@offgrid/pro/ui/McpServersScreen';
+import { McpToolsScreen } from '@offgrid/pro/ui/McpToolsScreen';
+import { useMcpStore } from '@offgrid/pro/mcp/mcpStore';
+import { disconnectServer } from '@offgrid/pro/mcp/mcpService';
+import { McpToolExtension } from '@offgrid/pro/mcp/McpToolExtension';
+import { installMcpHttpFake, type FakeMcpServer, type McpHttpFake } from '../../harness/mcpHttpFake';
+
+jest.unmock('@react-navigation/native');
+jest.mock('react-native-tcp-socket', () => {
+ const { createNativeTcpBoundary } = require('../../utils/nativeSyncBoundaries');
+ return { __esModule: true, default: createNativeTcpBoundary() };
+});
+jest.mock('react-native-zeroconf', () => {
+ const { createNativeDiscoveryBoundary } = require('../../utils/nativeSyncBoundaries');
+ return { __esModule: true, default: createNativeDiscoveryBoundary() };
+});
+
+const Stack = createNativeStackNavigator();
+const tool = (name: string) => ({ name, description: `${name} things`, inputSchema: { type: 'object', properties: {} } });
+
+const ALPHA = { id: 'srv-alpha', name: 'Alpha Docs', url: 'https://alpha.example.test/mcp' };
+const BETA = { id: 'srv-beta', name: 'Beta Wiki', url: 'https://beta.example.test/mcp' };
+
+describe('a tool never switches on for another server by itself', () => {
+ let fake: McpHttpFake;
+ let alpha: FakeMcpServer;
+ let beta: FakeMcpServer;
+
+ beforeEach(() => {
+ alpha = { label: 'Alpha', tools: [tool('search')] };
+ beta = { label: 'Beta', tools: [tool('search')] };
+ fake = installMcpHttpFake({ [ALPHA.url]: alpha, [BETA.url]: beta });
+ useMcpStore.getState().addServer(ALPHA);
+ useMcpStore.getState().addServer(BETA);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ [ALPHA.id, BETA.id].forEach(id => {
+ disconnectServer(id);
+ useMcpStore.getState().removeServer(id);
+ });
+ useMcpStore.setState({ enabledTools: [], knownToolNames: [], toolOwners: {} });
+ fake.restore();
+ });
+
+ const mount = () => render(
+
+
+
+
+
+ ,
+ );
+
+ const setActive = async (ui: ReturnType, id: string, on: boolean) => {
+ fireEvent(ui.getByTestId(`mcp-server-toggle-${id}`), 'valueChange', on);
+ await waitFor(() => expect(ui.getByTestId(`mcp-server-status-${id}`)).toHaveTextContent(on ? 'Active' : 'Inactive'));
+ };
+ const toolCount = (ui: ReturnType, id: string) => ui.getByTestId(`mcp-server-tool-count-${id}`);
+
+ it('keeps Beta search off after Alpha stops listing it and Beta connects later', async () => {
+ const ui = mount();
+
+ // Alpha connects first: its search is discovered and on.
+ await setActive(ui, ALPHA.id, true);
+ expect(toolCount(ui, ALPHA.id)).toHaveTextContent('1/1 tools');
+
+ // Alpha stops listing search while Beta is offline. A reconnect picks up the new list.
+ alpha.tools = [];
+ await setActive(ui, ALPHA.id, false);
+ await setActive(ui, ALPHA.id, true);
+ await waitFor(() => expect(toolCount(ui, ALPHA.id)).toHaveTextContent('0/0 tools'));
+
+ // Beta comes online with its own search. The user never chose it, so it is off.
+ await setActive(ui, BETA.id, true);
+ await waitFor(() => expect(toolCount(ui, BETA.id)).toHaveTextContent('0/1 tools'));
+
+ // And the tool loop cannot reach Beta's account.
+ await act(async () => {
+ await McpToolExtension.execute({ id: 'call-1', name: 'search', arguments: {} });
+ });
+ expect(fake.calls.filter(c => c.url === BETA.url)).toEqual([]);
+ });
+
+ it('keeps Beta search off after Alpha is removed and Beta refreshes', async () => {
+ const ui = mount();
+ await setActive(ui, ALPHA.id, true);
+ await setActive(ui, BETA.id, true);
+ // Alpha connected first, so search is on there and off on Beta.
+ expect(toolCount(ui, ALPHA.id)).toHaveTextContent('1/1 tools');
+ expect(toolCount(ui, BETA.id)).toHaveTextContent('0/1 tools');
+
+ // Remove Alpha through its delete control and confirm.
+ let confirmRemove: (() => void) | undefined;
+ jest.spyOn(Alert, 'alert').mockImplementation((_title, _message, buttons) => {
+ confirmRemove = buttons?.find(b => b.style === 'destructive')?.onPress as () => void;
+ });
+ fireEvent.press(ui.getByLabelText(`Remove ${ALPHA.name}`));
+ await act(async () => { confirmRemove?.(); });
+ await waitFor(() => expect(ui.queryByTestId(`mcp-server-card-${ALPHA.id}`)).toBeNull());
+ expect(toolCount(ui, BETA.id)).toHaveTextContent('0/1 tools');
+
+ // Beta refreshes. Its search is the one the user left off, not a new tool.
+ await setActive(ui, BETA.id, false);
+ await setActive(ui, BETA.id, true);
+ await waitFor(() => expect(toolCount(ui, BETA.id)).toHaveTextContent('0/1 tools'));
+
+ await act(async () => {
+ await McpToolExtension.execute({ id: 'call-1', name: 'search', arguments: {} });
+ });
+ expect(fake.calls.filter(c => c.url === BETA.url)).toEqual([]);
+ });
+});
diff --git a/__tests__/integration/pro/mcpToolsEmptyStates.rendered.test.tsx b/__tests__/integration/pro/mcpToolsEmptyStates.rendered.test.tsx
new file mode 100644
index 000000000..9824b88f8
--- /dev/null
+++ b/__tests__/integration/pro/mcpToolsEmptyStates.rendered.test.tsx
@@ -0,0 +1,134 @@
+/**
+ * The tools screen with no tools tells the user the server's real state and offers the one thing
+ * they can do: wait, connect, try again, or go back. It never claims "connect first" while the
+ * server is connected, connecting, or gone.
+ *
+ * Real McpServersScreen + McpToolsScreen on a real native stack, real connect switch and buttons,
+ * real mcpService/McpClient/store. Only the HTTP transport is faked (harness/mcpHttpFake).
+ */
+import React from 'react';
+import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
+import { NavigationContainer } from '@react-navigation/native';
+import { createNativeStackNavigator } from '@react-navigation/native-stack';
+import { McpServersScreen } from '@offgrid/pro/ui/McpServersScreen';
+import { McpToolsScreen } from '@offgrid/pro/ui/McpToolsScreen';
+import { useMcpStore } from '@offgrid/pro/mcp/mcpStore';
+import { disconnectServer } from '@offgrid/pro/mcp/mcpService';
+import { installMcpHttpFake, type McpHttpFake } from '../../harness/mcpHttpFake';
+
+jest.unmock('@react-navigation/native');
+jest.mock('react-native-tcp-socket', () => {
+ const { createNativeTcpBoundary } = require('../../utils/nativeSyncBoundaries');
+ return { __esModule: true, default: createNativeTcpBoundary() };
+});
+jest.mock('react-native-zeroconf', () => {
+ const { createNativeDiscoveryBoundary } = require('../../utils/nativeSyncBoundaries');
+ return { __esModule: true, default: createNativeDiscoveryBoundary() };
+});
+
+const Stack = createNativeStackNavigator();
+const tool = (name: string) => ({ name, description: `${name} things`, inputSchema: { type: 'object', properties: {} } });
+
+const DOCS = { id: 'srv-docs', name: 'Team Docs', url: 'https://docs.example.test/mcp' };
+const EMPTY = { id: 'srv-empty', name: 'Blank Server', url: 'https://blank.example.test/mcp' };
+
+function mountSettings() {
+ return render(
+
+
+
+
+
+ ,
+ );
+}
+
+async function connectFromList(ui: ReturnType, id: string) {
+ fireEvent(ui.getByTestId(`mcp-server-toggle-${id}`), 'valueChange', true);
+ await waitFor(() => expect(ui.getByTestId(`mcp-server-status-${id}`)).toHaveTextContent('Active'));
+}
+
+describe('tools screen empty states', () => {
+ let fake: McpHttpFake;
+
+ beforeEach(() => {
+ fake = installMcpHttpFake({
+ [DOCS.url]: { label: 'Docs', tools: [tool('search'), tool('fetch')] },
+ [EMPTY.url]: { label: 'Blank', tools: [] },
+ });
+ });
+
+ afterEach(() => {
+ [DOCS.id, EMPTY.id].forEach(id => {
+ disconnectServer(id);
+ useMcpStore.getState().removeServer(id);
+ });
+ fake.restore();
+ });
+
+ it('a connected server with no tools says so and Go back returns to the list', async () => {
+ useMcpStore.getState().addServer(EMPTY);
+ const ui = mountSettings();
+ await connectFromList(ui, EMPTY.id);
+
+ fireEvent.press(ui.getByText('Edit Tools'));
+ expect(await ui.findByText('Blank Server is connected but has no tools to offer.')).toBeTruthy();
+ expect(ui.queryByText(/connect the server first/)).toBeNull();
+ expect(ui.queryByTestId('mcp-tools-connect')).toBeNull();
+
+ fireEvent.press(ui.getByTestId('mcp-tools-go-back'));
+ await waitFor(() => expect(ui.queryByTestId('mcp-tools-empty')).toBeNull());
+ expect(ui.getByTestId(`mcp-server-status-${EMPTY.id}`)).toHaveTextContent('Active');
+ });
+
+ it('a dropped connection offers Connect, a failed connect offers Try again, and a held connect shows it is connecting', async () => {
+ useMcpStore.getState().addServer(DOCS);
+ const ui = mountSettings();
+ await connectFromList(ui, DOCS.id);
+ fireEvent.press(ui.getByText('Edit Tools'));
+ expect(await ui.findByText('search')).toBeTruthy();
+ expect(ui.queryByTestId('mcp-tools-empty')).toBeNull();
+
+ // The connection drops while the user is on the screen (the app disconnects it, as when Pro ends).
+ await act(async () => { disconnectServer(DOCS.id); });
+ expect(ui.getByText('Team Docs is not connected. Connect it to see its tools.')).toBeTruthy();
+ expect(ui.queryByText('search')).toBeNull();
+
+ // Connect while the server is unreachable: the screen says it failed and offers Try again.
+ fake.setDown(DOCS.url, true);
+ await act(async () => { fireEvent.press(ui.getByTestId('mcp-tools-connect')); });
+ expect(await ui.findByText('Could not connect to Team Docs.')).toBeTruthy();
+ expect(ui.queryByTestId('mcp-tools-connect')).toBeNull();
+
+ // Try again while the server is slow to answer: the user sees it connecting, with no button.
+ fake.setDown(DOCS.url, false);
+ const slow = fake.holdNext(DOCS.url);
+ await act(async () => { fireEvent.press(ui.getByTestId('mcp-tools-retry')); });
+ expect(await ui.findByText('Connecting to Team Docs')).toBeTruthy();
+ expect(ui.queryByTestId('mcp-tools-retry')).toBeNull();
+
+ await act(async () => { slow.release(); });
+ expect(await ui.findByText('search')).toBeTruthy();
+ expect(ui.getByText('fetch')).toBeTruthy();
+ expect(ui.queryByTestId('mcp-tools-empty')).toBeNull();
+ });
+
+ it('a server removed while its tools are open offers Go back', async () => {
+ useMcpStore.getState().addServer(DOCS);
+ const ui = mountSettings();
+ await connectFromList(ui, DOCS.id);
+ fireEvent.press(ui.getByText('Edit Tools'));
+ expect(await ui.findByText('search')).toBeTruthy();
+
+ // A paired device's tool route, for example, goes away when the device is unpaired.
+ await act(async () => {
+ disconnectServer(DOCS.id);
+ useMcpStore.getState().removeServer(DOCS.id);
+ });
+ expect(ui.getByText('This server was removed.')).toBeTruthy();
+
+ fireEvent.press(ui.getByTestId('mcp-tools-go-back'));
+ await waitFor(() => expect(ui.queryByTestId('mcp-tools-empty')).toBeNull());
+ expect(ui.queryByText('Team Docs')).toBeNull();
+ });
+});
diff --git a/__tests__/integration/remote/imageCancellation.test.ts b/__tests__/integration/remote/imageCancellation.test.ts
new file mode 100644
index 000000000..8b33a41d4
--- /dev/null
+++ b/__tests__/integration/remote/imageCancellation.test.ts
@@ -0,0 +1,275 @@
+import {
+ createServer,
+ request as httpRequest,
+ type ServerResponse,
+} from 'node:http';
+import type { AddressInfo } from 'node:net';
+import { remoteMediaRuntime } from '../../../src/services/remoteMediaRuntime';
+import type { RemoteServer } from '../../../src/types';
+
+// The device Keychain is unavailable in Node. Keep its public credential contract,
+// without spies, call counters, or substitutions for Mobile service code.
+jest.mock('react-native-keychain', () => {
+ const credentials = new Map();
+ return {
+ ACCESSIBLE: { WHEN_UNLOCKED: 'WhenUnlocked' },
+ async setGenericPassword(
+ username: string,
+ password: string,
+ options: { service: string },
+ ) {
+ credentials.set(options.service, { username, password });
+ return { service: options.service };
+ },
+ async getGenericPassword(options: { service: string }) {
+ return credentials.get(options.service) ?? false;
+ },
+ async resetGenericPassword(options: { service: string }) {
+ return credentials.delete(options.service);
+ },
+ };
+});
+
+// Jest disables fetch. This adapter uses real Node HTTP sockets and honours AbortSignal.
+// The server below is the external Desktop boundary; all Mobile owners are real.
+const socketFetch: typeof fetch = async (input, init = {}) =>
+ new Promise((resolve, reject) => {
+ const url =
+ typeof input === 'string'
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input.url;
+ const transport = httpRequest(
+ url,
+ {
+ method: init.method,
+ agent: false,
+ headers: init.headers as Record,
+ },
+ response => {
+ const chunks: Buffer[] = [];
+ response.on('data', chunk => chunks.push(Buffer.from(chunk)));
+ response.on('end', () => {
+ cleanup();
+ resolve(
+ new Response(Buffer.concat(chunks), {
+ status: response.statusCode,
+ }),
+ );
+ });
+ response.on('error', reject);
+ },
+ );
+ const abort = () => transport.destroy(new Error('HTTP request aborted'));
+ const cleanup = () => init.signal?.removeEventListener('abort', abort);
+ transport.on('error', error => {
+ cleanup();
+ reject(error);
+ });
+ init.signal?.addEventListener('abort', abort, { once: true });
+ if (init.signal?.aborted) abort();
+ transport.end(init.body as string | undefined);
+ });
+
+function event() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise(done => {
+ resolve = done;
+ });
+ return { promise, resolve };
+}
+
+function json(response: ServerResponse, payload: unknown, status = 200) {
+ response.writeHead(status, { 'Content-Type': 'application/json' });
+ response.end(JSON.stringify(payload));
+}
+
+describe('Desktop image cancellation through HTTP', () => {
+ const savedFetch = global.fetch;
+ let holdSubmission: boolean;
+ let holdPoll: boolean;
+ let olderDesktop: boolean;
+ let reportProgress: boolean;
+ let submitted: ReturnType>;
+ let polled: ReturnType>;
+ let deleted: ReturnType>;
+ let requests: string[];
+ let jobStatus: string;
+ let server: RemoteServer;
+ const desktop = createServer((request, response) => {
+ requests.push(`${request.method} ${request.url}`);
+ if (request.method === 'POST') {
+ request.resume();
+ jobStatus = 'running';
+ submitted.resolve(response);
+ if (!holdSubmission) json(response, { request_id: 'image-job' }, 202);
+ } else if (request.method === 'DELETE') {
+ if (olderDesktop)
+ json(response, { error: { message: 'Unknown endpoint' } }, 404);
+ else {
+ jobStatus = 'failed';
+ json(response, {
+ request_id: 'image-job',
+ status: 'failed',
+ cancelled: true,
+ });
+ }
+ deleted.resolve();
+ } else {
+ polled.resolve(response);
+ if (!holdPoll)
+ json(response, {
+ ...(reportProgress ? { progress: { step: 1, total: 1 } } : {}),
+ status: 'completed',
+ result: { data: [{ b64_json: 'aW1hZ2U=' }] },
+ });
+ }
+ });
+
+ beforeAll(async () => {
+ await new Promise(resolve => desktop.listen(0, '127.0.0.1', resolve));
+ server = {
+ id: 'cancellation-desktop',
+ name: 'Synthetic Desktop',
+ endpoint: `http://127.0.0.1:${
+ (desktop.address() as AddressInfo).port
+ }/v1`,
+ providerType: 'openai-compatible',
+ modelManagement: 'offgrid-desktop-v1',
+ createdAt: '2026-10-08T00:00:00.000Z',
+ mediaModels: { image: 'synthetic-image' },
+ };
+ });
+
+ beforeEach(() => {
+ global.fetch = socketFetch;
+ holdSubmission = false;
+ holdPoll = false;
+ olderDesktop = false;
+ reportProgress = false;
+ submitted = event();
+ polled = event();
+ deleted = event();
+ requests = [];
+ jobStatus = 'idle';
+ });
+
+ afterEach(() => {
+ global.fetch = savedFetch;
+ desktop.closeAllConnections();
+ });
+ afterAll(async () => {
+ await new Promise(resolve => desktop.close(() => resolve()));
+ });
+
+ it('does not submit when Stop occurs before generation', async () => {
+ const stop = new AbortController();
+ stop.abort();
+ await expect(
+ remoteMediaRuntime.generateImage(
+ server,
+ { prompt: 'image' },
+ {
+ signal: stop.signal,
+ },
+ ),
+ ).rejects.toThrow('Remote request cancelled');
+ expect(requests).toEqual([]);
+ });
+
+ it('receives a delayed request ID after Stop and cancels that job once', async () => {
+ holdSubmission = true;
+ const stop = new AbortController();
+ const outcome = remoteMediaRuntime.generateImage(
+ server,
+ { prompt: 'image' },
+ {
+ signal: stop.signal,
+ },
+ );
+ const settled = outcome.catch(error => error as Error);
+ const response = await submitted.promise;
+ stop.abort();
+ stop.abort();
+ json(response, { request_id: 'image-job' }, 202);
+ expect(await settled).toEqual(new Error('Remote request cancelled'));
+ expect(jobStatus).toBe('failed');
+ expect(requests).toEqual([
+ 'POST /v1/images/generations',
+ 'DELETE /v1/requests/image-job',
+ ]);
+ });
+
+ it('cancels during a pending poll and allows the next image to complete', async () => {
+ holdPoll = true;
+ const stop = new AbortController();
+ const outcome = remoteMediaRuntime.generateImage(
+ server,
+ { prompt: 'image' },
+ {
+ signal: stop.signal,
+ },
+ );
+ const settled = outcome.catch(error => error as Error);
+ await polled.promise;
+ stop.abort();
+ await deleted.promise;
+ expect(await settled).toEqual(new Error('Remote request cancelled'));
+ expect(jobStatus).toBe('failed');
+ expect(requests).toEqual([
+ 'POST /v1/images/generations',
+ 'GET /v1/requests/image-job',
+ 'DELETE /v1/requests/image-job',
+ ]);
+ holdPoll = false;
+ await expect(
+ remoteMediaRuntime.generateImage(server, { prompt: 'next image' }),
+ ).resolves.toEqual({ base64: 'aW1hZ2U=', url: undefined });
+ });
+
+ it('settles local Stop when an older Desktop rejects DELETE', async () => {
+ olderDesktop = true;
+ holdPoll = true;
+ const stop = new AbortController();
+ const outcome = remoteMediaRuntime.generateImage(
+ server,
+ { prompt: 'image' },
+ {
+ signal: stop.signal,
+ },
+ );
+ const settled = outcome.catch(error => error as Error);
+ await polled.promise;
+ stop.abort();
+ expect(await settled).toEqual(new Error('Remote request cancelled'));
+ expect(jobStatus).toBe('running');
+ expect(requests.at(-1)).toBe('DELETE /v1/requests/image-job');
+ });
+
+ it('honours Stop from the progress callback before accepting a completed result', async () => {
+ reportProgress = true;
+ const stop = new AbortController();
+ await expect(
+ remoteMediaRuntime.generateImage(
+ server,
+ { prompt: 'image' },
+ {
+ signal: stop.signal,
+ onImageProgress: () => stop.abort(),
+ },
+ ),
+ ).rejects.toThrow('Remote request cancelled');
+ expect(requests.at(-1)).toBe('DELETE /v1/requests/image-job');
+ });
+
+ it('returns a completed image without sending cancellation', async () => {
+ await expect(
+ remoteMediaRuntime.generateImage(server, { prompt: 'image' }),
+ ).resolves.toEqual({ base64: 'aW1hZ2U=', url: undefined });
+ expect(requests).toEqual([
+ 'POST /v1/images/generations',
+ 'GET /v1/requests/image-job',
+ ]);
+ });
+});
diff --git a/__tests__/pro/audio/engines/KokoroEngine.extra.test.ts b/__tests__/pro/audio/engines/KokoroEngine.extra.test.ts
index 45d2f2766..cecc67aef 100644
--- a/__tests__/pro/audio/engines/KokoroEngine.extra.test.ts
+++ b/__tests__/pro/audio/engines/KokoroEngine.extra.test.ts
@@ -175,6 +175,24 @@ describe('KokoroEngine.extra — uncovered branches', () => {
expect((err as Error).message).toMatch(/did not mount in time/i);
});
+ it('a speak waiting for the model to remount does not start once stop() was called', async () => {
+ // The model was freed; speak asks for a remount and waits. Stop arrives before it attaches.
+ const engine = new KokoroEngine();
+ let mountRequested = false;
+ engine._setMountRequester(() => { mountRequested = true; });
+ const handle = makeHandle();
+
+ const pending = engine.speak('do not say this');
+ await Promise.resolve();
+ expect(mountRequested).toBe(true);
+ engine.stop();
+ engine._setBridge(handle, 'af_heart'); // the remount completes after Stop
+
+ await pending;
+ expect(handle.speak).not.toHaveBeenCalled();
+ expect(engine.getPhase()).toBe('ready');
+ });
+
// ── isSupported (both sides, all platforms) ───────────────────────────────
it('isSupported reflects the OS version gate on android (both sides)', () => {
diff --git a/__tests__/pro/mcp/McpToolExtension.extra.test.ts b/__tests__/pro/mcp/McpToolExtension.extra.test.ts
index 450d003be..4d60500ba 100644
--- a/__tests__/pro/mcp/McpToolExtension.extra.test.ts
+++ b/__tests__/pro/mcp/McpToolExtension.extra.test.ts
@@ -270,7 +270,19 @@ describe('canHandle', () => {
});
describe('execute', () => {
+ // The user's server lists notion_search and it is switched on there (discovery turns it on).
+ const turnOnNotionSearch = () => useMcpStore.getState().setServerTools('notion', [compactTool]);
+
+ it('refuses a tool the user has switched off, without reaching any server', async () => {
+ turnOnNotionSearch();
+ useMcpStore.getState().toggleTool('notion_search', 'notion');
+ const r = await McpToolExtension.execute({ id: 'c0', name: 'notion_search', arguments: {} });
+ expect(r.error).toBe('The tool "notion_search" is turned off.');
+ expect(mockExecuteMcpTool).not.toHaveBeenCalled();
+ });
+
it('returns content + toolCallId on success and never sets error', async () => {
+ turnOnNotionSearch();
mockExecuteMcpTool.mockResolvedValue({
content: 'the result',
durationMs: 42,
@@ -396,6 +408,7 @@ describe('execute', () => {
});
it('returns a typed error result (does NOT throw) when the call rejects with an Error', async () => {
+ turnOnNotionSearch();
mockExecuteMcpTool.mockRejectedValue(
new Error('Server "notion" is not connected'),
);
@@ -411,6 +424,7 @@ describe('execute', () => {
});
it('uses the fallback message when the rejection is not an Error instance', async () => {
+ turnOnNotionSearch();
mockExecuteMcpTool.mockRejectedValue('boom-string');
const r = await McpToolExtension.execute({
id: 'c3',
diff --git a/__tests__/pro/mcp/mcpStore.test.ts b/__tests__/pro/mcp/mcpStore.test.ts
index 67893735a..34c56ce3d 100644
--- a/__tests__/pro/mcp/mcpStore.test.ts
+++ b/__tests__/pro/mcp/mcpStore.test.ts
@@ -118,12 +118,38 @@ describe('mcpStore setServerTools auto-enable semantics', () => {
expect(s.knownToolNames).toEqual(['t1']);
});
- it('re-owns a tool when a second server publishes the same name', () => {
+ it('keeps the first owner when a second server publishes the same name', () => {
get().setServerTools('srv1', [tool('shared')]);
get().setServerTools('srv2', [tool('shared')]);
- // last writer wins on owner; not double-enabled (already known)
+ // a second server never takes over a name the user already has routed; not double-enabled
+ expect(get().toolOwners.shared).toBe('srv1');
+ expect(get().enabledTools).toEqual(['shared']);
+ });
+
+ it('moves a name to the server the user turns it on for', () => {
+ get().setServerTools('srv1', [tool('shared')]);
+ get().setServerTools('srv2', [tool('shared')]);
+ get().toggleTool('shared', 'srv2');
expect(get().toolOwners.shared).toBe('srv2');
expect(get().enabledTools).toEqual(['shared']);
+ // a reconnect of the first server keeps the user's choice
+ get().setServerTools('srv1', [tool('shared')]);
+ expect(get().toolOwners.shared).toBe('srv2');
+ });
+
+ it('turns a tool off when the account behind it would change without the user choosing', () => {
+ // Review finding: srv1 dropped the tool, it moved to srv2, and stayed enabled there.
+ get().setServerTools('srv1', [tool('shared')]);
+ get().setServerTools('srv2', [tool('shared')]);
+ get().toggleTool('shared', 'srv2');
+ get().toggleTool('shared', 'srv2');
+ get().toggleTool('shared', 'srv1');
+ expect(get().toolOwners.shared).toBe('srv1');
+ expect(get().enabledTools).toEqual(['shared']);
+
+ get().setServerTools('srv1', []);
+ expect(get().toolOwners.shared).toBe('srv2');
+ expect(get().enabledTools).toEqual([]);
});
});
diff --git a/__tests__/pro/sync/ambientShare.integration.test.tsx b/__tests__/pro/sync/ambientShare.integration.test.tsx
index 22101b732..03ea57844 100644
--- a/__tests__/pro/sync/ambientShare.integration.test.tsx
+++ b/__tests__/pro/sync/ambientShare.integration.test.tsx
@@ -101,6 +101,14 @@ const PHONE_FINGERPRINT = 'fp-this-phone';
jest.unmock('@react-navigation/native');
+// The sync op store keeps its history in SQLite and reads stored payloads back on use, so it needs a
+// database that remembers: a real one. Every other database keeps the global inert mock.
+jest.mock('@op-engineering/op-sqlite', () =>
+ require('../../harness/sqliteFake').realSqliteModule({
+ only: ['offgrid-sync.sqlite'],
+ }),
+);
+
jest.mock('react-native-tcp-socket', () => {
const {
createNativeTcpBoundary,
diff --git a/__tests__/pro/sync/stateSync.integration.test.tsx b/__tests__/pro/sync/stateSync.integration.test.tsx
index feaa159f9..dbc5cf82f 100644
--- a/__tests__/pro/sync/stateSync.integration.test.tsx
+++ b/__tests__/pro/sync/stateSync.integration.test.tsx
@@ -69,6 +69,14 @@ const PHONE_FINGERPRINT = 'fp-this-phone';
jest.unmock('@react-navigation/native');
+// The sync op store keeps its history in SQLite and reads stored payloads back on use, so it needs a
+// database that remembers: a real one. Every other database keeps the global inert mock.
+jest.mock('@op-engineering/op-sqlite', () =>
+ require('../../harness/sqliteFake').realSqliteModule({
+ only: ['offgrid-sync.sqlite'],
+ }),
+);
+
jest.mock('react-native-tcp-socket', () => {
const {
createNativeTcpBoundary,
diff --git a/__tests__/pro/ui/McpToolPickerSheet.test.tsx b/__tests__/pro/ui/McpToolPickerSheet.test.tsx
index f6e5515bd..3a53f8a3a 100644
--- a/__tests__/pro/ui/McpToolPickerSheet.test.tsx
+++ b/__tests__/pro/ui/McpToolPickerSheet.test.tsx
@@ -45,6 +45,13 @@ const SERVER: McpServerConfig = { id: 'srv-1', name: 'Notion', url: 'https://x/m
const INITIAL = useMcpStore.getState();
+// A connected server owns the tools it lists (the first server to list a name keeps it).
+const ownersOf = (serverTools: Record = {}): Record => {
+ const owners: Record = {};
+ Object.entries(serverTools).forEach(([id, list]) => list.forEach(t => { owners[t.name] ??= id; }));
+ return owners;
+};
+
const seed = (opts: {
servers?: McpServerConfig[];
serverTools?: Record;
@@ -55,7 +62,7 @@ const seed = (opts: {
serverTools: opts.serverTools ?? {},
enabledTools: opts.enabledTools ?? [],
connectionStates: {},
- toolOwners: {},
+ toolOwners: ownersOf(opts.serverTools),
knownToolNames: [],
});
diff --git a/__tests__/pro/ui/McpToolsScreen.test.tsx b/__tests__/pro/ui/McpToolsScreen.test.tsx
index f535baa21..f0c1017c3 100644
--- a/__tests__/pro/ui/McpToolsScreen.test.tsx
+++ b/__tests__/pro/ui/McpToolsScreen.test.tsx
@@ -2,7 +2,7 @@
* McpToolsScreen (Pro) Tests
*
* Drives the REAL McpToolsScreen against the REAL useMcpStore. Only genuine boundaries
- * are mocked: the Feather icon shim, navigation, and theme. Everything the tests assert
+ * are mocked: the Feather icon shim and navigation; the real theme runs. Everything the tests assert
* (the enabled-tools set, per-tool toggle, bulk enable/disable, search filter, empty
* states, header/back) exercises the real component + real store state.
*
@@ -14,20 +14,11 @@ import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import type { McpTool } from '@offgrid/pro/mcp/types';
-const mockColors = {
- text: '#000', textMuted: '#999', textSecondary: '#666', textDisabled: '#bbb',
- primary: '#1DB954', error: '#E00', trending: '#F90',
- background: '#FFF', surface: '#F5F5F5', surfaceLight: '#EEE', border: '#E0E0E0', overlay: 'rgba(0,0,0,0.4)',
-};
-
jest.mock('react-native-vector-icons/Feather', () => {
const { Text } = require('react-native');
return ({ name }: any) => {name};
});
-jest.mock('../../../src/theme', () => ({
- useTheme: () => ({ colors: mockColors, shadows: { small: {} } }),
-}));
const mockGoBack = jest.fn();
let mockRouteParams: { serverId: string } = { serverId: 'srv-1' };
@@ -69,7 +60,14 @@ maybe('McpToolsScreen', () => {
const initial = useMcpStore.getState();
- const seed = (opts: {
+ // A connected server owns the tools it lists (the first server to list a name keeps it).
+ const ownersOf = (serverTools: Record = {}): Record => {
+ const owners: Record = {};
+ Object.entries(serverTools).forEach(([id, list]) => list.forEach(t => { owners[t.name] ??= id; }));
+ return owners;
+ };
+
+const seed = (opts: {
serverId?: string;
servers?: any[];
serverTools?: Record;
@@ -81,7 +79,7 @@ maybe('McpToolsScreen', () => {
serverTools: opts.serverTools ?? {},
enabledTools: opts.enabledTools ?? [],
connectionStates: {},
- toolOwners: {},
+ toolOwners: ownersOf(opts.serverTools),
knownToolNames: [],
});
};
@@ -109,11 +107,24 @@ maybe('McpToolsScreen', () => {
expect(getByText('Tools')).toBeTruthy();
});
- it('shows the connect-first empty message when no tools exist', () => {
+ it('says a missing server was removed and offers Go back', () => {
seed({ serverId: 'srv-1', serverTools: {} });
- const { getByText } = render();
- expect(getByText('No tools available — connect the server first.')).toBeTruthy();
+ const { getByText, getByTestId } = render();
+ expect(getByText('This server was removed.')).toBeTruthy();
expect(getByText('0 TOOLS AVAILABLE')).toBeTruthy();
+ fireEvent.press(getByTestId('mcp-tools-go-back'));
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('a paired device route that is not connected asks to keep the device online, not to connect', () => {
+ seed({
+ serverId: 'mesh-task-mac-1',
+ servers: [{ id: 'mesh-task-mac-1', name: 'My Mac', url: 'sync://mac-1' }],
+ });
+ const { getByText, queryByTestId, getByTestId } = render();
+ expect(getByText('My Mac is not reachable. Keep the paired device online, then come back.')).toBeTruthy();
+ expect(queryByTestId('mcp-tools-connect')).toBeNull();
+ expect(getByTestId('mcp-tools-go-back')).toBeTruthy();
});
it('lists tools with the available count and a per-tool token chip', () => {
@@ -253,7 +264,7 @@ maybe('McpToolsScreen', () => {
expect(getByText('create')).toBeTruthy();
expect(queryByText('lookup')).toBeNull();
- // No match -> empty message (not the connect-first one).
+ // No match -> the filter message, not the server state.
fireEvent.changeText(input, 'zzzz');
expect(getByText('No tools match that filter.')).toBeTruthy();
});
diff --git a/__tests__/rntl/components/ChatMessage.test.tsx b/__tests__/rntl/components/ChatMessage.test.tsx
index f6de7111d..1144c1ea7 100644
--- a/__tests__/rntl/components/ChatMessage.test.tsx
+++ b/__tests__/rntl/components/ChatMessage.test.tsx
@@ -255,7 +255,7 @@ describe('ChatMessage', () => {
expect(getByText('Thought process')).toBeTruthy();
});
- it('renders Markdown in the collapsed thinking preview', () => {
+ it('shows the collapsed thinking preview as plain text, without Markdown syntax', () => {
const message = createAssistantMessage(
'**Drafting Email Response**\nI am composing a direct reply.Done.',
);
@@ -266,7 +266,7 @@ describe('ChatMessage', () => {
fireEvent.press(getByTestId('assistant-work-toggle'));
expect(getByTestId('thinking-block-preview')).toBeTruthy();
- expect(getByText('Drafting Email Response')).toBeTruthy();
+ expect(getByText('Drafting Email Response I am composing a direct reply.')).toBeTruthy();
expect(queryByText(/\*\*Drafting Email Response\*\*/)).toBeNull();
});
diff --git a/__tests__/unit/mcp/McpToolExtension.test.ts b/__tests__/unit/mcp/McpToolExtension.test.ts
index c0de68ec7..30767f9bb 100644
--- a/__tests__/unit/mcp/McpToolExtension.test.ts
+++ b/__tests__/unit/mcp/McpToolExtension.test.ts
@@ -6,7 +6,11 @@
*/
jest.mock('@offgrid/core/utils/logger', () => ({ __esModule: true, default: { log: jest.fn(), warn: jest.fn(), error: jest.fn() } }));
jest.mock('@offgrid/core/stores', () => ({ useRemoteServerStore: { getState: () => ({}) } }));
-jest.mock('../../../pro/mcp/mcpStore', () => ({ useMcpStore: { getState: () => ({ toolOwners: {}, enabledTools: [], serverTools: {} }) } }));
+// notion_search is switched on for the notion server.
+jest.mock('../../../pro/mcp/mcpStore', () => ({
+ isToolEnabledOnServer: jest.requireActual('../../../pro/mcp/mcpStore').isToolEnabledOnServer,
+ useMcpStore: { getState: () => ({ toolOwners: { notion_search: 'notion' }, enabledTools: ['notion_search'], serverTools: {} }) },
+}));
jest.mock('../../../pro/mcp/schemaTrim', () => ({ trimToolForSmallModel: (t: any) => t }));
const mockExecuteMcpTool = jest.fn();
diff --git a/__tests__/unit/utils/proPricing.test.ts b/__tests__/unit/utils/proPricing.test.ts
index b922408f1..dfc627a22 100644
--- a/__tests__/unit/utils/proPricing.test.ts
+++ b/__tests__/unit/utils/proPricing.test.ts
@@ -3,14 +3,14 @@ import { getPricingCopy } from '../../../src/utils/proPricing';
describe('getPricingCopy', () => {
const copy = getPricingCopy();
- it('offers the two current plans - $49/yr and $69 lifetime - and no monthly', () => {
- expect(copy.title).toBe('$49/yr or $69 lifetime');
- expect(copy.sheetSubheadline).toMatch(/\$49 a year/);
- expect(copy.sheetSubheadline).toMatch(/\$69 once/);
- // The retired monthly plan must not resurface anywhere in the copy.
+ it('offers the two plans sold on getoffgridai.co - $119 lifetime and $7.99/month - and no yearly', () => {
+ expect(copy.title).toBe('$119 lifetime or $7.99/month');
+ expect(copy.sheetSubheadline).toMatch(/\$119 once/);
+ expect(copy.sheetSubheadline).toMatch(/\$7\.99 a month/);
+ // The yearly plan is not sold; it must not resurface anywhere in the copy.
const all = Object.values(copy).join(' ');
- expect(all).not.toMatch(/month/i);
- expect(all).not.toMatch(/\$39/);
+ expect(all).not.toMatch(/\byear(ly)?\b|\/yr/i);
+ expect(all).not.toMatch(/\$49|\$39|\$69/);
});
it('keeps the Get Pro CTA (the web pay-page trigger the Pro surfaces assert)', () => {
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 88875fc44..8d1c1e920 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -93,8 +93,8 @@ android {
applicationId "ai.offgridmobile"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 1790418213
- versionName "0.0.111"
+ versionCode 1791224063
+ versionName "0.0.112"
}
signingConfigs {
debug {
diff --git a/android/build.gradle b/android/build.gradle
index 291104255..73e44d60f 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -41,6 +41,13 @@ subprojects { subproject ->
subproject.android {
ndkVersion rootProject.ext.ndkVersion
}
+ if (subproject.name == "react-native-reanimated") {
+ // Worklets packages its own runtime. Reanimated imports that library
+ // for linking and must not package a second copy of it.
+ subproject.androidComponents.finalizeDsl { androidDsl ->
+ androidDsl.packaging.jniLibs.excludes.add("**/libworklets.so")
+ }
+ }
}
}
}
diff --git a/docs/PRIVACY_POLICY.md b/docs/PRIVACY_POLICY.md
index a5bae8438..0b53d3781 100644
--- a/docs/PRIVACY_POLICY.md
+++ b/docs/PRIVACY_POLICY.md
@@ -6,4 +6,4 @@ The Off Grid Privacy Policy is maintained in one place and covers all Off Grid p
Read the current policy at: https://getoffgridai.co/privacy
-For questions, email the Off Grid Support Team at support@offgridmobileai.co.
+For questions, email the Off Grid Support Team at support@getoffgridai.co.
diff --git a/ios/OffgridMobile.xcodeproj/project.pbxproj b/ios/OffgridMobile.xcodeproj/project.pbxproj
index de68e2172..4e8058ea5 100644
--- a/ios/OffgridMobile.xcodeproj/project.pbxproj
+++ b/ios/OffgridMobile.xcodeproj/project.pbxproj
@@ -487,7 +487,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = OffgridMobile/OffgridMobile.entitlements;
- CURRENT_PROJECT_VERSION = 1790418213;
+ CURRENT_PROJECT_VERSION = 1791224063;
DEVELOPMENT_TEAM = 84V6KCAC49;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = OffgridMobile/Info.plist;
@@ -498,7 +498,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.0.111;
+ MARKETING_VERSION = 0.0.112;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -520,7 +520,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = OffgridMobile/OffgridMobile.entitlements;
- CURRENT_PROJECT_VERSION = 1790418213;
+ CURRENT_PROJECT_VERSION = 1791224063;
DEVELOPMENT_TEAM = 84V6KCAC49;
INFOPLIST_FILE = OffgridMobile/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Off Grid AI";
@@ -530,7 +530,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.0.111;
+ MARKETING_VERSION = 0.0.112;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
diff --git a/jest.setup.ts b/jest.setup.ts
index 670bd380f..e0fdd0006 100644
--- a/jest.setup.ts
+++ b/jest.setup.ts
@@ -257,6 +257,7 @@ jest.mock('react-native-audio-api', () => ({
AudioManager: {
setAudioSessionOptions: jest.fn(),
setAudioSessionActivity: jest.fn().mockResolvedValue(true),
+ checkRecordingPermissions: jest.fn().mockResolvedValue('Granted'),
},
AudioRecorder: jest.fn().mockImplementation(() => ({
enableFileOutput: jest.fn().mockReturnValue({ status: 'success', path: '/mock/audio/input.wav' }),
diff --git a/package-lock.json b/package-lock.json
index 23f27de91..b9787e73d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "offgrid-mobile",
- "version": "0.0.111",
+ "version": "0.0.112",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "offgrid-mobile",
- "version": "0.0.111",
+ "version": "0.0.112",
"hasInstallScript": true,
"dependencies": {
"@dr.pogodin/react-native-fs": "^2.38.1",
@@ -154,12 +154,12 @@
"license": "AGPL-3.0-only"
},
"node_modules/@babel/code-frame": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
- "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"license": "MIT",
"dependencies": {
- "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -226,13 +226,13 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
- "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.28.6",
- "@babel/types": "^7.28.6",
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -325,9 +325,9 @@
}
},
"node_modules/@babel/helper-globals": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
- "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -347,27 +347,27 @@
}
},
"node_modules/@babel/helper-module-imports": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
- "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.28.6",
- "@babel/types": "^7.28.6"
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
- "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.28.6",
- "@babel/helper-validator-identifier": "^7.28.5",
- "@babel/traverse": "^7.28.6"
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -389,9 +389,9 @@
}
},
"node_modules/@babel/helper-plugin-utils": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
- "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -446,18 +446,18 @@
}
},
"node_modules/@babel/helper-string-parser": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
- "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
- "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -587,12 +587,12 @@
}
},
"node_modules/@babel/parser": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
- "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
+ "version": "7.29.9",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
+ "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.28.6"
+ "@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1469,16 +1469,16 @@
}
},
"node_modules/@babel/plugin-transform-modules-systemjs": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz",
- "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==",
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz",
+ "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-transforms": "^7.28.3",
- "@babel/helper-plugin-utils": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5",
- "@babel/traverse": "^7.28.5"
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.8"
},
"engines": {
"node": ">=6.9.0"
@@ -2137,31 +2137,31 @@
}
},
"node_modules/@babel/template": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
- "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.28.6",
- "@babel/parser": "^7.28.6",
- "@babel/types": "^7.28.6"
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
- "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
+ "version": "7.29.10",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz",
+ "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==",
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.28.6",
- "@babel/generator": "^7.28.6",
- "@babel/helper-globals": "^7.28.0",
- "@babel/parser": "^7.28.6",
- "@babel/template": "^7.28.6",
- "@babel/types": "^7.28.6",
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.9",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
"debug": "^4.3.1"
},
"engines": {
@@ -2188,13 +2188,13 @@
}
},
"node_modules/@babel/types": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
- "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"license": "MIT",
"dependencies": {
- "@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -2341,9 +2341,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2362,9 +2362,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -2405,9 +2405,9 @@
}
},
"node_modules/@expo/config-plugins/node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -2472,9 +2472,9 @@
}
},
"node_modules/@expo/config-plugins/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -2662,9 +2662,9 @@
}
},
"node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2673,9 +2673,9 @@
}
},
"node_modules/@humanwhocodes/config-array/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -2858,9 +2858,9 @@
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
- "version": "3.14.2",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
- "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
+ "version": "3.15.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
+ "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
@@ -3484,9 +3484,9 @@
}
},
"node_modules/@jest/globals/node_modules/picomatch": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
- "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4321,12 +4321,12 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
- "version": "1.29.0",
- "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
- "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
+ "version": "1.32.1",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz",
+ "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==",
"license": "MIT",
"dependencies": {
- "@hono/node-server": "^1.19.9",
+ "@hono/node-server": "^1.19.9 || ^2.0.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
@@ -5694,9 +5694,9 @@
}
},
"node_modules/@react-native/dev-middleware/node_modules/ws": {
- "version": "7.5.10",
- "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
- "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
+ "version": "7.5.13",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
+ "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
@@ -6547,9 +6547,9 @@
"license": "MIT"
},
"node_modules/@xmldom/xmldom": {
- "version": "0.8.11",
- "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
- "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
+ "version": "0.8.15",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
+ "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -7115,9 +7115,9 @@
}
},
"node_modules/babel-plugin-istanbul/node_modules/brace-expansion": {
- "version": "5.0.9",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
- "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7335,12 +7335,15 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.9.19",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
- "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
+ "version": "2.11.27",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz",
+ "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==",
"license": "Apache-2.0",
"bin": {
- "baseline-browser-mapping": "dist/cli.js"
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
}
},
"node_modules/big-integer": {
@@ -7459,9 +7462,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
+ "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7481,9 +7484,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.28.1",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
- "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "version": "4.29.3",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz",
+ "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==",
"funding": [
{
"type": "opencollective",
@@ -7500,11 +7503,11 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.9.0",
- "caniuse-lite": "^1.0.30001759",
- "electron-to-chromium": "^1.5.263",
- "node-releases": "^2.0.27",
- "update-browserslist-db": "^1.2.0"
+ "baseline-browser-mapping": "^2.11.26",
+ "caniuse-lite": "^1.0.30001813",
+ "electron-to-chromium": "^1.5.439",
+ "node-releases": "^2.0.57",
+ "update-browserslist-db": "^1.3.3"
},
"bin": {
"browserslist": "cli.js"
@@ -7637,9 +7640,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001766",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
- "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==",
+ "version": "1.0.30001815",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz",
+ "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==",
"funding": [
{
"type": "opencollective",
@@ -7907,15 +7910,16 @@
}
},
"node_modules/compression": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
- "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
+ "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
+ "destroy": "1.2.0",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
@@ -7923,6 +7927,10 @@
},
"engines": {
"node": ">= 0.8.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/compression/node_modules/debug": {
@@ -8588,9 +8596,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
- "version": "1.5.279",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.279.tgz",
- "integrity": "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==",
+ "version": "1.5.451",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.451.tgz",
+ "integrity": "sha512-vUNKPrUzw8ByEMOxloq0a71iD/mPuPxVkbDm3G5YhnxTzOw/I6qAg2MUS0cYj/c267lWmw26yW4Vv7foxTxPLg==",
"license": "ISC"
},
"node_modules/emittery": {
@@ -9214,9 +9222,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9238,9 +9246,9 @@
}
},
"node_modules/eslint-plugin-react/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -9316,9 +9324,9 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9367,9 +9375,9 @@
}
},
"node_modules/eslint/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -9950,9 +9958,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
- "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [
{
"type": "github",
@@ -9966,9 +9974,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
- "version": "4.5.3",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
- "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.7.tgz",
+ "integrity": "sha512-a6Qh1RMCNbSrU1+sAyAAZH3rTe+OaWJbNZIq0S+ifZciUUOQtlVxBJwoTUE2bYhysmG/RYyI5WJFIKdBahJdrQ==",
"devOptional": true,
"funding": [
{
@@ -9978,7 +9986,7 @@
],
"license": "MIT",
"dependencies": {
- "strnum": "^1.1.1"
+ "strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -10145,9 +10153,9 @@
}
},
"node_modules/flatted": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
- "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
"dev": true,
"license": "ISC"
},
@@ -10491,9 +10499,9 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -10501,9 +10509,9 @@
}
},
"node_modules/glob/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -10996,9 +11004,9 @@
}
},
"node_modules/ip-address": {
- "version": "10.2.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
- "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "version": "10.7.3",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz",
+ "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -12288,9 +12296,9 @@
}
},
"node_modules/joi": {
- "version": "17.13.3",
- "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz",
- "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==",
+ "version": "17.13.8",
+ "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.8.tgz",
+ "integrity": "sha512-iPKOGmiRw1jxf/JOPwxmCcUQAOdF359mdzYiP2DJ+TMX0YK2zjK3D+zYOaGjpumWxOFF/l2xVWjRVK5bGSLdEw==",
"devOptional": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -12329,10 +12337,20 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
- "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"devOptional": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -12564,9 +12582,9 @@
}
},
"node_modules/knip/node_modules/picomatch": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
- "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -12713,9 +12731,9 @@
}
},
"node_modules/lodash": {
- "version": "4.17.23",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
- "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
@@ -13389,9 +13407,9 @@
}
},
"node_modules/metro/node_modules/ws": {
- "version": "7.5.10",
- "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
- "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
+ "version": "7.5.13",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
+ "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
@@ -13485,13 +13503,13 @@
}
},
"node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -13538,9 +13556,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"funding": [
{
"type": "github",
@@ -13599,10 +13617,13 @@
"license": "MIT"
},
"node_modules/node-releases": {
- "version": "2.0.27",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
- "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
- "license": "MIT"
+ "version": "2.0.58",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz",
+ "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
},
"node_modules/node-stream-zip": {
"version": "1.15.0",
@@ -14248,9 +14269,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -14487,9 +14508,9 @@
"license": "MIT"
},
"node_modules/proxy-addr": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
- "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -14497,6 +14518,10 @@
},
"engines": {
"node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/punycode": {
@@ -14778,9 +14803,9 @@
}
},
"node_modules/react-devtools-core/node_modules/ws": {
- "version": "7.5.10",
- "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
- "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
+ "version": "7.5.13",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
+ "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
@@ -15447,9 +15472,9 @@
}
},
"node_modules/react-native/node_modules/ws": {
- "version": "7.5.10",
- "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
- "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
+ "version": "7.5.13",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
+ "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
@@ -16136,9 +16161,9 @@
}
},
"node_modules/shell-quote": {
- "version": "1.8.3",
- "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
- "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
+ "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -16313,9 +16338,9 @@
"license": "MIT"
},
"node_modules/smol-toml": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
- "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==",
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
+ "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -16826,9 +16851,9 @@
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -16836,9 +16861,9 @@
}
},
"node_modules/test-exclude/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -16926,9 +16951,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
- "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -16939,9 +16964,9 @@
}
},
"node_modules/tmp": {
- "version": "0.2.5",
- "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
- "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
+ "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"license": "MIT",
"engines": {
"node": ">=14.14"
@@ -17306,9 +17331,9 @@
}
},
"node_modules/update-browserslist-db": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
- "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz",
+ "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==",
"funding": [
{
"type": "opencollective",
@@ -17711,9 +17736,9 @@
}
},
"node_modules/ws": {
- "version": "6.2.3",
- "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz",
- "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==",
+ "version": "6.2.6",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.6.tgz",
+ "integrity": "sha512-XTrf1gv7kXoVf1hbC3PAyAiPgR8Wz1blcrYIjEsUmr08BLksT41R8KbjmS9408C2ERx7v1JDLD/BkpLEttjfKA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -17765,9 +17790,10 @@
}
},
"node_modules/xmldom": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.5.0.tgz",
- "integrity": "sha512-Foaj5FXVzgn7xFzsKeNIde9g6aFBxTPi37iwsno8QvApmtg7KYrr+OPyRHcJF7dud2a5nGRBXK3n0dL62Gf7PA==",
+ "name": "@xmldom/xmldom",
+ "version": "0.8.15",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
+ "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
diff --git a/package.json b/package.json
index 864b69f8e..46e178ad7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "offgrid-mobile",
- "version": "0.0.111",
+ "version": "0.0.112",
"private": true,
"scripts": {
"android": "react-native run-android --mode=debug --appId ai.offgridmobile.dev",
@@ -131,6 +131,9 @@
"op-sqlite": {},
"overrides": {
"react": "19.2.0",
- "linkify-it": "^5.0.2"
+ "linkify-it": "^5.0.2",
+ "@expo/plist": {
+ "xmldom": "npm:@xmldom/xmldom@0.8.15"
+ }
}
}
diff --git a/pro b/pro
index 035e38bd2..343d404c0 160000
--- a/pro
+++ b/pro
@@ -1 +1 @@
-Subproject commit 035e38bd2345569d82377809cd8a582701592cfd
+Subproject commit 343d404c0045669bea337c05af3ebc65567adbf9
diff --git a/src/components/CenteredAlert.tsx b/src/components/CenteredAlert.tsx
index 6388647d1..ce6f58676 100644
--- a/src/components/CenteredAlert.tsx
+++ b/src/components/CenteredAlert.tsx
@@ -48,9 +48,12 @@ export const CenteredAlert: React.FC = ({
onRequestClose={() => onClose?.()}
>
{/* Tapping the backdrop dismisses, matching the dropdown/sheet behaviour. */}
- onClose?.()}>
+ {/* accessible={false}: a Pressable is one accessibility element by default, which folded the
+ title, message and buttons into a single node that screen readers and automation could not
+ reach. */}
+ onClose?.()} accessible={false}>
{/* Stop propagation so taps on the card don't close it. */}
- {}}>
+ {}} accessible={false} accessibilityViewIsModal>
{title ? {title} : null}
{message ? {message} : null}
diff --git a/src/components/ChatInput/Voice.ts b/src/components/ChatInput/Voice.ts
index e75b9ad4e..0fea83fff 100644
--- a/src/components/ChatInput/Voice.ts
+++ b/src/components/ChatInput/Voice.ts
@@ -1,3 +1,4 @@
+import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import { useWhisperTranscription } from '../../hooks/useWhisperTranscription';
import { useWhisperStore, useAppStore, useRemoteServerStore } from '../../stores';
@@ -75,8 +76,39 @@ function useRemoteTranscriptionAvailable(): boolean {
});
}
+/**
+ * Who a stopped turn's transcript belongs to. Transcription takes seconds and the person can open
+ * another chat meanwhile; the composer's callbacks then belong to that chat. A turn claims its result
+ * for the chat it was recorded in, and the claim is checked right before dispatch, after every wait.
+ */
+function useTurnResultOwnership(
+ conversationId: string | null | undefined,
+ recordingConversationIdRef: React.MutableRefObject,
+) {
+ const conversationIdRef = useRef(conversationId ?? null);
+ conversationIdRef.current = conversationId ?? null;
+ // Bumped when a turn's result must be dropped although its chat is still open (a cancel).
+ const tokenRef = useRef(0);
+ return {
+ /** Returns a check that holds while the turn is not cancelled and its chat is still on screen. */
+ claim: (): (() => boolean) => {
+ const token = tokenRef.current;
+ const chat = recordingConversationIdRef.current ?? conversationIdRef.current;
+ return () => tokenRef.current === token && chat === conversationIdRef.current;
+ },
+ invalidate: () => { tokenRef.current += 1; },
+ /** A result that no longer belongs to the open chat: nothing is sent and the floor is released. */
+ drop: () => {
+ logger.log('[TURN] result dropped - its chat is no longer open');
+ voiceSession.dispatch('nothingHeard');
+ voiceSession.dispatch('nothingHeard');
+ },
+ };
+}
+
export function useVoiceInput({ conversationId, interfaceMode, onTranscript, onAudioAttachment, onAutoSend }: UseVoiceInputParams) {
const recordingConversationIdRef = useRef(null);
+ const turnResult = useTurnResultOwnership(conversationId, recordingConversationIdRef);
const onTranscriptRef = useRef(onTranscript);
onTranscriptRef.current = onTranscript;
const onAudioAttachmentRef = useRef(onAudioAttachment);
@@ -190,6 +222,13 @@ export function useVoiceInput({ conversationId, interfaceMode, onTranscript, onA
await startWhisperRecording();
};
+ /** Tell the person nothing was heard. Each strike is one empty-turn verdict for the session. */
+ const reportNothingHeard = (message: string, strikes: number) => {
+ for (let i = 0; i < strikes; i++) voiceSession.dispatch('nothingHeard');
+ setDirectError(message);
+ setTimeout(() => setDirectError(null), 3000);
+ };
+
// Transcribe a just-recorded file, toggling the transcribing flag around the work.
// whisperReady tracks whether the MODEL loaded — a throw from transcribeFile after a
// successful load is a transcription miss (not a load failure), so whisperReady stays
@@ -215,6 +254,7 @@ export function useVoiceInput({ conversationId, interfaceMode, onTranscript, onA
const { path, durationSeconds } = await stopAndFinalise(silence);
setIsDirectRecording(false);
if (!recordingConversationIdRef.current || recordingConversationIdRef.current === conversationId) {
+ const stillOwned = turnResult.claim();
const format = audioRecorderService.getFormat();
// In Audio Mode, transcribe FIRST, then auto-send with the text.
// Sending audio with EMPTY text made the intent router classify on "" — so a
@@ -222,41 +262,24 @@ export function useVoiceInput({ conversationId, interfaceMode, onTranscript, onA
// needs the transcribed prompt, which never reached routing). We still attach
// the audio so multimodal text models get the original speech; the text is what
// lets routing pick image vs text.
- if (onAutoSendRef.current && isInAudioInterfaceMode()) {
- const { whisperReady, transcript } = await transcribeRecordedFile(path, '[Voice] transcription error:');
- // NEVER dispatch an empty transcript — that misroutes to the text model.
- const outcome = resolveTranscription(whisperReady, transcript);
- if (outcome.dispatch) {
- onAutoSendRef.current(outcome.text, { uri: path, format, durationSeconds });
- } else {
- // Nothing to send. Hands-free must NOT re-open the mic: on device that spun - record,
- // hear the room, transcribe to nothing, arm again - three turns in eight seconds with no
- // output. A person tapping the mic resumes it.
- voiceSession.dispatch('nothingHeard');
- voiceSession.dispatch('nothingHeard');
- setDirectError(outcome.message);
- setTimeout(() => setDirectError(null), 3000);
- }
- } else {
- // CHAT mode: STT is dictation-into-the-input-box on EVERY engine — the SAME behavior a non-audio
- // (llama) model's hold-to-talk has. Transcribe the recording and drop the text into the composer
- // for the user to review/edit/send; do NOT build a voice-note attachment (that was the litert-only
- // divergence). Voice/Audio interface mode still attaches audio above. `durationSeconds`/`format`
- // are unused here now (no attachment) — the temp recording file is transient.
- const { whisperReady, transcript } = await transcribeRecordedFile(path, '[Voice] chat-mode dictation transcription error:');
- const outcome = resolveTranscription(whisperReady, transcript);
- if (outcome.dispatch) {
- onTranscriptRef.current(outcome.text);
- } else {
- // Nothing to send. Hands-free must NOT re-open the mic: on device that spun - record,
- // hear the room, transcribe to nothing, arm again - three turns in eight seconds with no
- // output. A person tapping the mic resumes it.
- voiceSession.dispatch('nothingHeard');
- voiceSession.dispatch('nothingHeard');
- setDirectError(outcome.message);
- setTimeout(() => setDirectError(null), 3000);
- }
- }
+ // CHAT mode: STT is dictation-into-the-input-box on EVERY engine — the SAME behavior a non-audio
+ // (llama) model's hold-to-talk has. Transcribe the recording and drop the text into the composer
+ // for the user to review/edit/send; do NOT build a voice-note attachment (that was the litert-only
+ // divergence). The temp recording file is transient there.
+ const autoSend = !!onAutoSendRef.current && isInAudioInterfaceMode();
+ const { whisperReady, transcript } = await transcribeRecordedFile(
+ path,
+ autoSend ? '[Voice] transcription error:' : '[Voice] chat-mode dictation transcription error:',
+ );
+ // NEVER dispatch an empty transcript — that misroutes to the text model.
+ const outcome = resolveTranscription(whisperReady, transcript);
+ if (!stillOwned()) turnResult.drop();
+ // Nothing to send. Hands-free must NOT re-open the mic: on device that spun - record, hear the
+ // room, transcribe to nothing, arm again - three turns in eight seconds with no output. Two
+ // verdicts end the session; a person tapping the mic resumes it.
+ else if (!outcome.dispatch) reportNothingHeard(outcome.message, 2);
+ else if (autoSend) onAutoSendRef.current?.(outcome.text, { uri: path, format, durationSeconds });
+ else onTranscriptRef.current(outcome.text);
}
recordingConversationIdRef.current = null;
} catch (err) {
@@ -274,6 +297,7 @@ export function useVoiceInput({ conversationId, interfaceMode, onTranscript, onA
recordingConversationIdRef.current = null;
return;
}
+ const stillOwned = turnResult.claim();
setIsTranscribingFile(true);
let whisperReady = false;
let transcript = '';
@@ -287,17 +311,15 @@ export function useVoiceInput({ conversationId, interfaceMode, onTranscript, onA
recordingConversationIdRef.current = null;
// NEVER dispatch an empty transcript — that misroutes to the text model.
const outcome = resolveTranscription(whisperReady, transcript);
- if (outcome.dispatch) {
- if (onAutoSendRef.current) {
- onAutoSendRef.current(outcome.text, { uri: path, format: 'wav', durationSeconds });
- } else {
- onAudioAttachmentRef.current?.({ uri: path, format: 'wav', durationSeconds, transcription: outcome.text });
- onTranscriptRef.current(outcome.text);
- }
+ if (!stillOwned()) {
+ turnResult.drop();
+ } else if (!outcome.dispatch) {
+ reportNothingHeard(outcome.message, 1);
+ } else if (onAutoSendRef.current) {
+ onAutoSendRef.current(outcome.text, { uri: path, format: 'wav', durationSeconds });
} else {
- voiceSession.dispatch('nothingHeard');
- setDirectError(outcome.message);
- setTimeout(() => setDirectError(null), 3000);
+ onAudioAttachmentRef.current?.({ uri: path, format: 'wav', durationSeconds, transcription: outcome.text });
+ onTranscriptRef.current(outcome.text);
}
} catch (err) {
setIsAudioModeRecording(false);
@@ -347,7 +369,7 @@ export function useVoiceInput({ conversationId, interfaceMode, onTranscript, onA
setIsAudioModeRecording,
stopWhisperRecording,
clearWhisperResult: clearResult,
- clearConversation: () => { recordingConversationIdRef.current = null; },
+ clearConversation: () => { recordingConversationIdRef.current = null; turnResult.invalidate(); },
});
} finally {
// A user cancellation ends the manual turn. A replay cancellation is
diff --git a/src/components/ChatInput/index.tsx b/src/components/ChatInput/index.tsx
index 215f1c5c7..cf37fb6a4 100644
--- a/src/components/ChatInput/index.tsx
+++ b/src/components/ChatInput/index.tsx
@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { View, Text, TextInput, TouchableOpacity, Animated, Platform, ActionSheetIOS } from 'react-native';
import Icon from 'react-native-vector-icons/Feather';
-import IconMC from 'react-native-vector-icons/MaterialCommunityIcons';
+import { GodIcon } from '../GodIcon';
import { useTheme, useThemedStyles } from '../../theme';
import { ImageModeState, MediaAttachment } from '../../types';
import { VoiceRecordButton, type VoiceRecordInteractionMode } from '../VoiceRecordButton';
@@ -518,8 +518,7 @@ export const ChatInput: React.FC = ({
active={assistantEnabled}
loading={assistantTransitioning}
style={styles.assistantButton}
- icon={}
diff --git a/src/components/ChatMessage/components/ThinkingBlock.tsx b/src/components/ChatMessage/components/ThinkingBlock.tsx
index d32d8fd1d..d394a02a9 100644
--- a/src/components/ChatMessage/components/ThinkingBlock.tsx
+++ b/src/components/ChatMessage/components/ThinkingBlock.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { MarkdownText } from '../../MarkdownText';
+import { prepareMessageForSpeech } from '../../../utils/messageContent';
import type { ParsedContent } from '../types';
interface ThinkingBlockProps {
@@ -43,9 +44,15 @@ export function ThinkingBlock({
testID="thinking-block-preview"
style={styles.thinkingPreview}
>
-
- {parsedContent.thinking}
-
+ {/* Plain text clamped to two lines, so the cut ends in an ellipsis instead of a
+ fixed-height box slicing through a word. */}
+
+ {prepareMessageForSpeech(parsedContent.thinking).replace(/\s+/g, ' ').trim()}
+
)}
diff --git a/src/components/ChatMessage/styles.ts b/src/components/ChatMessage/styles.ts
index 8d6742ee7..56ed186ff 100644
--- a/src/components/ChatMessage/styles.ts
+++ b/src/components/ChatMessage/styles.ts
@@ -304,10 +304,12 @@ const createThinkingStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
},
thinkingPreview: {
marginTop: 6,
- maxHeight: 36,
- overflow: 'hidden' as const,
opacity: 0.8,
},
+ thinkingPreviewText: {
+ ...TYPOGRAPHY.bodySmall,
+ color: colors.textMuted,
+ },
thinkingToggle: {
...TYPOGRAPHY.meta,
color: colors.textMuted,
diff --git a/src/components/GodIcon.tsx b/src/components/GodIcon.tsx
new file mode 100644
index 000000000..a4b9548bc
--- /dev/null
+++ b/src/components/GodIcon.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import Svg, { Path, type SvgProps } from 'react-native-svg';
+
+// Same Ares artwork as Desktop; the host control supplies its theme and active color.
+export const GodIcon: React.FC = ({
+ size = 20, color = 'currentColor', ...props
+}) => (
+
+);
diff --git a/src/components/SelectDropdown.tsx b/src/components/SelectDropdown.tsx
index 89b0334f1..76ef8895a 100644
--- a/src/components/SelectDropdown.tsx
+++ b/src/components/SelectDropdown.tsx
@@ -98,7 +98,9 @@ export const SelectDropdown: React.FC = ({
animationType="none"
onRequestClose={() => setOpen(false)}
>
- setOpen(false)}>
+ {/* accessible={false}: the wrapper otherwise makes the whole overlay one accessibility
+ element, hiding every option from screen readers and automation. */}
+ setOpen(false)} accessible={false}>
;
+ /** The microphone permission is denied, so nothing can be recorded until it is allowed. */
+ isMicPermissionDenied?: boolean;
}
export type VoiceButtonState =
@@ -30,11 +32,14 @@ export type VoiceButtonState =
| { kind: 'transcribing' }
| { kind: 'ready' }
| { kind: 'downloading'; progress: number }
- | { kind: 'unavailable' };
+ | { kind: 'unavailable' }
+ | { kind: 'micDenied' };
export function deriveVoiceButtonState(i: VoiceButtonInputs): VoiceButtonState {
if (i.isModelLoading) return { kind: 'loading' };
if (i.isTranscribing && !i.isRecording) return { kind: 'transcribing' };
+ // A denied mic cannot record, whatever model is installed: the button must not look usable.
+ if (i.isMicPermissionDenied) return { kind: 'micDenied' };
// A usable model wins over any background download — the mic stays a normal idle mic.
if (i.isAvailable) return { kind: 'ready' };
// No usable model: an in-flight STT download renders as download progress (the model
diff --git a/src/components/VoiceRecordButton/index.tsx b/src/components/VoiceRecordButton/index.tsx
index 8ca2ad861..b0ab0fcd4 100644
--- a/src/components/VoiceRecordButton/index.tsx
+++ b/src/components/VoiceRecordButton/index.tsx
@@ -5,6 +5,7 @@ import {
TouchableOpacity,
Animated,
Vibration,
+ Linking,
} from 'react-native';
import Icon from 'react-native-vector-icons/Feather';
import ReanimatedAnimated, {
@@ -32,6 +33,7 @@ import {
} from './states';
import { deriveVoiceButtonState } from './derive';
import { useWhisperStore } from '../../stores';
+import { useMicPermissionDenied } from '../../hooks/useMicPermissionDenied';
import logger from '../../utils/logger';
import {
buildVoiceRecordGesture,
@@ -104,6 +106,50 @@ const ChatButtonFace: React.FC<{
);
};
+/** The mic when its permission is denied: the unavailable glyph, and a tap that explains why and
+ * opens Settings. Module scope keeps it out of the component's size budget. */
+const MicDeniedButton: React.FC<{ asSendButton: boolean }> = ({ asSendButton }) => {
+ const styles = useThemedStyles(createStyles);
+ const [alertState, setAlertState] = useState(initialAlertState);
+ const openSettings = () => {
+ setAlertState(hideAlert());
+ Linking.openSettings().catch(err => {
+ logger.error('[VoiceRecordButton] Could not open Settings:', err);
+ });
+ };
+ return (
+
+
+ setAlertState(
+ showAlert(
+ 'Microphone Access Is Off',
+ 'Turn on microphone access for Off Grid AI in Settings to talk instead of type.',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Open Settings', onPress: openSettings },
+ ],
+ ),
+ )
+ }
+ >
+
+
+ setAlertState(hideAlert())}
+ />
+
+ );
+};
+
export const VoiceRecordButton: React.FC = ({
isRecording,
isAvailable,
@@ -122,6 +168,7 @@ export const VoiceRecordButton: React.FC = ({
const styles = useThemedStyles(createStyles);
const downloadModel = useWhisperStore(s => s.downloadModel);
const downloadProgressById = useWhisperStore(s => s.downloadProgressById);
+ const isMicPermissionDenied = useMicPermissionDenied();
// The ONE derivation of what the mic renders (see derive.ts): a background STT
// download is never the busy spinner — that is reserved for a tap-triggered
// model load and live transcription.
@@ -131,6 +178,7 @@ export const VoiceRecordButton: React.FC = ({
isTranscribing: !!isTranscribing,
isRecording,
downloadProgressById,
+ isMicPermissionDenied,
});
// State-machine trace: which face the mic renders. This is the crux of the
// slide-to-cancel / release-during-load behaviour — when kind flips to 'loading'
@@ -310,6 +358,10 @@ export const VoiceRecordButton: React.FC = ({
);
}
+ if (buttonState.kind === 'micDenied') {
+ return ;
+ }
+
if (
buttonState.kind === 'downloading' ||
buttonState.kind === 'unavailable'
diff --git a/src/hooks/useActiveTextModel.ts b/src/hooks/useActiveTextModel.ts
index ae8e9782b..6dc7c410d 100644
--- a/src/hooks/useActiveTextModel.ts
+++ b/src/hooks/useActiveTextModel.ts
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useAppStore, useRemoteServerStore } from '../stores';
import { activeModelService } from '../services/activeModelService';
+import { displayModelName } from '../utils/displayModelName';
import { DownloadedModel, RemoteModel } from '../types';
export type ActiveTextModelResult = {
@@ -42,7 +43,7 @@ export function useActiveTextModel(): ActiveTextModelResult {
return {
model: remoteModel ?? null,
modelId: activeRemoteTextModelId,
- modelName: remoteModel?.name ?? activeRemoteTextModelId,
+ modelName: remoteModel?.name ?? displayModelName(activeRemoteTextModelId),
isRemote: true,
};
}
diff --git a/src/hooks/useMicPermissionDenied.ts b/src/hooks/useMicPermissionDenied.ts
new file mode 100644
index 000000000..50b1577b2
--- /dev/null
+++ b/src/hooks/useMicPermissionDenied.ts
@@ -0,0 +1,33 @@
+import { useEffect, useState } from 'react';
+import { AppState } from 'react-native';
+import logger from '../utils/logger';
+import {
+ isMicPermissionDenied,
+ onMicPermissionRefused,
+} from '../services/micPermission';
+
+/**
+ * Whether the microphone permission is denied, kept current while the caller is mounted.
+ * Re-read whenever the app returns to the foreground, because that is where a person changes it
+ * (Settings), and at once when a request is refused.
+ */
+export function useMicPermissionDenied(): boolean {
+ const [denied, setDenied] = useState(false);
+ useEffect(() => {
+ const refresh = () => {
+ isMicPermissionDenied()
+ .then(setDenied)
+ .catch(error => logger.warn('[MicPermission] could not refresh the permission:', error));
+ };
+ refresh();
+ const appState = AppState.addEventListener('change', state => {
+ if (state === 'active') refresh();
+ });
+ const unsubscribe = onMicPermissionRefused(setDenied);
+ return () => {
+ appState.remove();
+ unsubscribe();
+ };
+ }, []);
+ return denied;
+}
diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts
index 99f5f7878..e4f42e1cf 100644
--- a/src/screens/ChatScreen/useChatGenerationActions.ts
+++ b/src/screens/ChatScreen/useChatGenerationActions.ts
@@ -8,7 +8,6 @@ import {
intentClassifier,
generationService,
imageGenerationService,
- onnxImageGeneratorService,
ImageGenerationState,
buildToolSystemPromptHint,
contextCompactionService,
@@ -47,6 +46,7 @@ import {
GenerationMeta,
} from '../../types';
import logger from '../../utils/logger';
+import { deleteChatImages, imagesNotDeletedAlert } from '../../services/chatImageCleanup';
import { ModelReadyOutcome, ensureReadyOrAlert } from './modelReadiness';
type SetState = Dispatch>;
const FALLBACK_RECENT_MESSAGE_COUNT = 2;
@@ -1058,7 +1058,6 @@ export async function executeDeleteConversationFn(
| 'activeConversationId'
| 'isStreaming'
| 'clearStreamingMessage'
- | 'removeImagesByConversationId'
| 'deleteConversation'
| 'setActiveConversation'
| 'navigation'
@@ -1073,13 +1072,16 @@ export async function executeDeleteConversationFn(
await generationService.stopGeneration();
deps.clearStreamingMessage();
}
- // Read each image's saved path first: remote .jpg/.webp files are found by it, not by id.
- const imagePaths = new Map(useAppStore.getState().generatedImages.map(image => [image.id, image.imagePath]));
- for (const id of deps.removeImagesByConversationId(deps.activeConversationId))
- await onnxImageGeneratorService.deleteGeneratedImage(id, imagePaths.get(id));
+ // Files first; a record goes only once its file is gone (shared with the chats list).
+ const notDeleted = await deleteChatImages(deps.activeConversationId);
contextCompactionService.clearSummary(deps.activeConversationId);
deps.deleteConversation(deps.activeConversationId);
deps.setActiveConversation(null);
+ if (notDeleted > 0) {
+ // Say so before leaving, or the user never learns some images stayed in the Gallery.
+ deps.setAlertState(imagesNotDeletedAlert(notDeleted, () => deps.navigation.goBack()));
+ return;
+ }
deps.navigation.goBack();
}
export type RegenerateCall = {
diff --git a/src/screens/ChatsListScreen.tsx b/src/screens/ChatsListScreen.tsx
index 9f05fa4df..c088678ef 100644
--- a/src/screens/ChatsListScreen.tsx
+++ b/src/screens/ChatsListScreen.tsx
@@ -18,7 +18,8 @@ import type { ThemeColors, ThemeShadows } from '../theme';
import { TYPOGRAPHY, SPACING } from '../constants';
import { useChatStore, useProjectStore, useAppStore } from '../stores';
import { useActiveTextModel } from '../hooks/useActiveTextModel';
-import { onnxImageGeneratorService, activeModelService, llmService, remoteServerManager } from '../services';
+import { activeModelService, llmService, remoteServerManager } from '../services';
+import { deleteChatImages, imagesNotDeletedAlert } from '../services/chatImageCleanup';
import { Conversation } from '../types';
import { RootStackParamList, MainTabParamList } from '../navigation/types';
import { byRecentActivity } from '../utils/conversationOrdering';
@@ -41,11 +42,6 @@ export const ChatsListScreen: React.FC = () => {
const { deleteConversation, setActiveConversation } = useChatStore.getState();
const { getProject } = useProjectStore();
const activeImageModelId = useAppStore(s => s.activeImageModelId);
- const { removeImagesByConversationId } = useAppStore.getState();
- // Remote images can be .jpg or .webp; deletion needs each image's saved file path, read
- // before the records are removed.
- const savedImagePaths = () =>
- new Map(useAppStore.getState().generatedImages.map(image => [image.id, image.imagePath]));
const { modelId: activeTextModelId } = useActiveTextModel();
const [alertState, setAlertState] = useState(initialAlertState);
const [showModelSelector, setShowModelSelector] = useState(false);
@@ -96,6 +92,10 @@ export const ChatsListScreen: React.FC = () => {
}
};
+ const reportImagesNotDeleted = (count: number) => {
+ if (count > 0) setAlertState(imagesNotDeletedAlert(count));
+ };
+
const handleDeleteChat = (conversation: Conversation) => {
setAlertState(showAlert(
'Delete Chat',
@@ -105,14 +105,11 @@ export const ChatsListScreen: React.FC = () => {
{
text: 'Delete',
style: 'destructive',
- onPress: () => {
+ onPress: async () => {
setAlertState(hideAlert());
- const imagePaths = savedImagePaths();
- const imageIds = removeImagesByConversationId(conversation.id);
- for (const imageId of imageIds) {
- onnxImageGeneratorService.deleteGeneratedImage(imageId, imagePaths.get(imageId)).catch(() => {});
- }
+ const notDeleted = await deleteChatImages(conversation.id);
deleteConversation(conversation.id);
+ reportImagesNotDeleted(notDeleted);
},
},
]
@@ -135,17 +132,15 @@ export const ChatsListScreen: React.FC = () => {
{
text: 'Delete',
style: 'destructive',
- onPress: () => {
+ onPress: async () => {
setAlertState(hideAlert());
- const imagePaths = savedImagePaths();
+ let notDeleted = 0;
for (const conversation of selected) {
- const imageIds = removeImagesByConversationId(conversation.id);
- for (const imageId of imageIds) {
- onnxImageGeneratorService.deleteGeneratedImage(imageId, imagePaths.get(imageId)).catch(() => {});
- }
+ notDeleted += await deleteChatImages(conversation.id);
deleteConversation(conversation.id);
}
stopSelecting();
+ reportImagesNotDeleted(notDeleted);
},
},
],
diff --git a/src/screens/ModelsScreen/ImageModelsTab.tsx b/src/screens/ModelsScreen/ImageModelsTab.tsx
index c0c3e4ab1..b7b2655ca 100644
--- a/src/screens/ModelsScreen/ImageModelsTab.tsx
+++ b/src/screens/ModelsScreen/ImageModelsTab.tsx
@@ -7,6 +7,7 @@ import { ModelCard } from '../../components';
import { useTheme, useThemedStyles } from '../../theme';
import { HFImageModel, getVariantLabel } from '../../services/huggingFaceModelBrowser';
import { ImageModelRecommendation } from '../../types';
+import { useAppStore } from '../../stores';
import { useDownloadStore, isActiveStatus, isQueuedStatus, isDownloadingStatus } from '../../stores/downloadStore';
import { makeImageModelKey } from '../../utils/modelKey';
import { imageBackendLabel } from '../../utils/imageBackend';
@@ -70,6 +71,10 @@ export const ImageModelCardItem: React.FC = ({
const isDownloading = !!entry && isDownloadingStatus(entry.status);
const isPaused = entry?.status === 'paused';
const progressValue = entry?.progress ?? 0;
+ // A downloaded model stays in the catalog and shows as downloaded (as on the Text tab),
+ // so finishing a download never makes the card vanish.
+ const isDownloaded = useAppStore(s => s.downloadedImageModels.some(d => d.id === model.id));
+ const isActiveModel = useAppStore(s => s.activeImageModelId === model.id);
const authorLabel = model._coreml ? 'Core ML' : imageBackendLabel(model.backend);
const variantSuffix = model.variant ? ` \u00B7 ${getVariantLabel(model.variant)}` : '';
return (
@@ -83,6 +88,8 @@ export const ImageModelCardItem: React.FC = ({
author: authorLabel,
description: `${formatBytes(model.size)}${variantSuffix}`,
}}
+ isDownloaded={isDownloaded}
+ isActive={isDownloaded && isActiveModel}
isDownloading={isDownloading}
isPaused={isPaused}
isQueued={isQueued}
@@ -95,7 +102,7 @@ export const ImageModelCardItem: React.FC = ({
isCompatible={isCompatible}
incompatibleReason={incompatibleReason}
testID={`image-model-card-${index}`}
- onDownload={isActive || isPaused ? undefined : () => handleDownloadImageModel(hfModelToDescriptor(model))}
+ onDownload={isDownloaded || isActive || isPaused ? undefined : () => handleDownloadImageModel(hfModelToDescriptor(model))}
onCancel={isActive || isPaused ? () => handleCancelImageDownload(model.id) : undefined}
onPause={isDownloading && entry?.downloadId ? () => {
modelDownloadService.pause(uniformDownloadId('image', model.id)).catch(error => logger.error('Failed to pause image download:', error));
@@ -163,7 +170,7 @@ const ImageModelsScrollContent: React.FC = ({
} else if (hasActiveImageFilters) {
emptyMessage = 'No models match your filters';
} else {
- emptyMessage = 'All available models are downloaded';
+ emptyMessage = 'No models available';
}
return (
diff --git a/src/screens/ModelsScreen/useImageModels.ts b/src/screens/ModelsScreen/useImageModels.ts
index 3f2eaa2a1..0e93bf89c 100644
--- a/src/screens/ModelsScreen/useImageModels.ts
+++ b/src/screens/ModelsScreen/useImageModels.ts
@@ -165,13 +165,11 @@ export function useImageModels(setAlertState: (s: AlertState) => void) {
if (backendFilter !== 'all' && m.backend !== backendFilter) return false;
if (styleFilter !== 'all' && guessStyle(m.name) !== styleFilter) return false;
if (!matchesSdVersionFilter(m.name, sdVersionFilter)) return false;
- if (downloadedImageModels.some(d => d.id === m.id)) return false;
- if (query && !m.displayName.toLowerCase().includes(query) && !m.name.toLowerCase().includes(query)) return false;
- return true;
+ return !query || m.displayName.toLowerCase().includes(query) || m.name.toLowerCase().includes(query);
});
if (!showRecommendedOnly) filtered.sort((a, b) => a.displayName.localeCompare(b.displayName));
return filtered;
- }, [availableHFModels, backendFilter, styleFilter, sdVersionFilter, downloadedImageModels, imageSearchQuery, imageRec, isRecommendedModel, showRecommendedOnly]);
+ }, [availableHFModels, backendFilter, styleFilter, sdVersionFilter, imageSearchQuery, imageRec, isRecommendedModel, showRecommendedOnly]);
const hasActiveImageFilters = backendFilter !== 'all' || styleFilter !== 'all' || sdVersionFilter !== 'all';
const imageRecommendation = imageRec?.bannerText ?? 'Loading recommendation...';
diff --git a/src/screens/ProjectDetailScreen.styles.ts b/src/screens/ProjectDetailScreen.styles.ts
index 0d39bb6e8..73f78fd72 100644
--- a/src/screens/ProjectDetailScreen.styles.ts
+++ b/src/screens/ProjectDetailScreen.styles.ts
@@ -172,6 +172,7 @@ export const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
},
chatTitle: {
...TYPOGRAPHY.body,
+ color: colors.text,
fontWeight: '400' as const,
flex: 1,
marginRight: SPACING.sm,
diff --git a/src/screens/ProjectEditScreen.tsx b/src/screens/ProjectEditScreen.tsx
index 830e37be1..ceb9962ba 100644
--- a/src/screens/ProjectEditScreen.tsx
+++ b/src/screens/ProjectEditScreen.tsx
@@ -179,6 +179,7 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
},
headerTitle: {
...TYPOGRAPHY.h2,
+ color: colors.text,
fontWeight: '400' as const,
},
saveText: {
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx
index 36eab9e43..521904848 100644
--- a/src/screens/SettingsScreen.tsx
+++ b/src/screens/SettingsScreen.tsx
@@ -5,6 +5,7 @@ import {
ScrollView,
TouchableOpacity,
Alert,
+ Switch,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Icon from 'react-native-vector-icons/Feather';
@@ -45,6 +46,22 @@ type NavigationProp = CompositeNavigationProp<
NativeStackNavigationProp
>;
+/** Developer-only: load the demo fixture and report what it added. Never throws. */
+async function loadDemoData(): Promise {
+ try {
+ const { seedDemoData } = require('../services/demoSeed');
+ const { buildVoiceAttachment } = require('../components/ChatInput/voiceNoteSend');
+ const result = await seedDemoData({ buildVoiceAttachment });
+ const missing = result.warnings.length ? `\n\n${result.warnings.join('\n\n')}` : '';
+ Alert.alert(
+ result.warnings.length ? 'Demo loaded with missing documents' : 'Demo ready',
+ `${result.chats} chats and ${result.documents} documents added. Existing matching demo records were kept.${missing}`,
+ );
+ } catch (error) {
+ Alert.alert('Demo not loaded', error instanceof Error ? error.message : String(error));
+ }
+}
+
export const SettingsScreen: React.FC = () => {
const navigation = useNavigation();
const focusTrigger = useFocusTrigger();
@@ -59,7 +76,10 @@ export const SettingsScreen: React.FC = () => {
const themeMode = useAppStore(s => s.themeMode);
const setThemeMode = useAppStore(s => s.setThemeMode);
const completeChecklistStep = useAppStore(s => s.completeChecklistStep);
+ const showSyncStatusBar = useAppStore(s => s.showSyncStatusBar);
+ const setShowSyncStatusBar = useAppStore(s => s.setShowSyncStatusBar);
const [showDebugLogs, setShowDebugLogs] = useState(false);
+ const [isSeedingDemo, setIsSeedingDemo] = useState(false);
const deviceInfo = useAppStore(s => s.deviceInfo);
// Hidden once the user dismisses it, or once Pro is active (the upsell makes no
// sense to a paid user). hasRegisteredPro only flips true after RC verification
@@ -123,6 +143,13 @@ export const SettingsScreen: React.FC = () => {
);
};
+ const handleSeedDemo = async () => {
+ if (!__DEV__ || isSeedingDemo) return;
+ setIsSeedingDemo(true);
+ await loadDemoData();
+ setIsSeedingDemo(false);
+ };
+
const handleResetOnboarding = () => {
setOnboardingComplete(false);
// Navigate to root stack and reset to Onboarding
@@ -311,6 +338,35 @@ export const SettingsScreen: React.FC = () => {
{__DEV__ && (
+
+ Show sync status bar
+
+
+
+
+
+ {isSeedingDemo ? 'Loading demo data...' : 'Load demo data'}
+
+
{
handleToggleTool(tool.id)}
- trackColor={{ false: colors.border, true: `${colors.primary}80` }}
- thumbColor={isEnabled ? colors.primary : colors.textMuted}
+ trackColor={{ false: colors.border, true: colors.primary }}
+ thumbColor="#FFFFFF"
+ ios_backgroundColor={colors.border}
accessibilityLabel={`${tool.displayName}, ${isEnabled ? 'ON' : 'OFF'}`}
testID={`tool-picker-toggle-${tool.id}`}
/>
diff --git a/src/services/activeModelService/loaders.ts b/src/services/activeModelService/loaders.ts
index 294c7f724..208dea388 100644
--- a/src/services/activeModelService/loaders.ts
+++ b/src/services/activeModelService/loaders.ts
@@ -214,11 +214,11 @@ export async function doLoadTextModel(ctx: TextLoadContext): Promise {
}
const multimodalSupport = llmService.getMultimodalSupport();
- // If the model had a pre-existing stored mmproj link but the native layer rejected it
- // (incompatible file), clear it so the eye icon reappears for repair.
- // Only applies when the link was already persisted before this load attempt — not
- // when resolveMmProjPath just discovered the file via directory scan.
- if (ctx.model.mmProjPath && !multimodalSupport?.vision) {
+ // Init can fail from incompatibility or temporary memory pressure. Clear only
+ // the link and size, keeping vision metadata so repair remains available.
+ // Include links just persisted by the directory scan. The file stays on disk
+ // so repair or HomeScreen can relink it for a later load attempt.
+ if ((mmProjPath || ctx.model.mmProjPath) && !multimodalSupport?.vision) {
await modelManager.clearMmProjLink(ctx.modelId);
}
diff --git a/src/services/audioRecorderService.ts b/src/services/audioRecorderService.ts
index 2601f258a..d03ad2a20 100644
--- a/src/services/audioRecorderService.ts
+++ b/src/services/audioRecorderService.ts
@@ -1,6 +1,7 @@
import { AudioRecorder, FileFormat, FileDirectory, BitDepth, IOSAudioQuality, FlacCompressionLevel } from 'react-native-audio-api';
import { PermissionsAndroid, Platform } from 'react-native';
import { audioSessionManager } from './audioSessionManager';
+import { noteMicPermissionRefused } from './micPermission';
import logger from '../utils/logger';
/** Supported formats for llama.rn audio input */
@@ -129,6 +130,7 @@ class AudioRecorderService {
buttonNegative: 'Cancel',
},
);
+ if (granted !== PermissionsAndroid.RESULTS.GRANTED) noteMicPermissionRefused();
return granted === PermissionsAndroid.RESULTS.GRANTED;
} catch {
return false;
diff --git a/src/services/chatImageCleanup.ts b/src/services/chatImageCleanup.ts
new file mode 100644
index 000000000..45f86cf30
--- /dev/null
+++ b/src/services/chatImageCleanup.ts
@@ -0,0 +1,37 @@
+import { useAppStore } from '../stores/appStore';
+import { showAlert, type AlertState } from '../utils/alertState';
+import { imageGenerationService } from './imageGenerationService';
+import { localDreamGeneratorService } from './localDreamGenerator';
+
+/**
+ * Delete the files of a chat's generated images. An image still being drawn for the chat is
+ * cancelled first and its request ends before the images are read, so it cannot add one after
+ * this cleanup. A record is removed only once its file is gone; remote images can be .jpg or
+ * .webp, so each delete uses the image's saved path. An image whose file stays keeps its Gallery
+ * record, so the user can delete it again from the Gallery. Returns how many images could not be
+ * removed.
+ */
+export async function deleteChatImages(conversationId: string): Promise {
+ await imageGenerationService.cancelGenerationFor(conversationId);
+ const images = useAppStore.getState().generatedImages
+ .filter(image => image.conversationId === conversationId);
+ let notDeleted = 0;
+ for (const image of images) {
+ const deleted = await localDreamGeneratorService
+ .deleteGeneratedImage(image.id, image.imagePath)
+ .catch(() => false);
+ if (deleted) useAppStore.getState().removeGeneratedImage(image.id);
+ else notDeleted += 1;
+ }
+ return notDeleted;
+}
+
+/** The alert that tells the user some images from a deleted chat are still in the Gallery. */
+export function imagesNotDeletedAlert(count: number, onOk?: () => void): AlertState {
+ const one = count === 1;
+ return showAlert(
+ 'Some images were not deleted',
+ `${count} ${one ? 'image' : 'images'} from the deleted chat could not be removed. ${one ? 'It is' : 'They are'} still in your Gallery, where you can delete ${one ? 'it' : 'them'} again.`,
+ [{ text: 'OK', onPress: onOk }],
+ );
+}
diff --git a/src/services/demoSeed.ts b/src/services/demoSeed.ts
new file mode 100644
index 000000000..cf96f2c38
--- /dev/null
+++ b/src/services/demoSeed.ts
@@ -0,0 +1,291 @@
+import RNFS from 'react-native-fs';
+import { useAppStore } from '../stores/appStore';
+import { useProjectStore } from '../stores/projectStore';
+import { useChatStore, waitForChatPersistence } from '../stores/chatStore';
+import { ragService } from './rag';
+import { demoRolloutPdfBase64 } from './demoSeedPdf';
+import type { MediaAttachment, Message } from '../types';
+
+const commitments =
+ 'You made three commitments to Sam: send the pilot scope for 40 seats, starting on 14 November; confirm the owners and checks, with Priya owning the rollout and Tom verifying the gateway policy; and arrange a kickoff call next Tuesday.';
+const scenes = [
+ {
+ title: 'What did I promise Sam?',
+ prompt: 'What did I promise Sam?',
+ answer:
+ 'You made three commitments to Sam:\n\n1. Send the pilot scope for 40 seats, starting on 14 November.\n2. Confirm the owners and checks: Priya owns the rollout, and Tom verifies the gateway policy.\n3. Arrange a kickoff call next Tuesday.',
+ },
+ {
+ title: 'Acme pilot owners and kickoff',
+ prompt:
+ 'Who owns the rollout, who checks the gateway policy, and when does the pilot start?',
+ answer:
+ 'Priya Nair owns the rollout plan. Tom Reyes verifies the gateway policy before kickoff. The Acme Corp pilot starts on 14 November with 40 seats.',
+ },
+ {
+ title: 'Reply to Sam',
+ prompt:
+ 'Draft a short reply to Sam confirming the pilot scope and next steps.',
+ answer:
+ 'Hi Sam,\n\nThe Acme Corp pilot starts on 14 November with 40 seats. Priya owns the rollout plan, and Tom will verify the gateway policy before kickoff. I will send the rollout plan on Friday at 10 AM. Let us confirm the kickoff call for next Tuesday.\n\nAlex',
+ },
+ {
+ title: 'Translate the pilot email',
+ prompt: 'Translate my pilot update into Spanish. Keep the names unchanged.',
+ answer:
+ 'Hola Sam:\n\nEl piloto de Acme Corp comienza el 14 de noviembre con 40 plazas. Priya Nair se encarga del plan de despliegue y Tom Reyes verifica la política de la pasarela antes de la reunión inicial. Te enviaré el plan el viernes a las 10:00. Confirmemos la reunión inicial para el próximo martes.\n\nAlex',
+ },
+ {
+ title: 'Pilot readiness checklist',
+ prompt: 'Give me a short checklist for the Acme pilot.',
+ answer:
+ '1. Send Sam the scope for 40 seats.\n2. Confirm Priya owns the rollout plan.\n3. Ask Tom to verify the gateway policy.\n4. Send Acme_rollout_v3.pdf on Friday at 10 AM.\n5. Confirm the kickoff call next Tuesday.\n6. Start the pilot on 14 November.',
+ },
+];
+
+/** Opt-in developer data, written through the same owners as normal app actions. */
+export async function seedDemoData(deps: {
+ /** The composer's voice-note attachment builder, passed in so this service does not import UI. */
+ buildVoiceAttachment: (opts: {
+ uri: string;
+ format: 'wav' | 'mp3';
+ durationSeconds?: number;
+ transcription?: string;
+ }) => MediaAttachment;
+}): Promise<{
+ chats: number;
+ documents: number;
+ warnings: string[];
+}> {
+ if (!__DEV__)
+ throw new Error('Demo seeding is available in developer builds only.');
+ if (
+ !useChatStore.persist.hasHydrated() ||
+ !useProjectStore.persist.hasHydrated()
+ ) {
+ throw new Error(
+ 'Wait for the app to finish loading, then load the demo again.',
+ );
+ }
+ const modelId = useAppStore.getState().downloadedModels[0]?.id;
+ if (!modelId)
+ throw new Error('Download a text model before loading the demo.');
+ const projects = useProjectStore.getState();
+ const project =
+ projects.projects.find(item => item.name === 'Acme Corp pilot') ??
+ projects.createProject({
+ name: 'Acme Corp pilot',
+ description:
+ 'Alex at Off Grid AI. Synthetic pilot documents and customer conversations.',
+ systemPrompt:
+ 'The user is Alex at Off Grid AI. Use the project documents as your source. Do not invent dates or commitments. Keep replies short.',
+ });
+ let chats = 0;
+ for (const scene of scenes) {
+ const store = useChatStore.getState();
+ if (
+ store.conversations.some(
+ item =>
+ item.projectId === project.id &&
+ item.title === scene.title &&
+ item.messages.some(
+ message =>
+ message.role === 'assistant' && message.content === scene.answer,
+ ),
+ )
+ )
+ continue;
+ const id = store.createConversation(modelId, scene.title, project.id);
+ store.addMessage(id, { role: 'user', content: scene.prompt });
+ store.addMessage(id, { role: 'assistant', content: scene.answer });
+ chats++;
+ }
+ // A playable synthetic voice note uses the same attachment contract as a recording.
+ const voicePath = `${RNFS.DocumentDirectoryPath}/acme-demo-question.wav`;
+ if (await RNFS.exists(voicePath)) {
+ const store = useChatStore.getState();
+ const title = 'Acme pilot voice notes';
+ const existingVoice = store.conversations.find(item => item.projectId === project.id && item.title === title);
+ const voiceAnswer = existingVoice?.messages.find(message => message.role === 'assistant' && message.content === scenes[1].answer);
+ if (existingVoice && voiceAnswer) {
+ // Reloading this opt-in fixture restores its two-message capture scene.
+ store.deleteMessagesAfter(existingVoice.id, voiceAnswer.id);
+ }
+ if (!existingVoice) {
+ const scene = scenes[1];
+ const id = store.createConversation(modelId, title, project.id);
+ const { buildVoiceAttachment } = deps;
+ store.addMessage(id, {
+ role: 'user',
+ content: scene.prompt,
+ attachments: [buildVoiceAttachment({
+ uri: voicePath,
+ format: 'wav',
+ durationSeconds: 4.86,
+ transcription: scene.prompt,
+ })],
+ });
+ store.addMessage(id, { role: 'assistant', content: scene.answer });
+ chats++;
+ }
+ }
+ // Screenshot fixtures are normal saved messages, including the same portable
+ // work records that a paired device supplies. No model request is dispatched.
+ const receiptPath = `${RNFS.DocumentDirectoryPath}/acme-demo-receipt.png`;
+ const receiptAvailable = await RNFS.exists(receiptPath);
+ const captureScenes: Array<{
+ key: string;
+ title: string;
+ prompt: string;
+ reply?: Omit;
+ receipt?: boolean;
+ }> = [
+ {
+ key: '000000000001', title: 'Pilot date reply',
+ prompt: 'Draft a reply to Sam about the pilot date',
+ reply: { role: 'assistant', isStreaming: true, turnStatus: 'running',
+ content: 'Hi Sam,\n\nThe Acme Corp pilot starts on 14 November with 40 seats. Priya Nair owns the rollout plan, and Tom Reyes will verify the gateway policy',
+ reasoningContent: 'Use the pilot notes. Keep the date, seat count and owners unchanged. Sign the draft Alex.' },
+ },
+ {
+ key: '000000000002', title: 'Pilot seat-days',
+ prompt: 'How many seat-days is the pilot?',
+ reply: { role: 'assistant', content: '', isStreaming: true, turnStatus: 'running',
+ timeline: [{ kind: 'thinking', text: 'The pilot has 40 seats for 30 days. Multiply 40 by 30.' }, { kind: 'tool', toolIndex: 0 }],
+ toolArtifacts: [{ id: 'acme-seat-days', name: 'calculator', arguments: '{"expression":"40 * 30"}', result: '', status: 'running' }] },
+ },
+ {
+ key: '000000000003', title: 'Pilot seat-days result',
+ prompt: 'How many seat-days is the pilot?',
+ reply: { role: 'assistant', content: 'The pilot accounts for **1,200 seat-days**.\n\n40 seats × 30 days = 1,200 seat-days.', turnStatus: 'completed',
+ timeline: [{ kind: 'thinking', text: 'The pilot has 40 seats for 30 days. Multiply 40 by 30.' }, { kind: 'tool', toolIndex: 0 }],
+ toolArtifacts: [{ id: 'acme-seat-days', name: 'calculator', arguments: '{"expression":"40 * 30"}', result: '1200', status: 'completed', durationMs: 18 }] },
+ },
+ {
+ key: '000000000004', title: 'Receipt total', prompt: "What's the total?", receipt: true,
+ reply: { role: 'assistant', content: '', isThinking: true, isStreaming: true, turnStatus: 'running',
+ reasoningContent: 'Read the receipt. Check the subtotal and tax, then find the total.' },
+ },
+ {
+ key: '000000000005', title: 'Receipt total result', prompt: "What's the total?", receipt: true,
+ reply: { role: 'assistant', content: 'The total is **$18.90**, including $1.40 in tax. The receipt lists two flat whites and one sandwich.', turnStatus: 'completed' },
+ },
+ {
+ key: '000000000006', title: 'Pilot voice input', prompt: 'Remind me when the Acme pilot starts and who owns the rollout.',
+ reply: { role: 'assistant', content: 'The Acme Corp pilot starts on **14 November** with **40 seats**. Priya Nair owns the rollout. Tom Reyes verifies the gateway policy before kickoff.', turnStatus: 'completed' },
+ },
+ {
+ key: '000000000007', title: 'Pilot notes offline', prompt: 'Summarize the Acme pilot notes in three points.',
+ reply: { role: 'assistant', content: '1. Start on **14 November** with **40 seats**.\n2. **Priya Nair** owns the rollout; **Tom Reyes** verifies the gateway policy.\n3. Send **Sam Okafor** the rollout plan on Friday at 10 AM.', turnStatus: 'completed',
+ generationMeta: { modelName: 'Qwen 3.5 0.8B' } },
+ },
+ {
+ key: '000000000008', title: 'Pilot review on Alex’s Mac', prompt: 'Review the Acme pilot plan and list the next actions.',
+ reply: { role: 'assistant', content: '**Next actions for the 40-seat pilot**\n\n- Priya Nair: confirm the rollout plan.\n- Tom Reyes: verify the gateway policy.\n- Alex: send Sam Okafor the plan on Friday at 10 AM.\n\nThe pilot starts on **14 November**.', turnStatus: 'completed',
+ generationMeta: { modelName: 'Qwen 3.5 9B · Alex’s Mac' } },
+ },
+ {
+ key: '000000000009', title: 'Pilot seat-days question',
+ prompt: 'How many seat-days is the pilot?',
+ },
+ {
+ key: '000000000010', title: 'Pilot notes offline question',
+ prompt: 'Summarize the Acme pilot notes in three points.',
+ },
+ {
+ key: '000000000011', title: 'Pilot reply awaiting approval',
+ prompt: 'Draft a reply to Sam about the pilot date. Ask me before sending.',
+ reply: { role: 'assistant', turnStatus: 'completed', content: 'Hi Sam,\n\nThe Acme Corp pilot starts on 14 November with 40 seats. Priya Nair owns the rollout. Tom Reyes verifies the gateway policy before kickoff.\n\nAlex\n\nSend this reply to Sam?' },
+ },
+ {
+ key: '000000000012', title: 'Pilot email draft handoff',
+ prompt: 'Open the pilot reply to Sam in my mail app. I will review it before sending.',
+ reply: { role: 'assistant', content: '', isStreaming: true, turnStatus: 'running',
+ timeline: [{ kind: 'tool', toolIndex: 0 }],
+ toolArtifacts: [{ id: 'acme-email-draft', name: 'send_email',
+ arguments: JSON.stringify({ to: 'sam@acme.example', subject: 'Acme Corp pilot — 14 November', body: 'Hi Sam,\n\nThe Acme Corp pilot starts on 14 November with 40 seats. Priya Nair owns the rollout. Tom Reyes verifies the gateway policy before kickoff.\n\nAlex' }),
+ result: '', status: 'running' }] },
+ },
+ {
+ key: '000000000013', title: 'Pilot email draft ready',
+ prompt: 'Open the pilot reply to Sam in my mail app. I will review it before sending.',
+ reply: { role: 'assistant', content: 'The draft to **Sam Okafor** is ready in your mail app.\n\nReview the **14 November** pilot date and **40 seats**, then send it when you are ready.\n\n**No email has been sent.**', turnStatus: 'completed',
+ timeline: [{ kind: 'tool', toolIndex: 0 }],
+ toolArtifacts: [{ id: 'acme-email-draft', name: 'send_email',
+ arguments: JSON.stringify({ to: 'sam@acme.example', subject: 'Acme Corp pilot — 14 November', body: 'Hi Sam,\n\nThe Acme Corp pilot starts on 14 November with 40 seats. Priya Nair owns the rollout. Tom Reyes verifies the gateway policy before kickoff.\n\nAlex' }),
+ result: 'Mail app opened with a draft to sam@acme.example (subject: "Acme Corp pilot — 14 November").', status: 'completed', durationMs: 320 }] },
+ },
+ ];
+ for (const scene of captureScenes) {
+ if (scene.receipt && !receiptAvailable) continue;
+ const store = useChatStore.getState();
+ const uuid = `a1ec0000-0000-4000-8000-${scene.key}`;
+ if (store.conversations.some(item => item.messages.some(message => message.uuid === uuid))) continue;
+ const id = store.createConversation(modelId, scene.title, project.id);
+ store.addMessage(id, {
+ role: 'user', content: scene.prompt, uuid,
+ ...(scene.receipt ? { attachments: [{
+ id: 'a1ec0000-0000-4000-8000-000000000099', type: 'image' as const,
+ uri: receiptPath, mimeType: 'image/png', width: 900, height: 1200,
+ fileName: 'Acme-lunch-receipt.png',
+ }] } : {}),
+ });
+ if (scene.reply) store.addMessage(id, scene.reply);
+ chats++;
+ }
+ await waitForChatPersistence();
+
+ const warnings: string[] = [];
+ // Use the optional Pro module boundary; free builds keep the core demo only.
+ const pro = require('@offgrid/pro');
+ if (pro?.seedDemoSyncData) {
+ try { await pro.seedDemoSyncData(); }
+ catch (error) { warnings.push(`Sync demo: ${error instanceof Error ? error.message : String(error)}`); }
+ }
+
+ let documents = 0;
+ const notes = [
+ { title: 'Promises to Sam', text: commitments },
+ {
+ title: 'Acme pilot brief',
+ text: 'Alex at Off Grid AI is the user. Sam Okafor is the customer contact at Acme Corp. The pilot starts on 14 November with 40 seats. Priya Nair owns the rollout plan. Tom Reyes verifies the gateway policy before kickoff. Alex sends Sam the rollout plan on Friday at 10 AM. The source documents are Acme_rollout_v3.pdf and Pilot_scope.docx. All content is synthetic demo data.',
+ },
+ ];
+ for (const note of notes) {
+ try {
+ const existing = await ragService.getDocumentsByProject(project.id);
+ if (existing.some(item => item.name === `${note.title}.txt`)) continue;
+ await ragService.indexPastedText({ projectId: project.id, ...note });
+ documents++;
+ } catch (error) {
+ warnings.push(
+ `${note.title}: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ );
+ }
+ }
+ const fileName = 'Acme_rollout_v3.pdf';
+ const filePath = `${RNFS.DocumentDirectoryPath}/${fileName}`;
+ try {
+ const existing = await ragService.getDocumentsByProject(project.id);
+ if (!existing.some(item => item.name === fileName)) {
+ if (!(await RNFS.exists(filePath))) {
+ await RNFS.writeFile(filePath, demoRolloutPdfBase64, 'base64');
+ }
+ const stat = await RNFS.stat(filePath);
+ await ragService.indexDocument({
+ projectId: project.id,
+ filePath,
+ fileName,
+ fileSize: Number(stat.size),
+ });
+ documents++;
+ }
+ } catch (error) {
+ warnings.push(
+ `${fileName}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ return { chats, documents, warnings };
+}
diff --git a/src/services/demoSeedPdf.ts b/src/services/demoSeedPdf.ts
new file mode 100644
index 000000000..b286a2ded
--- /dev/null
+++ b/src/services/demoSeedPdf.ts
@@ -0,0 +1,3 @@
+// Synthetic, one-page demo document with an extractable text layer.
+export const demoRolloutPdfBase64 =
+ 'JVBERi0xLjQKJZOMi54gUmVwb3J0TGFiIEdlbmVyYXRlZCBQREYgZG9jdW1lbnQgKG9wZW5zb3VyY2UpCjEgMCBvYmoKPDwKL0YxIDIgMCBSIC9GMiAzIDAgUiAvRjMgNCAwIFIKPj4KZW5kb2JqCjIgMCBvYmoKPDwKL0Jhc2VGb250IC9IZWx2ZXRpY2EgL0VuY29kaW5nIC9XaW5BbnNpRW5jb2RpbmcgL05hbWUgL0YxIC9TdWJ0eXBlIC9UeXBlMSAvVHlwZSAvRm9udAo+PgplbmRvYmoKMyAwIG9iago8PAovQmFzZUZvbnQgL0hlbHZldGljYS1Cb2xkIC9FbmNvZGluZyAvV2luQW5zaUVuY29kaW5nIC9OYW1lIC9GMiAvU3VidHlwZSAvVHlwZTEgL1R5cGUgL0ZvbnQKPj4KZW5kb2JqCjQgMCBvYmoKPDwKL0Jhc2VGb250IC9IZWx2ZXRpY2EtT2JsaXF1ZSAvRW5jb2RpbmcgL1dpbkFuc2lFbmNvZGluZyAvTmFtZSAvRjMgL1N1YnR5cGUgL1R5cGUxIC9UeXBlIC9Gb250Cj4+CmVuZG9iago1IDAgb2JqCjw8Ci9Db250ZW50cyA5IDAgUiAvTWVkaWFCb3ggWyAwIDAgNTk1LjI3NTYgODQxLjg4OTggXSAvUGFyZW50IDggMCBSIC9SZXNvdXJjZXMgPDwKL0ZvbnQgMSAwIFIgL1Byb2NTZXQgWyAvUERGIC9UZXh0IC9JbWFnZUIgL0ltYWdlQyAvSW1hZ2VJIF0KPj4gL1JvdGF0ZSAwIC9UcmFucyA8PAoKPj4gCiAgL1R5cGUgL1BhZ2UKPj4KZW5kb2JqCjYgMCBvYmoKPDwKL1BhZ2VNb2RlIC9Vc2VOb25lIC9QYWdlcyA4IDAgUiAvVHlwZSAvQ2F0YWxvZwo+PgplbmRvYmoKNyAwIG9iago8PAovQXV0aG9yIChcKGFub255bW91c1wpKSAvQ3JlYXRpb25EYXRlIChEOjIwMjYxMDA4MDgyOTAyKzA1JzAwJykgL0NyZWF0b3IgKFwodW5zcGVjaWZpZWRcKSkgL0tleXdvcmRzICgpIC9Nb2REYXRlIChEOjIwMjYxMDA4MDgyOTAyKzA1JzAwJykgL1Byb2R1Y2VyIChSZXBvcnRMYWIgUERGIExpYnJhcnkgLSBcKG9wZW5zb3VyY2VcKSkgCiAgL1N1YmplY3QgKFwodW5zcGVjaWZpZWRcKSkgL1RpdGxlIChcKGFub255bW91c1wpKSAvVHJhcHBlZCAvRmFsc2UKPj4KZW5kb2JqCjggMCBvYmoKPDwKL0NvdW50IDEgL0tpZHMgWyA1IDAgUiBdIC9UeXBlIC9QYWdlcwo+PgplbmRvYmoKOSAwIG9iago8PAovRmlsdGVyIFsgL0FTQ0lJODVEZWNvZGUgL0ZsYXRlRGVjb2RlIF0gL0xlbmd0aCAxMTY3Cj4+CnN0cmVhbQpHYXQ9KmdNWiUwJjpPOlNiWmluVzx1JV44cFdadWMoRzQ+ZClsbkJtMEdHT0ssRGRtQWdDX2VubXVbKi9Gczcuc0tcLys9UzJtKiJCLW1hSkkjLmdgMjhIQyFyOjVPQVRocWhAYCdGZ05UQz8mPnEwLDEhUTpWRWRSS1JuImNnXy5QI1UpYiRQVSUrTDEhU14ySjo9I10rKilJXzoyNGxESmBQKi5WOVVPM0JMJjpHYTdpIi9kZmxvWUhyZGg0PGBbQkIlKWlgKjUvPjNbMUlVZENUNXVpL0cpIiIiUU8lOGMhJiMsYU1nNUhTSFc9J1xBImcjLCI5NjIlNjV1bUJMaUBkPzdFYCVkaUx0bG0tKF1aalw2WjVwWC1QJV9JUXRWcCdiPzphR1YzPSFvQjhvSVZlIWFmQ0A3ZVBcWU9jN1dZTyxwVnMycTo4R20zWE1SQHRtLChRcGw+NkM2KzRFUkAsVTQqWGxHMSpOOiVgJkVfOWBYVGhEcEhgcyllY1k3MStUInNIckdzQzNeXzBEMVozJVxCKlw0XFhjY3I1SjJKVmtuO1MzZmxsJD9FX1g8KGpKQDJMS0lqTD1nTWhXUmdITEgpQlJXQzY2Qj9rRj5OT3QyUWFPYyMuUjhGKTklRlFaSitcbWRWJXJgN1FFc1VvQlIscCZPN2k+IzYoVk9oLztASyhMbjFnWWZRWD4vIyI2XCY/bEBVZXJPQWVPYzNtXiUyIU8uK2UrWGFVQ0A5VjF1cSZoMiVdNiFqQi5fSCVAbD5vL1dbLVNYYjBSNTgsaEEwTjtZZkk1OWU8aW0/OlIrZ1cnJDhRKllJZWc8YHFxMmElSlFTMUFqIy8yJyVKTDY7WTNnLk9hZklENil1S1c0NjRtYEA+XlsmSW88PnRCWyMtI0AoS2wrZ2NkR1QxKjFPVXFLWlNQV19qRjcyM3I3Smc7SGlWVU4pKi43b1MjQFklNGdzPilLM0RuKVZHUEYtMEwkZjtNOztjWCZrUEFQZjsuZ3M6ITQmLGM8VUNkYlFUYU1wPjxCcFJbOXJsZkducyNSdEteWyE9P0YvVjVOaDVaKCdoanBcQjlqQVwzZFdpTl5Nby1WLFxEUDNDQDUkRFNLbXI0PjlCRjFAQlhgcjxwTkIycmZxL1xNV2lpMUAvSTQvMiE6K3M1TEk8Ui9LS0M3dFNtb0FvOjIyZDNKZ2gjQWVdRi9XNGB0TCkkNjopSVhSRFkoQUNzUyhNaE1mPmc2XiduN04qRCNJJHFbXVxuamQsVFwjcEw6ZyRRdVZqWnFeKT90MlUhI0FiRWsidSs/WVFtLmcuVlFjLEtqbDkjYWgiT0QyaTxnKHFGKVpUQkRsXlFGSGRHNmooa282cmNXZ1NDJDNcJEtgN105aU5XMzI2UVheMT4vaUBWXkI0MmIjay9OJl08PyIjUE83JD5mOj1PSkFbOTA9ZVw/YTVIdSssPFU5MEhYZW9VQ3MtPj5OQCI8LCUnZEpeZz5kSCs+b2p0cW1CUV1mRS1ka3VzLjhyaStOamxqYDtaLCxMTlApNUJrfj5lbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCAxMAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwNjEgMDAwMDAgbiAKMDAwMDAwMDExMiAwMDAwMCBuIAowMDAwMDAwMjE5IDAwMDAwIG4gCjAwMDAwMDAzMzEgMDAwMDAgbiAKMDAwMDAwMDQ0NiAwMDAwMCBuIAowMDAwMDAwNjQ5IDAwMDAwIG4gCjAwMDAwMDA3MTcgMDAwMDAgbiAKMDAwMDAwMDk5NyAwMDAwMCBuIAowMDAwMDAxMDU2IDAwMDAwIG4gCnRyYWlsZXIKPDwKL0lEIApbPGZlZjAyNDk3MmZhNjEzZTFlMTY2YjFjYjk0OTNiYmZmPjxmZWYwMjQ5NzJmYTYxM2UxZTE2NmIxY2I5NDkzYmJmZj5dCiUgUmVwb3J0TGFiIGdlbmVyYXRlZCBQREYgZG9jdW1lbnQgLS0gZGlnZXN0IChvcGVuc291cmNlKQoKL0luZm8gNyAwIFIKL1Jvb3QgNiAwIFIKL1NpemUgMTAKPj4Kc3RhcnR4cmVmCjIzMTQKJSVFT0YK';
diff --git a/src/services/engines.ts b/src/services/engines.ts
index 618192828..c48f9d597 100644
--- a/src/services/engines.ts
+++ b/src/services/engines.ts
@@ -259,6 +259,30 @@ export function isRemoteTextModelActive(): boolean {
return true;
}
+/** How to stop the one-shot request generateStandalone is running, so its caller can cancel it. */
+let standaloneStop: (() => Promise) | null = null;
+
+/** Run one standalone request, remembering the engine or provider stop that ends it. */
+async function trackStandalone(
+ stop: () => Promise,
+ run: () => Promise,
+): Promise {
+ standaloneStop = stop;
+ try {
+ return await run();
+ } finally {
+ if (standaloneStop === stop) standaloneStop = null;
+ }
+}
+
+/**
+ * Stop the request generateStandalone is running, through the same engine or provider it runs on.
+ * Its pending call then settles with whatever streamed before the stop. No-op when none is running.
+ */
+export async function stopStandalone(): Promise {
+ await standaloneStop?.();
+}
+
/**
* One-shot standalone text completion on the ACTIVE text engine — engine-agnostic.
*
@@ -288,17 +312,19 @@ export async function generateStandalone(
if (activeRemoteTextModelId && provider.getLoadedModelId() !== activeRemoteTextModelId) {
await provider.loadModel(activeRemoteTextModelId);
}
- let content = '';
- await provider.generate(
- messages,
- { enableThinking: false },
- {
- onToken: (t: string) => { content += t; onToken?.(t); },
- onComplete: (result) => { if (result?.content) content = result.content; },
- onError: (err) => { throw err instanceof Error ? err : new Error(String(err)); },
- },
- );
- return content;
+ return trackStandalone(() => provider.stopGeneration(), async () => {
+ let content = '';
+ await provider.generate(
+ messages,
+ { enableThinking: false },
+ {
+ onToken: (t: string) => { content += t; onToken?.(t); },
+ onComplete: (result) => { if (result?.content) content = result.content; },
+ onError: (err) => { throw err instanceof Error ? err : new Error(String(err)); },
+ },
+ );
+ return content;
+ });
}
if (getActiveEngineService() === liteRTService) {
const system = messages.find(m => m.role === 'system');
@@ -311,15 +337,18 @@ export async function generateStandalone(
history: [],
});
try {
- return await liteRTService.generateRaw(userText, undefined, { onToken: (t: string) => onToken?.(t) });
+ return await trackStandalone(
+ () => liteRTService.stopGeneration(),
+ () => liteRTService.generateRaw(userText, undefined, { onToken: (t: string) => onToken?.(t) }),
+ );
} finally {
liteRTService.invalidateConversation();
}
}
// llama (default engine). Stream tokens for live progress; force thinking OFF so the
// enhanced prompt is a clean rewrite, never a leaked reasoning chain (B30/B30b).
- return llmService.generateResponse(messages, {
+ return trackStandalone(() => llmService.stopGeneration(), () => llmService.generateResponse(messages, {
onStream: onToken ? (data) => { if (typeof (data as { content?: string })?.content === 'string') onToken((data as { content: string }).content); } : () => {},
disableThinking: true,
- });
+ }));
}
diff --git a/src/services/imageGenerationResult.ts b/src/services/imageGenerationResult.ts
index 02a86a348..a263f348c 100644
--- a/src/services/imageGenerationResult.ts
+++ b/src/services/imageGenerationResult.ts
@@ -1,6 +1,8 @@
import { useAppStore, useChatStore } from '../stores';
import type { GeneratedImage } from '../types';
+import logger from '../utils/logger';
import { buildImageGenMeta, scheduleImageSharePrompt } from './imageGenerationHelpers';
+import { localDreamGeneratorService } from './localDreamGenerator';
import type {
ActiveImageModel,
GenerateImageParams,
@@ -20,7 +22,17 @@ export function completedImageGenerationState(
};
}
-export function saveImageGenerationResult(
+/** True when the image was drawn for a chat that has since been deleted. */
+function conversationIsGone(conversationId: string | undefined): boolean {
+ if (!conversationId) return false;
+ return !useChatStore.getState().conversations.some(c => c.id === conversationId);
+}
+
+/**
+ * Publish a finished image: Gallery record, and a chat message when it belongs to a chat. A result
+ * for a chat that was deleted while it was drawing is not published, and its file is removed.
+ */
+export async function saveImageGenerationResult(
result: GeneratedImage,
input: {
params: GenerateImageParams;
@@ -32,8 +44,21 @@ export function saveImageGenerationResult(
startTime: number;
isRemote?: boolean;
},
-): GeneratedImage {
+): Promise {
const { params, activeImageModel } = input;
+ if (conversationIsGone(params.conversationId)) {
+ logger.log('[IMG-SM] result for a deleted chat, not saved');
+ const removed = await localDreamGeneratorService
+ .deleteGeneratedImage(result.id, result.imagePath)
+ .catch(() => false);
+ if (!removed) {
+ result.modelId = activeImageModel.id;
+ result.conversationId = params.conversationId;
+ useAppStore.getState().addGeneratedImage(result);
+ logger.warn('[ImageGen] could not remove the image of a deleted chat; kept in Gallery');
+ }
+ return null;
+ }
result.modelId = activeImageModel.id;
if (params.conversationId) result.conversationId = params.conversationId;
const appStore = useAppStore.getState();
diff --git a/src/services/imageGenerationService.ts b/src/services/imageGenerationService.ts
index f11ef5843..5f5b970de 100644
--- a/src/services/imageGenerationService.ts
+++ b/src/services/imageGenerationService.ts
@@ -11,7 +11,10 @@ import {
generationProgressStatus,
imagePhaseTransitionLog,
} from './imageGenerationHelpers';
-import { enhanceImagePrompt } from './imagePromptEnhancement';
+import {
+ cancelImagePromptEnhancement,
+ enhanceImagePrompt,
+} from './imagePromptEnhancement';
import {
completedImageGenerationState,
saveImageGenerationResult,
@@ -34,6 +37,19 @@ export type {
ImageGenerationState,
} from './imageGenerationTypes';
+/**
+ * One generate request. Cancel and the remote request belong to the request, not the service, so
+ * a cancelled request that is still unwinding can never read the next request's flag, clear its
+ * controller or write its progress.
+ */
+interface ImageJob {
+ conversationId: string | null;
+ cancelled: boolean;
+ remoteRequest: AbortController | null;
+ /** Settles when the request has fully ended, whatever its outcome. */
+ settled: Promise;
+}
+
class ImageGenerationService {
// The ONLY stored state is `phase` (+ the data fields). `isGenerating` is NOT
// stored — there's no second source to desync. It's computed from phase in
@@ -51,8 +67,8 @@ class ImageGenerationService {
};
private readonly listeners: Set = new Set();
- private cancelRequested: boolean = false;
- private remoteRequest: AbortController | null = null;
+ /** The latest request. A deleted chat waits for its own job; only this job may publish state. */
+ private job: ImageJob | null = null;
/** Last generate request, so a failure card's Retry button can re-run it. */
private _lastParams: GenerateImageParams | null = null;
@@ -93,6 +109,31 @@ class ImageGenerationService {
this.notifyListeners();
}
+ /** True while `job` is the latest request and nobody cancelled it. */
+ private isLive(job: ImageJob): boolean {
+ return this.job === job && !job.cancelled;
+ }
+
+ /** Publish state for `job` only while it is live, so a cancelled request stays silent. */
+ private updateFor(job: ImageJob, partial: Partial): void {
+ if (this.isLive(job)) this.updateState(partial);
+ }
+
+ /** A request that noticed its cancel returns the card to idle, unless a newer request owns it. */
+ private resetFor(job: ImageJob): null {
+ if (this.job === job) this.resetState();
+ return null;
+ }
+
+ /** Fail `job`; a cancelled or replaced request shows no failure card. */
+ private failFor(
+ job: ImageJob,
+ error: string,
+ opts?: { cause?: unknown; remote?: boolean },
+ ): null {
+ return this.isLive(job) ? this._fail(error, opts) : null;
+ }
+
/** Own the terminal error state and its retry actions. */
private _fail(error: string, opts?: { cause?: unknown; remote?: boolean }): null {
this.updateState({
@@ -140,10 +181,11 @@ class ImageGenerationService {
}
private _setEnhancementState(
+ job: ImageJob,
params: GenerateImageParams,
status: string,
): void {
- this.updateState({
+ this.updateFor(job, {
phase: 'enhancing',
prompt: params.prompt,
conversationId: params.conversationId || null,
@@ -156,14 +198,18 @@ class ImageGenerationService {
}
private async _enhancePrompt(
+ job: ImageJob,
params: GenerateImageParams,
): Promise {
- return enhanceImagePrompt(params, status =>
- this._setEnhancementState(params, status),
+ return enhanceImagePrompt(
+ params,
+ status => this._setEnhancementState(job, params, status),
+ () => !this.isLive(job),
);
}
private async _ensureImageModelLoaded(
+ job: ImageJob,
activeImageModelId: string | null,
activeImageModel: ActiveImageModel,
opts: { desiredThreads: number; override?: boolean },
@@ -180,11 +226,11 @@ class ImageGenerationService {
)
return true;
if (!activeImageModelId) {
- this._fail('No image model selected');
+ this.failFor(job, 'No image model selected');
return false;
}
try {
- this.updateState({
+ this.updateFor(job, {
phase: 'loading',
status: `Loading ${activeImageModel.name}...`,
});
@@ -197,7 +243,8 @@ class ImageGenerationService {
} 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(
+ this.failFor(
+ job,
`Failed to load image model: ${error?.message || 'Unknown error'}`,
{ cause: error },
);
@@ -206,6 +253,7 @@ class ImageGenerationService {
}
private async _runGenerationAndSave(
+ job: ImageJob,
opts: RunGenerationOptions,
): Promise {
const {
@@ -240,7 +288,7 @@ class ImageGenerationService {
}
}
- this.updateState({
+ this.updateFor(job, {
phase: 'generating',
status: isFirstRun
? 'Optimizing GPU for your device (~120s, one-time)...'
@@ -261,7 +309,7 @@ class ImageGenerationService {
useOpenCL,
},
progress => {
- if (this.cancelRequested) return;
+ if (!this.isLive(job)) return;
const displayStep = Math.min(progress.step, steps);
// Once steps are advancing it IS generating — don't mislabel it "GPU
// optimization" (which read as if generation hadn't started). On the first run
@@ -277,7 +325,7 @@ class ImageGenerationService {
});
},
preview => {
- if (this.cancelRequested) return;
+ if (!this.isLive(job)) return;
const displayStep = Math.min(preview.step, steps);
this.updateState({
previewPath: `file://${preview.previewPath}?t=${Date.now()}`,
@@ -285,12 +333,22 @@ class ImageGenerationService {
});
},
);
- if (this.cancelRequested || !result?.imagePath) {
- this.resetState();
- return null;
+ if (!this.isLive(job) || !result?.imagePath) {
+ if (result?.imagePath) {
+ const removed = await onnxImageGeneratorService
+ .deleteGeneratedImage(result.id, result.imagePath)
+ .catch(() => false);
+ if (!removed) {
+ result.modelId = activeImageModel.id;
+ if (params.conversationId) result.conversationId = params.conversationId;
+ useAppStore.getState().addGeneratedImage(result);
+ logger.warn('[ImageGen] could not remove a cancelled image; kept in Gallery');
+ }
+ }
+ return this.resetFor(job);
}
this.updateState(completedImageGenerationState(result));
- return saveImageGenerationResult(result, {
+ return await saveImageGenerationResult(result, {
params,
activeImageModel,
messageId: this.state.messageId,
@@ -301,8 +359,8 @@ class ImageGenerationService {
});
} catch (error: any) {
const errorMsg = error?.message || 'Image generation failed';
- if (errorMsg.includes('cancelled')) {
- this.resetState();
+ if (errorMsg.includes('cancelled') || !this.isLive(job)) {
+ this.resetFor(job);
} else {
logger.error('[ImageGenerationService] Generation error:', error);
@@ -317,7 +375,7 @@ class ImageGenerationService {
? 'Image generation failed — the model encountered an error and was unloaded. Please try again.'
: errorMsg;
- this._fail(userMessage);
+ this.failFor(job, userMessage);
}
return null;
}
@@ -331,29 +389,50 @@ class ImageGenerationService {
params: GenerateImageParams,
opts?: { override?: boolean },
): Promise {
- if (isInFlight(this.state.phase)) {
+ if (isInFlight(this.state.phase) || (this.job && !this.job.cancelled)) {
logger.log(
'[ImageGenerationService] Already generating, ignoring request',
);
return null;
}
- this.cancelRequested = false;
+ // A cancelled request may still be unwinding (a text model mid-load, a native cancel). It owns
+ // its own flag and controller and publishes nothing once replaced, so the new request starts now.
+ const job: ImageJob = {
+ conversationId: params.conversationId || null,
+ cancelled: false,
+ remoteRequest: null,
+ settled: Promise.resolve(),
+ };
+ this.job = job;
this._lastParams = params; // so a failure card's Retry can re-run this exact request
+ const run = this._generate(job, params, opts);
+ job.settled = run.catch(() => null);
+ try {
+ return await run;
+ } finally {
+ if (this.job === job) this.job = null;
+ }
+ }
+
+ private async _generate(
+ job: ImageJob,
+ params: GenerateImageParams,
+ opts?: { override?: boolean },
+ ): Promise {
const remoteServer = useRemoteServerStore
.getState()
.getActiveRemoteMediaServer('image');
if (remoteServer?.mediaModels?.image) {
- const enhancedPrompt = await this._enhancePrompt(params);
- if (this.cancelRequested) {
- this.resetState();
- return null;
- }
+ const enhancedPrompt = await this._enhancePrompt(job, params);
+ if (!this.isLive(job)) return this.resetFor(job);
return runRemoteImageGeneration(params, remoteServer, {
- updateState: state => this.updateState(state),
- fail: (message, cause) => this._fail(message, { cause, remote: true }),
- isCancelled: () => this.cancelRequested,
+ updateState: state => this.updateFor(job, state),
+ fail: (message, cause) => this.failFor(job, message, { cause, remote: true }),
+ isCancelled: () => !this.isLive(job),
setRequest: controller => {
- this.remoteRequest = controller;
+ job.remoteRequest = controller;
+ // Cancelled between the check and the request: stop it before it is sent.
+ if (controller && job.cancelled) controller.abort();
},
}, { ...opts, enhancedPrompt });
}
@@ -362,7 +441,7 @@ class ImageGenerationService {
const activeImageModel = downloadedImageModels.find(
m => m.id === activeImageModelId,
);
- if (!activeImageModel) return this._fail('No image model selected');
+ if (!activeImageModel) return this.failFor(job, 'No image model selected');
const messageId = params.conversationId ? generateId() : null;
@@ -375,7 +454,7 @@ class ImageGenerationService {
const imageWidth = imageParameters.size;
const imageHeight = imageParameters.size;
- this.updateState({
+ this.updateFor(job, {
phase: settings.enhanceImagePrompts ? 'enhancing' : 'loading',
prompt: params.prompt,
conversationId: params.conversationId || null,
@@ -389,17 +468,14 @@ class ImageGenerationService {
result: null,
});
- const enhancedPrompt = await this._enhancePrompt(params);
+ const enhancedPrompt = await this._enhancePrompt(job, params);
logger.log(
'[ImageGen] enhanceImagePrompts setting:',
settings.enhanceImagePrompts,
);
// Stop can arrive while prompt enhancement owns the text engine. Do not clear that request and
// continue into the image model after the user already pressed X.
- if (this.cancelRequested) {
- this.resetState();
- return null;
- }
+ if (!this.isLive(job)) return this.resetFor(job);
// Establish the generating state unconditionally — not only when enhancement
// is off. When enhancement is ON but _enhancePrompt bailed early (e.g. no text
@@ -407,7 +483,7 @@ class ImageGenerationService {
// in-progress card never appeared. Setting it here fixes that; on the
// enhancement-ran path this just swaps the 'Enhancing…' status for 'Preparing…'
// before the image model loads.
- this.updateState({
+ this.updateFor(job, {
phase: 'loading',
prompt: params.prompt,
conversationId: params.conversationId || null,
@@ -419,17 +495,15 @@ class ImageGenerationService {
});
const loaded = await this._ensureImageModelLoaded(
+ job,
activeImageModelId,
activeImageModel,
{ desiredThreads: settings.imageThreads ?? 4, override: opts?.override },
);
if (!loaded) return null;
- if (this.cancelRequested) {
- this.resetState();
- return null;
- }
+ if (!this.isLive(job)) return this.resetFor(job);
- return this._runGenerationAndSave({
+ return this._runGenerationAndSave(job, {
params,
enhancedPrompt,
activeImageModel,
@@ -441,10 +515,31 @@ class ImageGenerationService {
});
}
+ /**
+ * Cancel the request drawing an image for this conversation and wait until it has ended. Deleting
+ * a chat calls this first, so no image is saved for the chat after its images are removed.
+ */
+ async cancelGenerationFor(conversationId: string): Promise {
+ const job = this.job;
+ if (job?.conversationId !== conversationId) return;
+ await this.cancelGeneration();
+ await job.settled;
+ }
+
async cancelGeneration(): Promise {
- if (!isInFlight(this.state.phase)) return;
- this.cancelRequested = true;
- this.remoteRequest?.abort();
+ const job = this.job;
+ if (!isInFlight(this.state.phase)) {
+ // Started but not yet showing progress: the run checks its job before each step.
+ if (job) job.cancelled = true;
+ return;
+ }
+ if (job) {
+ job.cancelled = true;
+ job.remoteRequest?.abort();
+ }
+ // While enhancing, the job waits on a text request that the image backends cannot stop.
+ const enhancement =
+ this.state.phase === 'enhancing' ? cancelImagePromptEnhancement() : null;
// Publish the terminal while conversation identity is still present. Sync subscribers run
// synchronously, so every peer can remove its live image card before native cancellation waits.
this.updateState({
@@ -455,11 +550,13 @@ class ImageGenerationService {
error: null,
});
try {
+ await enhancement;
await onnxImageGeneratorService.cancelGeneration();
} catch {
/* Ignore */
} finally {
- this.resetState();
+ // A newer request may own the card by now; only clear what this cancel ended.
+ if (!job || this.job === job) this.resetState();
}
}
diff --git a/src/services/imagePromptEnhancement.ts b/src/services/imagePromptEnhancement.ts
index e17be9848..1d877f875 100644
--- a/src/services/imagePromptEnhancement.ts
+++ b/src/services/imagePromptEnhancement.ts
@@ -6,6 +6,7 @@ import {
generateStandalone,
getActiveEngineService,
isRemoteTextModelActive,
+ stopStandalone,
} from './engines';
import {
buildEnhancementCardContent,
@@ -109,16 +110,34 @@ function enhancementTokenWriter(
};
}
+/**
+ * Stop a prompt enhancement that is waiting on its text request, local or remote. The pending
+ * enhanceImagePrompt then returns promptly instead of holding its image job open.
+ */
+export async function cancelImagePromptEnhancement(): Promise {
+ try {
+ await stopStandalone();
+ } catch (error) {
+ logger.warn('[ImageGen] Failed to stop prompt enhancement:', error);
+ }
+}
+
+/**
+ * Rewrite the image prompt on the text model. `isCancelled` reports that the image request was
+ * cancelled: it is checked once the text model is ready, so a request cancelled while the model
+ * was loading never starts a text request or a chat message.
+ */
export async function enhanceImagePrompt(
params: GenerateImageParams,
setState: EnhancementStateWriter,
+ isCancelled: () => boolean = () => false,
): Promise {
if (!useAppStore.getState().settings.enhanceImagePrompts) return params.prompt;
const loaded =
isRemoteTextModelActive() ||
(getActiveEngineService()?.isModelLoaded() ?? false) ||
(await loadTextModel(setState));
- if (!loaded) return params.prompt;
+ if (!loaded || isCancelled()) return params.prompt;
setState(PROMPT_ENHANCEMENT_STATUS);
const context = params.conversationId
diff --git a/src/services/micPermission.ts b/src/services/micPermission.ts
new file mode 100644
index 000000000..421e2ae33
--- /dev/null
+++ b/src/services/micPermission.ts
@@ -0,0 +1,52 @@
+/**
+ * Microphone permission, read without prompting, on both platforms.
+ *
+ * The mic button looked usable when the permission was denied: nothing read the permission until
+ * the person pressed it, and then the press failed. This answers "is the mic denied?" up front so
+ * the button can say so and point to Settings.
+ *
+ * - The OS answer comes from react-native-audio-api's checkRecordingPermissions, already a
+ * dependency: AVAudioApplication/AVAudioSession recordPermission on iOS, checkSelfPermission on
+ * Android. Neither shows a prompt.
+ * - Android reports a "Don't ask again" denial as Undetermined (it cannot tell it apart from
+ * never-asked). The request paths report a refused request here, so that case reads as denied
+ * too, until the OS says Granted again.
+ */
+import { AudioManager } from 'react-native-audio-api';
+import logger from '../utils/logger';
+
+type Listener = (denied: boolean) => void;
+
+let deniedByRequest = false;
+const listeners = new Set();
+
+function emit(denied: boolean): void {
+ listeners.forEach(listener => listener(denied));
+}
+
+/** True when the microphone permission is denied. Never prompts; an unreadable answer is not denied. */
+export async function isMicPermissionDenied(): Promise {
+ let status: string;
+ try {
+ status = await AudioManager.checkRecordingPermissions();
+ } catch (error) {
+ logger.warn('[MicPermission] could not read the permission:', error);
+ return false;
+ }
+ if (status === 'Granted') deniedByRequest = false;
+ return status === 'Denied' || (status === 'Undetermined' && deniedByRequest);
+}
+
+/** A permission request was refused. Called by the paths that request the mic. */
+export function noteMicPermissionRefused(): void {
+ deniedByRequest = true;
+ emit(true);
+}
+
+/** Called when a refused request is reported, so a visible mic button updates at once. */
+export function onMicPermissionRefused(listener: Listener): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
diff --git a/src/services/modelManager/storage.ts b/src/services/modelManager/storage.ts
index b4bad0963..f0a70e44b 100644
--- a/src/services/modelManager/storage.ts
+++ b/src/services/modelManager/storage.ts
@@ -1,7 +1,7 @@
import RNFS from 'react-native-fs';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { DownloadedModel, LlamaDownloadedModel, LiteRTDownloadedModel, ModelFile, ModelCredibility, ModelOrigin, ONNXImageModel } from '../../types';
-import { LMSTUDIO_AUTHORS, OFFICIAL_MODEL_AUTHORS, VERIFIED_QUANTIZERS } from '../../constants';
+import { LMSTUDIO_AUTHORS, OFFICIAL_MODEL_AUTHORS, RECOMMENDED_MODELS, VERIFIED_QUANTIZERS } from '../../constants';
import { getCuratedLiteRTEntry } from '../curatedLiteRTRegistry';
import logger from '../../utils/logger';
import { statFile } from '../../utils/fileStat';
@@ -135,7 +135,13 @@ export async function loadDownloadedModels(modelsDir: string): Promise model.id === modelId);
+ if (curated) return curated.name;
+ const basename = modelId.split('/').pop() || modelId;
+ return basename.replace(/[-_]GGUF$/i, '') || basename;
}
export async function buildDownloadedModel(opts: BuildModelOpts): Promise {
diff --git a/src/services/modelManager/visionRepairService.ts b/src/services/modelManager/visionRepairService.ts
index 7e213bcc4..e054f9063 100644
--- a/src/services/modelManager/visionRepairService.ts
+++ b/src/services/modelManager/visionRepairService.ts
@@ -237,15 +237,16 @@ export async function clearMmProjLink(
modelId: string,
): Promise {
const models = await ctx.getDownloadedModels();
+ // Clearing a link must preserve the model's vision identity. Keep the filename
+ // so repair can relink the existing file or download its replacement.
await commitModelsList(
models.map(m =>
m.id === modelId
? {
...m,
mmProjPath: undefined,
- mmProjFileName: undefined,
mmProjFileSize: undefined,
- isVisionModel: false,
+ isVisionModel: true,
}
: m,
),
diff --git a/src/services/offGridDesktopModels.ts b/src/services/offGridDesktopModels.ts
index 638990f91..415e217f3 100644
--- a/src/services/offGridDesktopModels.ts
+++ b/src/services/offGridDesktopModels.ts
@@ -7,6 +7,7 @@ import type {
RemoteServer,
} from '../types';
import { predictGgufCapabilities } from '../utils/ggufCapabilities';
+import { displayModelName } from '../utils/displayModelName';
import {
REMOTE_FETCH_REDIRECT_POLICY,
remoteAuthorizationHeaders,
@@ -80,7 +81,9 @@ function parseCatalog(value: unknown): GatewayCatalogModel[] | null {
}
models.push({
id: candidate.id.trim(),
- name: candidate.name.trim(),
+ // Desktop names an imported model by its file path; every picker reads this name, so it is
+ // cleaned once here, exactly as the other remote discovery paths already clean theirs.
+ name: displayModelName(candidate.name.trim()),
kind: candidate.kind,
files: modelFiles(candidate.files),
});
diff --git a/src/services/providers/openAICompatibleProvider.ts b/src/services/providers/openAICompatibleProvider.ts
index e74f4503e..96ed9ed07 100644
--- a/src/services/providers/openAICompatibleProvider.ts
+++ b/src/services/providers/openAICompatibleProvider.ts
@@ -167,7 +167,7 @@ export class OpenAICompatibleProvider implements LLMProvider {
let baseUrl = this.config.endpoint;
while (baseUrl.endsWith('/')) baseUrl = baseUrl.slice(0, -1);
- const url = `${baseUrl}/v1/chat/completions`;
+ const url = `${baseUrl}${baseUrl.endsWith('/v1') ? '' : '/v1'}/chat/completions`;
const state: OpenAIStreamState = {
fullContent: '', fullReasoningContent: '',
diff --git a/src/services/remoteImageGeneration.ts b/src/services/remoteImageGeneration.ts
index 2f6208535..12cab120c 100644
--- a/src/services/remoteImageGeneration.ts
+++ b/src/services/remoteImageGeneration.ts
@@ -2,6 +2,7 @@ import RNFS from 'react-native-fs';
import type { GeneratedImage, RemoteServer } from '../types';
import { useAppStore } from '../stores';
import { generateId } from '../utils/generateId';
+import logger from '../utils/logger';
import type { GenerateImageParams, ImageGenerationState } from './imageGenerationTypes';
import { resolveMobileImageParameters } from './imageParameterPolicy';
import { remoteMediaRuntime } from './remoteMediaRuntime';
@@ -17,6 +18,85 @@ interface RemoteImageGenerationDeps {
setRequest: (controller: AbortController | null) => void;
}
+/** Remove a file this run wrote but did not publish, so a retry starts clean. */
+async function removePartialFile(path: string | null): Promise {
+ if (!path) return;
+ try {
+ if (await RNFS.exists(path)) await RNFS.unlink(path);
+ } catch (error) {
+ logger.warn('[RemoteImage] could not remove an incomplete image file', error);
+ }
+}
+
+/** Transfer the image file. Cancel stops the transfer instead of letting it finish in the background. */
+async function downloadImage(fromUrl: string, toFile: string, signal: AbortSignal) {
+ // Cancelled while the folder was being made: an abort listener added now would never fire.
+ if (signal.aborted) throw new Error('Image generation cancelled');
+ const transfer = RNFS.downloadFile({ fromUrl, toFile });
+ const stopTransfer = () => RNFS.stopDownload(transfer.jobId);
+ signal.addEventListener('abort', stopTransfer);
+ try {
+ return await transfer.promise;
+ } finally {
+ signal.removeEventListener('abort', stopTransfer);
+ }
+}
+
+/**
+ * Write the returned image to disk: decode inline data or transfer it from its URL. `track` is
+ * told every path that may hold bytes, so the caller can remove a file it does not publish.
+ */
+async function storeRemoteImage(
+ remote: { url?: string; base64?: string },
+ signal: AbortSignal,
+ track: (path: string) => void,
+): Promise<{ id: string; fileName: string; imagePath: string }> {
+ const dataUrl = remote.url?.match(/^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=]+)$/i);
+ const base64 = dataUrl?.[2] ?? remote.base64;
+ if (!base64 && !/^https?:\/\//i.test(remote.url ?? '')) {
+ throw new Error('Remote server returned no image data');
+ }
+ const id = generateId();
+ const directory = `${RNFS.DocumentDirectoryPath}/generated_images`;
+ const urlExtension = remote.url?.match(/\.(png|jpe?g|webp)(?:[?#]|$)/i)?.[1]?.toLowerCase();
+ const extension = dataUrl?.[1]?.toLowerCase() === 'image/jpeg'
+ ? 'jpg'
+ : dataUrl?.[1]?.toLowerCase().replace('image/', '') ?? urlExtension ?? 'png';
+ let fileName = `${id}.${extension}`;
+ let imagePath = `${directory}/${fileName}`;
+ await RNFS.mkdir(directory);
+ track(imagePath);
+ if (base64) {
+ await RNFS.writeFile(imagePath, base64, 'base64');
+ return { id, fileName, imagePath };
+ }
+ const outcome = await downloadImage(remote.url!, imagePath, signal);
+ if (outcome.statusCode < 200 || outcome.statusCode >= 300) {
+ throw new Error(`Image download returned HTTP ${outcome.statusCode}`);
+ }
+ if (outcome.bytesWritten <= 0) {
+ throw new Error('Remote server returned an empty image');
+ }
+ const contentType = Object.entries(outcome.headers ?? {})
+ .find(([key]) => key.toLowerCase() === 'content-type')?.[1]
+ ?.split(';')[0]?.toLowerCase();
+ const receivedExtension = contentType === 'image/jpeg' ? 'jpg'
+ : contentType === 'image/png' ? 'png'
+ : contentType === 'image/webp' ? 'webp'
+ : undefined;
+ if (contentType && !receivedExtension) {
+ throw new Error('Remote server returned an unsupported image format');
+ }
+ if (receivedExtension && receivedExtension !== extension) {
+ fileName = `${id}.${receivedExtension}`;
+ const correctedPath = `${directory}/${fileName}`;
+ await RNFS.moveFile(imagePath, correctedPath);
+ imagePath = correctedPath;
+ track(correctedPath);
+ }
+ return { id, fileName, imagePath };
+}
+
export async function runRemoteImageGeneration(
params: GenerateImageParams,
server: RemoteServer,
@@ -41,6 +121,7 @@ export async function runRemoteImageGeneration(
});
const controller = new AbortController();
deps.setRequest(controller);
+ let partialPath: string | null = null;
try {
const remote = await remoteMediaRuntime.generateImage(
server,
@@ -58,52 +139,22 @@ export async function runRemoteImageGeneration(
},
},
);
- const dataUrl = remote.url?.match(/^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=]+)$/i);
- const base64 = dataUrl?.[2] ?? remote.base64;
- if (!base64 && !/^https?:\/\//i.test(remote.url ?? '')) {
- throw new Error('Remote server returned no image data');
- }
if (deps.isCancelled()) return null;
- const id = generateId();
- const directory = `${RNFS.DocumentDirectoryPath}/generated_images`;
- const urlExtension = remote.url?.match(/\.(png|jpe?g|webp)(?:[?#]|$)/i)?.[1]?.toLowerCase();
- const extension = dataUrl?.[1]?.toLowerCase() === 'image/jpeg'
- ? 'jpg'
- : dataUrl?.[1]?.toLowerCase().replace('image/', '') ?? urlExtension ?? 'png';
- let fileName = `${id}.${extension}`;
- let imagePath = `${directory}/${fileName}`;
- await RNFS.mkdir(directory);
- if (base64) {
- await RNFS.writeFile(imagePath, base64, 'base64');
- } else {
- const transfer = RNFS.downloadFile({ fromUrl: remote.url!, toFile: imagePath });
- const outcome = await transfer.promise;
- if (outcome.statusCode < 200 || outcome.statusCode >= 300) {
- throw new Error(`Image download returned HTTP ${outcome.statusCode}`);
- }
- const contentType = Object.entries(outcome.headers ?? {})
- .find(([key]) => key.toLowerCase() === 'content-type')?.[1]
- ?.split(';')[0]?.toLowerCase();
- const receivedExtension = contentType === 'image/jpeg' ? 'jpg'
- : contentType === 'image/png' ? 'png'
- : contentType === 'image/webp' ? 'webp'
- : undefined;
- if (contentType && !receivedExtension) {
- throw new Error('Remote server returned an unsupported image format');
- }
- if (receivedExtension && receivedExtension !== extension) {
- fileName = `${id}.${receivedExtension}`;
- const correctedPath = `${directory}/${fileName}`;
- await RNFS.moveFile(imagePath, correctedPath);
- imagePath = correctedPath;
- }
+ const { id, fileName, imagePath } = await storeRemoteImage(
+ remote, controller.signal, path => { partialPath = path; },
+ );
+ // Cancel may land while the file was being written or moved. Never publish it then.
+ if (controller.signal.aborted || deps.isCancelled()) {
+ await removePartialFile(partialPath);
+ return null;
}
+ partialPath = null;
const result: GeneratedImage = {
id, prompt: params.prompt, negativePrompt: params.negativePrompt, imagePath, fileName,
width, height, steps, seed: params.seed ?? 0, modelId, createdAt: new Date().toISOString(),
};
deps.updateState(completedImageGenerationState(result));
- return saveImageGenerationResult(result, {
+ return await saveImageGenerationResult(result, {
params,
activeImageModel: {
id: modelId, name: `${server.name} / ${modelId}`, modelPath: server.endpoint, backend: 'remote',
@@ -111,6 +162,8 @@ export async function runRemoteImageGeneration(
messageId, steps, guidanceScale, useOpenCL: false, startTime, isRemote: true,
});
} catch (error) {
+ // A failed or cancelled transfer must not leave an untracked file behind.
+ await removePartialFile(partialPath);
if (controller.signal.aborted || deps.isCancelled()) return null;
return deps.fail(error instanceof Error ? error.message : 'Remote image generation failed', error);
} finally {
diff --git a/src/services/remoteMediaRuntime.ts b/src/services/remoteMediaRuntime.ts
index 996d2e531..9131b1f08 100644
--- a/src/services/remoteMediaRuntime.ts
+++ b/src/services/remoteMediaRuntime.ts
@@ -3,6 +3,7 @@ import type { RemoteMediaModelIds, RemoteServer } from '../types';
import { REMOTE_FETCH_REDIRECT_POLICY, remoteAuthorizationHeaders } from './remoteTransportPolicy';
import { remoteHttpErrorMessage } from './httpClient';
import { OverridableMemoryError } from './modelLoadErrors';
+import logger from '../utils/logger';
export interface RemoteImageResult {
base64?: string;
@@ -37,11 +38,21 @@ async function request(
): Promise {
const { server, path, init, signal } = input;
const controller = new AbortController();
- const abort = () => controller.abort();
+ // An async image submission must receive its ID before it can cancel the Desktop job.
+ const needsRequestId = server.modelManagement === 'offgrid-desktop-v1' &&
+ path === '/v1/images/generations' && init.method === 'POST';
+ let sent = false;
+ let responseTimer: ReturnType | undefined;
+ const abort = () => {
+ if (!needsRequestId || !sent) controller.abort();
+ };
signal?.addEventListener('abort', abort, { once: true });
+ if (signal?.aborted) abort();
try {
const apiKey = await remoteServerManager.getApiKey(server.id);
if (controller.signal.aborted) throw new Error('Remote request cancelled');
+ sent = true;
+ if (needsRequestId) responseTimer = setTimeout(() => controller.abort(), 30000);
const response = await fetch(endpoint(server, path), {
...init,
headers: {
@@ -70,9 +81,16 @@ async function request(
// consumed. A successful header is not a completed image/audio transfer.
return await consume(response);
} catch (error) {
+ if (needsRequestId && sent && signal?.aborted) {
+ logger.warn('[RemoteImage] no Desktop request ID received; remote cancellation is unconfirmed:', error);
+ }
+ if (needsRequestId && controller.signal.aborted && !signal?.aborted) {
+ throw new Error('Desktop image request timed out');
+ }
if (controller.signal.aborted) throw new Error('Remote request cancelled');
throw error;
} finally {
+ if (responseTimer) clearTimeout(responseTimer);
signal?.removeEventListener('abort', abort);
}
}
@@ -125,36 +143,65 @@ export const remoteMediaRuntime = {
}, response => response.json() as Promise);
let result: ImagePayload = payload;
if (desktop && payload.request_id) {
- while (true) {
- if (options.signal?.aborted) throw new Error('Remote request cancelled');
- const state = await request({
+ const requestId = payload.request_id;
+ let cancellation: Promise | undefined;
+ const cancelDesktopRequest = () => {
+ if (cancellation) return;
+ const cancelController = new AbortController();
+ const timeout = setTimeout(() => cancelController.abort(), 30000);
+ cancellation = request({
server,
- path: `/v1/requests/${encodeURIComponent(payload.request_id)}`,
- init: { method: 'GET' },
- signal: options.signal,
- }, response => response.json() as Promise<{
- status: string;
- result?: ImagePayload;
- error?: { message?: string };
- progress?: { step: number; total: number };
- }>);
- if (state.progress) options.onImageProgress?.(state.progress.step, state.progress.total);
- if (state.status === 'completed') {
- result = state.result ?? {};
- break;
+ path: `/v1/requests/${encodeURIComponent(requestId)}`,
+ init: { method: 'DELETE' },
+ // The caller's signal is already aborted. Cleanup needs a fresh request.
+ signal: cancelController.signal,
+ }, response => response.json() as Promise<{ cancelled: boolean; status: string }>)
+ .then(state => {
+ if (!state.cancelled) {
+ logger.log('[RemoteImage] Desktop request was not cancelled:', requestId, state.status);
+ }
+ })
+ .catch(error => logger.warn('[RemoteImage] could not confirm Desktop cancellation:', error))
+ .finally(() => clearTimeout(timeout));
+ };
+ options.signal?.addEventListener('abort', cancelDesktopRequest, { once: true });
+ if (options.signal?.aborted) cancelDesktopRequest();
+ try {
+ while (true) {
+ if (options.signal?.aborted) throw new Error('Remote request cancelled');
+ const state = await request({
+ server,
+ path: `/v1/requests/${encodeURIComponent(payload.request_id)}`,
+ init: { method: 'GET' },
+ signal: options.signal,
+ }, response => response.json() as Promise<{
+ status: string;
+ result?: ImagePayload;
+ error?: { message?: string };
+ progress?: { step: number; total: number };
+ }>);
+ if (state.progress) options.onImageProgress?.(state.progress.step, state.progress.total);
+ if (options.signal?.aborted) throw new Error('Remote request cancelled');
+ if (state.status === 'completed') {
+ result = state.result ?? {};
+ break;
+ }
+ if (state.status === 'failed') throw new Error(state.error?.message ?? 'Remote image generation failed');
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ options.signal?.removeEventListener('abort', abort);
+ resolve();
+ }, 1000);
+ const abort = () => {
+ clearTimeout(timer);
+ reject(new Error('Remote request cancelled'));
+ };
+ options.signal?.addEventListener('abort', abort, { once: true });
+ });
}
- if (state.status === 'failed') throw new Error(state.error?.message ?? 'Remote image generation failed');
- await new Promise((resolve, reject) => {
- const timer = setTimeout(() => {
- options.signal?.removeEventListener('abort', abort);
- resolve();
- }, 1000);
- const abort = () => {
- clearTimeout(timer);
- reject(new Error('Remote request cancelled'));
- };
- options.signal?.addEventListener('abort', abort, { once: true });
- });
+ } finally {
+ options.signal?.removeEventListener('abort', cancelDesktopRequest);
+ if (cancellation) await cancellation;
}
}
const image = result.data?.[0];
diff --git a/src/services/whisperService.ts b/src/services/whisperService.ts
index 7bf2f397a..755f23339 100644
--- a/src/services/whisperService.ts
+++ b/src/services/whisperService.ts
@@ -6,6 +6,7 @@ import {
import { Platform, PermissionsAndroid } from 'react-native';
import logger from '../utils/logger';
import { audioSessionManager } from './audioSessionManager';
+import { noteMicPermissionRefused } from './micPermission';
import { audioRecorderService } from './audioRecorderService';
import { cleanTranscription } from './whisperModels';
import * as whisperModelFiles from './whisperModelFiles';
@@ -163,6 +164,7 @@ class WhisperService {
buttonNegative: 'Cancel',
},
);
+ if (granted !== PermissionsAndroid.RESULTS.GRANTED) noteMicPermissionRefused();
return granted === PermissionsAndroid.RESULTS.GRANTED;
} catch (error) {
logger.error('[Whisper] Failed to request permission:', error);
diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts
index 053637864..93d5f3b59 100644
--- a/src/stores/appStore.ts
+++ b/src/stores/appStore.ts
@@ -128,6 +128,8 @@ export type AppSettings = {
type ThemeMode = 'system' | 'light' | 'dark';
export interface AppState extends ProAccessSlice {
+ showSyncStatusBar: boolean;
+ setShowSyncStatusBar: (visible: boolean) => void;
themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void;
hasCompletedOnboarding: boolean;
@@ -290,6 +292,8 @@ const appStorage = createHydrationGatedStorage(
export const useAppStore = create()(
persist(
(set, get) => ({
+ showSyncStatusBar: true,
+ setShowSyncStatusBar: visible => set({ showSyncStatusBar: visible }),
themeMode: 'system' as ThemeMode,
setThemeMode: mode => set({ themeMode: mode }),
hasCompletedOnboarding: false,
@@ -484,6 +488,7 @@ export const useAppStore = create()(
function persistedAppState(state: AppState) {
return {
+ showSyncStatusBar: state.showSyncStatusBar,
themeMode: state.themeMode,
hasCompletedOnboarding: state.hasCompletedOnboarding,
onboardingChecklist: state.onboardingChecklist,
diff --git a/src/stores/remoteServerHelpers.ts b/src/stores/remoteServerHelpers.ts
index 2c30cf700..671891377 100644
--- a/src/stores/remoteServerHelpers.ts
+++ b/src/stores/remoteServerHelpers.ts
@@ -31,6 +31,7 @@ import {
remoteAuthorizationHeaders,
} from '../services/remoteTransportPolicy';
import { readOffGridDesktopModelState } from '../services/offGridDesktopModels';
+import { displayModelName } from '../utils/displayModelName';
/** Timeout for model discovery fetches (non-critical, background operation) */
const DISCOVERY_FETCH_TIMEOUT_MS = 5000;
@@ -179,40 +180,8 @@ function isTextModel(model: {
return isGenerativeModel(model.id ?? model.name ?? '');
}
-const MODEL_FILE_EXT = /\.(gguf|bin|safetensors|task|litertlm|pte)$/i;
-
-/**
- * Human-readable label for a remote model. Some gateways report the model id as a
- * full file path (e.g. "/Users/admin/.offgrid/models/Qwen3.5-9B-Q4_K_M.gguf"),
- * which is unreadable in the picker. Show the basename without the extension while
- * keeping the raw id for loading.
- *
- * Only basename-strip when the id actually LOOKS like a filesystem path — an
- * absolute POSIX path ("/…"), a Windows path ("C:\…" / "C:/…"), or any string
- * that ends in a known model file extension. A namespace-style slug ("org/model",
- * "meta-llama/Llama-3.1-8B") is NOT a path: stripping its prefix would drop the
- * meaningful namespace and could collapse distinct models to the same label, so
- * it's returned unchanged.
- */
-export function displayModelName(id: string): string {
- if (id.startsWith('remote-vision:')) {
- const modelSeparator = id.indexOf(':', 'remote-vision:'.length);
- if (modelSeparator !== -1) {
- try {
- return decodeURIComponent(id.slice(modelSeparator + 1));
- } catch {
- // Keep the stable raw id when a third-party gateway sends bad encoding.
- }
- }
- }
- const looksLikePath =
- id.startsWith('/') ||
- /^[A-Za-z]:[\\/]/.test(id) ||
- id.includes('\\') ||
- MODEL_FILE_EXT.test(id);
- const base = looksLikePath ? id.split(/[\\/]/).pop() || id : id;
- return base.replace(MODEL_FILE_EXT, '');
-}
+// Lives in utils so services can use it without importing this store module (import cycle).
+export { displayModelName };
export async function testServerConnection(
server: RemoteServer,
diff --git a/src/utils/displayModelName.ts b/src/utils/displayModelName.ts
new file mode 100644
index 000000000..61868b086
--- /dev/null
+++ b/src/utils/displayModelName.ts
@@ -0,0 +1,34 @@
+const MODEL_FILE_EXT = /\.(gguf|bin|safetensors|task|litertlm|pte)$/i;
+
+/**
+ * Human-readable label for a remote model. Some gateways report the model id as a
+ * full file path (e.g. "/Users/admin/.offgrid/models/Qwen3.5-9B-Q4_K_M.gguf"),
+ * which is unreadable in the picker. Show the basename without the extension while
+ * keeping the raw id for loading.
+ *
+ * Only basename-strip when the id actually LOOKS like a filesystem path — an
+ * absolute POSIX path ("/…"), a Windows path ("C:\…" / "C:/…"), or any string
+ * that ends in a known model file extension. A namespace-style slug ("org/model",
+ * "meta-llama/Llama-3.1-8B") is NOT a path: stripping its prefix would drop the
+ * meaningful namespace and could collapse distinct models to the same label, so
+ * it's returned unchanged.
+ */
+export function displayModelName(id: string): string {
+ if (id.startsWith('remote-vision:')) {
+ const modelSeparator = id.indexOf(':', 'remote-vision:'.length);
+ if (modelSeparator !== -1) {
+ try {
+ return decodeURIComponent(id.slice(modelSeparator + 1));
+ } catch {
+ // Keep the stable raw id when a third-party gateway sends bad encoding.
+ }
+ }
+ }
+ const looksLikePath =
+ id.startsWith('/') ||
+ /^[A-Za-z]:[\\/]/.test(id) ||
+ id.includes('\\') ||
+ MODEL_FILE_EXT.test(id);
+ const base = looksLikePath ? id.split(/[\\/]/).pop() || id : id;
+ return base.replace(MODEL_FILE_EXT, '');
+}
diff --git a/src/utils/proPricing.ts b/src/utils/proPricing.ts
index fae7751db..3ceae5169 100644
--- a/src/utils/proPricing.ts
+++ b/src/utils/proPricing.ts
@@ -1,9 +1,11 @@
/**
* Off Grid AI Pro pricing copy, shared by every Pro surface so they stay in sync.
*
- * Two plans only: a yearly subscription and a one-time lifetime purchase. The price is
- * a founder rate that only ever goes up as we grow, so the numbers are hardcoded to
- * today's tier and bumped in a new release when a tier fills. The web pay page
+ * Two plans, matching getoffgridai.co/pro: a one-time lifetime purchase and a monthly
+ * subscription. Both are founder rates that only ever go up as we grow. The app states the
+ * next tier ($119 / $7.99) because the website's first tier ($69 / $4.99) is nearly full
+ * and a release outlives it. The numbers are hardcoded and bumped in a new release
+ * when a tier fills. The web pay page
* (getoffgridai.co/pay) is where checkout happens and holds the authoritative price.
*/
export interface ProPricingCopy {
@@ -24,10 +26,10 @@ export interface ProPricingCopy {
export function getPricingCopy(): ProPricingCopy {
return {
label: 'FOUNDER RATE',
- title: '$49/yr or $69 lifetime',
+ title: '$119 lifetime or $7.99/month',
subtitle: "Lock in today's rate - it only goes up, never down. One license covers up to 5 devices, laptop and phone.",
cta: 'Get Pro',
- sheetSubheadline: 'Off Grid AI Pro - $49 a year, or $69 once for life.',
- sheetFooter: 'Founder rate, locked in when you join. Yearly renews; lifetime is a one-time payment.',
+ sheetSubheadline: 'Off Grid AI Pro - $119 once for life, or $7.99 a month.',
+ sheetFooter: 'Founder rate, locked in when you join. Monthly renews; lifetime is a one-time payment.',
};
}