@sagepilot-ai/react-native-sdk 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ // src/public/SdkClient.ts
2
+ import { Platform } from "react-native";
3
+
1
4
  // src/core/errors/SagepilotChatError.ts
2
5
  var SagepilotChatError = class extends Error {
3
6
  constructor(code, message, options = {}) {
@@ -15,7 +18,7 @@ var SagepilotChatError = class extends Error {
15
18
 
16
19
  // src/core/config/constants.ts
17
20
  var SDK_NAME = "@sagepilot-ai/react-native-sdk";
18
- var SDK_VERSION = "0.2.4";
21
+ var SDK_VERSION = "0.3.0";
19
22
  var DEFAULT_HOST = "https://app.sagepilot.ai";
20
23
  var DEFAULT_WIDGET_HOST = "https://app.sagepilot.ai";
21
24
  var CUSTOMER_API_PREFIX = "/customer-api/v1";
@@ -324,6 +327,34 @@ async function resolveDeviceInfo(adapter) {
324
327
  return adapter.getDeviceInfo();
325
328
  }
326
329
 
330
+ // src/core/storage/cache.ts
331
+ function createAsyncStorageCacheStorage(asyncStorage) {
332
+ return {
333
+ getItem: (key) => asyncStorage.getItem(key),
334
+ setItem: (key, value) => asyncStorage.setItem(key, value),
335
+ removeItem: (key) => asyncStorage.removeItem(key)
336
+ };
337
+ }
338
+ function createJsonCache(storage, namespace) {
339
+ return {
340
+ async get(key) {
341
+ const value = await storage.getItem(`${namespace}:${key}`);
342
+ if (!value) return null;
343
+ try {
344
+ return JSON.parse(value);
345
+ } catch {
346
+ return null;
347
+ }
348
+ },
349
+ async set(key, value) {
350
+ await storage.setItem(`${namespace}:${key}`, JSON.stringify(value));
351
+ },
352
+ async remove(key) {
353
+ await storage.removeItem(`${namespace}:${key}`);
354
+ }
355
+ };
356
+ }
357
+
327
358
  // src/resources/channels.ts
328
359
  function bootstrapChannel(http, host, channelId) {
329
360
  return http.request(
@@ -407,6 +438,9 @@ function fetchUnreadCount(http, host, channelId, sessionId, authorizationHeader)
407
438
  }
408
439
 
409
440
  // src/public/SdkClient.ts
441
+ var PRESENTATION_CACHE_NAMESPACE = "sagepilot_rn_presentation";
442
+ var PRESENTATION_CACHE_KEY = "state";
443
+ var PRESENTATION_RESTORE_MAX_AGE_MS = 15 * 60 * 1e3;
410
444
  var DEFAULT_MOBILE_LAUNCHER_CONFIG = {
411
445
  label: "Chat",
412
446
  buttonColor: "#173c2d",
@@ -476,6 +510,10 @@ function readRecordField(input, key) {
476
510
  if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
477
511
  return value;
478
512
  }
513
+ function isFreshPresentationState(updatedAt) {
514
+ if (typeof updatedAt !== "number" || !Number.isFinite(updatedAt)) return false;
515
+ return Date.now() - updatedAt <= PRESENTATION_RESTORE_MAX_AGE_MS;
516
+ }
479
517
  function toPublicSessionState(session) {
480
518
  return {
481
519
  session_id: session.session_id,
@@ -532,6 +570,9 @@ var SagepilotReactNativeChat = class {
532
570
  const { workspaceId, channelId } = parseKey(config.key);
533
571
  this.runtimeOptions = {
534
572
  behavior: config.behavior,
573
+ cacheStorage: config.cacheStorage,
574
+ filePicker: config.filePicker,
575
+ fileStore: config.fileStore,
535
576
  presentation: config.presentation,
536
577
  theme: config.theme,
537
578
  hostedClaimsStorageKeyPrefix: config.hostedClaimsStorageKeyPrefix || DEFAULT_HOSTED_CLAIMS_STORAGE_KEY_PREFIX
@@ -562,6 +603,7 @@ var SagepilotReactNativeChat = class {
562
603
  this.channel = channel;
563
604
  this.session = session;
564
605
  this.isConfigured = true;
606
+ await this.restorePresentationFromCache();
565
607
  this.emitReady();
566
608
  this.emitState();
567
609
  if (config.behavior?.enableUnreadPolling ?? true) {
@@ -701,12 +743,14 @@ var SagepilotReactNativeChat = class {
701
743
  this.hostedChatView = { screen: "home" };
702
744
  if (this.presented) {
703
745
  this.emitState();
746
+ this.persistPresentationState();
704
747
  return true;
705
748
  }
706
749
  this.presented = true;
707
750
  this.setUnreadCount(0);
708
751
  this.presentCallbacks.forEach((callback) => callback(this.getLifecycleState()));
709
752
  this.emitState();
753
+ this.persistPresentationState();
710
754
  return true;
711
755
  }
712
756
  presentMessages() {
@@ -734,6 +778,7 @@ var SagepilotReactNativeChat = class {
734
778
  this.emitUnreadChange();
735
779
  this.dismissCallbacks.forEach((callback) => callback(this.getLifecycleState()));
736
780
  this.emitState();
781
+ this.persistPresentationState();
737
782
  return true;
738
783
  }
739
784
  hide() {
@@ -868,6 +913,10 @@ var SagepilotReactNativeChat = class {
868
913
  }
869
914
  return this.session?.conversation_id ?? void 0;
870
915
  }
916
+ /** Returns the chat id that is explicitly part of the current hosted URL. */
917
+ getHostedRouteChatId() {
918
+ return this.hostedChatView.screen === "composer" ? this.hostedChatView.chatId : void 0;
919
+ }
871
920
  getHostedIdentityMessage() {
872
921
  if (!this.legacyWidgetJwt) return null;
873
922
  return {
@@ -878,6 +927,34 @@ var SagepilotReactNativeChat = class {
878
927
  }
879
928
  };
880
929
  }
930
+ /** Captures the hosted route that should receive a picked attachment batch. */
931
+ getHostedAttachmentTarget() {
932
+ return {
933
+ chatId: this.getActiveHostedChatId(),
934
+ routeChatId: this.getHostedRouteChatId(),
935
+ hostedChatView: this.serializeHostedChatView(this.hostedChatView)
936
+ };
937
+ }
938
+ /** Restores the hosted route for a queued attachment batch before delivery. */
939
+ restoreHostedAttachmentTarget(target) {
940
+ if (Platform.OS !== "android") return false;
941
+ if (!target) return false;
942
+ const hostedChatView = this.readPersistedHostedChatView(target.hostedChatView);
943
+ if (!hostedChatView) return false;
944
+ const currentChatId = this.getActiveHostedChatId();
945
+ const targetChatId = normalizeOptionalString(target.chatId);
946
+ const routeChatId = normalizeOptionalString(target.routeChatId) ?? (hostedChatView.screen === "composer" ? normalizeOptionalString(hostedChatView.chatId) : void 0);
947
+ const nextHostedChatView = routeChatId && hostedChatView.screen === "composer" ? this.withPinnedChatId(hostedChatView, routeChatId) : hostedChatView;
948
+ if (this.presented && (!targetChatId || currentChatId === targetChatId) && this.areHostedChatViewsEqual(this.hostedChatView, nextHostedChatView)) {
949
+ return false;
950
+ }
951
+ this.hostedChatView = nextHostedChatView;
952
+ this.presented = true;
953
+ this.setUnreadCount(0);
954
+ this.emitState();
955
+ this.persistPresentationState();
956
+ return true;
957
+ }
881
958
  handleHostedBridgeMessage(message) {
882
959
  if (!message) return false;
883
960
  if (message.type === "close_widget") {
@@ -945,6 +1022,7 @@ var SagepilotReactNativeChat = class {
945
1022
  }
946
1023
  destroy() {
947
1024
  this.stopUnreadPolling();
1025
+ this.persistPresentationState({ cleanShutdown: true, presented: false });
948
1026
  this.resetRuntimeState();
949
1027
  this.emitState();
950
1028
  this.identifyCallbacks.clear();
@@ -1100,8 +1178,86 @@ var SagepilotReactNativeChat = class {
1100
1178
  this.presentCallbacks.forEach((callback) => callback(this.getLifecycleState()));
1101
1179
  }
1102
1180
  this.emitState();
1181
+ this.persistPresentationState();
1103
1182
  return true;
1104
1183
  }
1184
+ /** Returns the dedicated cache used for hosted-chat presentation recovery. */
1185
+ getPresentationCache() {
1186
+ const cacheStorage = this.runtimeOptions?.cacheStorage;
1187
+ if (!cacheStorage) return null;
1188
+ return createJsonCache(cacheStorage, PRESENTATION_CACHE_NAMESPACE);
1189
+ }
1190
+ /** Removes callback-only fields so the hosted route can be safely serialized. */
1191
+ serializeHostedChatView(view) {
1192
+ if (view.screen !== "composer") return view;
1193
+ return {
1194
+ screen: "composer",
1195
+ message: view.message,
1196
+ mode: view.mode,
1197
+ chatId: view.chatId,
1198
+ metadata: view.metadata
1199
+ };
1200
+ }
1201
+ /** Validates a persisted hosted view before using it to rebuild a widget URL. */
1202
+ readPersistedHostedChatView(view) {
1203
+ if (!view || typeof view !== "object") return null;
1204
+ const record = view;
1205
+ if (record.screen === "home") return { screen: "home" };
1206
+ if (record.screen === "messages") return { screen: "messages" };
1207
+ if (record.screen !== "composer") return null;
1208
+ const mode = record.mode === "new" ? "new" : "auto";
1209
+ return {
1210
+ screen: "composer",
1211
+ message: typeof record.message === "string" ? record.message : void 0,
1212
+ mode,
1213
+ chatId: typeof record.chatId === "string" && record.chatId ? record.chatId : void 0,
1214
+ metadata: readRecordField(record, "metadata")
1215
+ };
1216
+ }
1217
+ /** Forces a persisted hosted route to target the exact chat that owns a pending attachment. */
1218
+ withPinnedChatId(view, chatId) {
1219
+ if (view.screen === "composer") {
1220
+ return {
1221
+ ...view,
1222
+ chatId
1223
+ };
1224
+ }
1225
+ return {
1226
+ screen: "composer",
1227
+ mode: "auto",
1228
+ chatId
1229
+ };
1230
+ }
1231
+ /** Compares hosted route state without relying on object identity. */
1232
+ areHostedChatViewsEqual(first, second) {
1233
+ return JSON.stringify(this.serializeHostedChatView(first)) === JSON.stringify(this.serializeHostedChatView(second));
1234
+ }
1235
+ /** Persists presentation state without blocking customer-facing SDK methods. */
1236
+ persistPresentationState(options) {
1237
+ const cache = this.getPresentationCache();
1238
+ if (!cache) return;
1239
+ const state = {
1240
+ presented: options?.presented ?? this.presented,
1241
+ hostedChatView: this.serializeHostedChatView(this.hostedChatView),
1242
+ updatedAt: Date.now(),
1243
+ cleanShutdown: options?.cleanShutdown
1244
+ };
1245
+ void cache.set(PRESENTATION_CACHE_KEY, state).catch(() => void 0);
1246
+ }
1247
+ /** Restores Android presentation state after configure without resetting the hosted route to Home. */
1248
+ async restorePresentationFromCache() {
1249
+ const cache = this.getPresentationCache();
1250
+ if (!cache || Platform.OS !== "android") return;
1251
+ const persisted = await cache.get(PRESENTATION_CACHE_KEY).catch(() => null);
1252
+ if (persisted?.cleanShutdown) return;
1253
+ if (!isFreshPresentationState(persisted?.updatedAt)) return;
1254
+ if (!persisted?.presented) return;
1255
+ const hostedChatView = this.readPersistedHostedChatView(persisted.hostedChatView);
1256
+ if (!hostedChatView) return;
1257
+ this.hostedChatView = hostedChatView;
1258
+ this.presented = true;
1259
+ this.setUnreadCount(0);
1260
+ }
1105
1261
  };
1106
1262
  var internalSagepilotChat = new SagepilotReactNativeChat();
1107
1263
  var SagepilotChat = {
@@ -1138,22 +1294,337 @@ var SagepilotChat = {
1138
1294
  destroy: () => internalSagepilotChat.destroy()
1139
1295
  };
1140
1296
 
1141
- // src/core/storage/cache.ts
1142
- function createAsyncStorageCacheStorage(asyncStorage) {
1297
+ // src/core/storage/fileStore.ts
1298
+ var DEFAULT_DIRECTORY_NAME = "sagepilot-attachments";
1299
+ function sanitizeKey(key) {
1300
+ return key.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/\.{2,}/g, "_");
1301
+ }
1302
+ function createSagepilotFileStore(blobUtil, options = {}) {
1303
+ const directory = `${blobUtil.fs.dirs.DocumentDir}/${options.directoryName ?? DEFAULT_DIRECTORY_NAME}`;
1304
+ const pathFor = (key) => `${directory}/${sanitizeKey(key)}`;
1305
+ let dirReady = null;
1306
+ function ensureDirectory() {
1307
+ if (!dirReady) {
1308
+ dirReady = (async () => {
1309
+ if (!await blobUtil.fs.exists(directory)) {
1310
+ await blobUtil.fs.mkdir(directory);
1311
+ }
1312
+ })().catch((error) => {
1313
+ dirReady = null;
1314
+ throw error;
1315
+ });
1316
+ }
1317
+ return dirReady;
1318
+ }
1143
1319
  return {
1144
- getItem: (key) => asyncStorage.getItem(key),
1145
- setItem: (key, value) => asyncStorage.setItem(key, value),
1146
- removeItem: (key) => asyncStorage.removeItem(key)
1320
+ async write(key, base64) {
1321
+ await ensureDirectory();
1322
+ await blobUtil.fs.writeFile(pathFor(key), base64, "base64");
1323
+ },
1324
+ async read(key) {
1325
+ const content = await blobUtil.fs.readFile(pathFor(key), "base64");
1326
+ if (typeof content !== "string" || content.length === 0) {
1327
+ throw new Error("Persisted attachment is empty or unreadable.");
1328
+ }
1329
+ return content;
1330
+ },
1331
+ async remove(key) {
1332
+ try {
1333
+ if (await blobUtil.fs.exists(pathFor(key))) {
1334
+ await blobUtil.fs.unlink(pathFor(key));
1335
+ }
1336
+ } catch {
1337
+ }
1338
+ },
1339
+ async prune(keepKeys) {
1340
+ try {
1341
+ if (!await blobUtil.fs.exists(directory)) return;
1342
+ const keep = new Set(keepKeys.map(sanitizeKey));
1343
+ const entries = await blobUtil.fs.ls(directory);
1344
+ await Promise.all(
1345
+ entries.filter((entry) => !keep.has(entry)).map((entry) => blobUtil.fs.unlink(`${directory}/${entry}`).catch(() => void 0))
1346
+ );
1347
+ } catch {
1348
+ }
1349
+ }
1350
+ };
1351
+ }
1352
+
1353
+ // src/core/native/filePicker.ts
1354
+ import { Alert, Linking, PermissionsAndroid, Platform as Platform2 } from "react-native";
1355
+
1356
+ // src/core/native/filePickerShared.ts
1357
+ var SagepilotFilePickerError = class extends Error {
1358
+ /** Creates a typed picker error that can be surfaced over the widget bridge. */
1359
+ constructor(code, message) {
1360
+ super(message);
1361
+ this.name = "SagepilotFilePickerError";
1362
+ this.code = code;
1363
+ }
1364
+ };
1365
+ var SAGEPILOT_DEFAULT_IMAGE_MAX_DIMENSION = 1920;
1366
+ var SAGEPILOT_DEFAULT_IMAGE_QUALITY = 0.8;
1367
+ var SAGEPILOT_DEFAULT_IMAGE_MAX_FILE_SIZE_BYTES = 15 * 1024 * 1024;
1368
+
1369
+ // src/core/native/filePicker.ts
1370
+ var DEBUG_PREFIX = "[SagepilotSDK][FilePicker]";
1371
+ var DEFAULT_IMAGE_MAX_DIMENSION = SAGEPILOT_DEFAULT_IMAGE_MAX_DIMENSION;
1372
+ var DEFAULT_IMAGE_QUALITY = SAGEPILOT_DEFAULT_IMAGE_QUALITY;
1373
+ var DEFAULT_IMAGE_MIME_TYPE = "image/jpeg";
1374
+ var DEFAULT_DOCUMENT_MIME_TYPE = "application/octet-stream";
1375
+ var DEFAULT_IMAGE_SELECTION_LIMIT = 5;
1376
+ var DEFAULT_DOCUMENT_MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024;
1377
+ var DEFAULT_IMAGE_MAX_FILE_SIZE_BYTES = SAGEPILOT_DEFAULT_IMAGE_MAX_FILE_SIZE_BYTES;
1378
+ function bytesToMb(bytes) {
1379
+ return Math.round(bytes / (1024 * 1024) * 10) / 10;
1380
+ }
1381
+ function estimateBase64ByteSize(dataBase64) {
1382
+ return Math.floor(dataBase64.length * 3 / 4);
1383
+ }
1384
+ function stripFileScheme(uri) {
1385
+ return uri.startsWith("file://") ? uri.replace("file://", "") : uri;
1386
+ }
1387
+ function debugFilePicker(message, details) {
1388
+ console.log(`${DEBUG_PREFIX} ${message}`, details ?? "");
1389
+ }
1390
+ async function promptOpenSagepilotCameraSettings() {
1391
+ if (Platform2.OS !== "android") return;
1392
+ await new Promise((resolve) => {
1393
+ Alert.alert(
1394
+ "Camera access is off",
1395
+ "Enable camera access for this app in Settings to take a photo.",
1396
+ [
1397
+ {
1398
+ text: "Not now",
1399
+ style: "cancel",
1400
+ onPress: resolve
1401
+ },
1402
+ {
1403
+ text: "Open Settings",
1404
+ onPress: () => {
1405
+ Linking.openSettings().finally(resolve);
1406
+ }
1407
+ }
1408
+ ]
1409
+ );
1410
+ });
1411
+ }
1412
+ async function ensureSagepilotAndroidCameraPermission() {
1413
+ if (Platform2.OS !== "android") return;
1414
+ let result;
1415
+ try {
1416
+ debugFilePicker("requesting Android camera permission");
1417
+ result = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.CAMERA);
1418
+ } catch {
1419
+ debugFilePicker("camera permission request threw; continuing to native launch");
1420
+ return;
1421
+ }
1422
+ debugFilePicker("Android camera permission result", { result });
1423
+ if (result === PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN) {
1424
+ await promptOpenSagepilotCameraSettings();
1425
+ throw new SagepilotFilePickerError(
1426
+ "permission_denied",
1427
+ "Camera access is turned off. Enable it for this app in Settings to take a photo."
1428
+ );
1429
+ }
1430
+ if (result === PermissionsAndroid.RESULTS.DENIED) {
1431
+ throw new SagepilotFilePickerError(
1432
+ "permission_denied",
1433
+ "Camera permission is required to take a photo."
1434
+ );
1435
+ }
1436
+ }
1437
+ function canUseCameraSource(imagePicker) {
1438
+ return Boolean(imagePicker);
1439
+ }
1440
+ function mapImagePickerErrorCode(errorCode) {
1441
+ switch (errorCode) {
1442
+ case "permission":
1443
+ return "permission_denied";
1444
+ case "camera_unavailable":
1445
+ return "camera_unavailable";
1446
+ default:
1447
+ return "unknown";
1448
+ }
1449
+ }
1450
+ function imageAssetsToPickedFiles(result, maxFileSizeBytes) {
1451
+ if (result.didCancel) return [];
1452
+ if (result.errorCode) {
1453
+ throw new SagepilotFilePickerError(
1454
+ mapImagePickerErrorCode(result.errorCode),
1455
+ result.errorMessage || `Could not capture the photo (${result.errorCode}).`
1456
+ );
1457
+ }
1458
+ const assets = result.assets ?? [];
1459
+ const usable = assets.filter((asset) => typeof asset.base64 === "string" && asset.base64.length > 0);
1460
+ if (assets.length > usable.length) {
1461
+ throw new SagepilotFilePickerError(
1462
+ "encode_failed",
1463
+ assets.length === 1 ? "The photo could not be processed. Please try again." : "Some photos could not be processed. Please try selecting them again."
1464
+ );
1465
+ }
1466
+ return usable.map((asset, index) => {
1467
+ const dataBase64 = asset.base64;
1468
+ const size = asset.fileSize ?? estimateBase64ByteSize(dataBase64);
1469
+ if (maxFileSizeBytes > 0 && size > maxFileSizeBytes) {
1470
+ throw new SagepilotFilePickerError(
1471
+ "file_too_large",
1472
+ `Image is too large (max ${bytesToMb(maxFileSizeBytes)}MB).`
1473
+ );
1474
+ }
1475
+ return {
1476
+ file_name: asset.fileName || `photo-${Date.now()}-${index + 1}.jpg`,
1477
+ mime_type: asset.type || DEFAULT_IMAGE_MIME_TYPE,
1478
+ size,
1479
+ data_base64: dataBase64
1480
+ };
1481
+ });
1482
+ }
1483
+ async function readUriAsBase64(uri, fileReader) {
1484
+ if (fileReader) {
1485
+ const content = await fileReader.fs.readFile(stripFileScheme(uri), "base64");
1486
+ if (typeof content !== "string") {
1487
+ throw new Error("File reader returned unexpected content.");
1488
+ }
1489
+ return content;
1490
+ }
1491
+ const response = await fetch(uri);
1492
+ const blob = await response.blob();
1493
+ return new Promise((resolve, reject) => {
1494
+ const reader = new FileReader();
1495
+ reader.onload = () => {
1496
+ const dataUrl = typeof reader.result === "string" ? reader.result : "";
1497
+ const [, payload = ""] = dataUrl.split(",");
1498
+ if (!payload) {
1499
+ reject(new Error("Could not read the selected file."));
1500
+ return;
1501
+ }
1502
+ resolve(payload);
1503
+ };
1504
+ reader.onerror = () => reject(new Error("Could not read the selected file."));
1505
+ reader.readAsDataURL(blob);
1506
+ });
1507
+ }
1508
+ function createSagepilotFilePicker(options) {
1509
+ const { imagePicker, documentsPicker, fileReader } = options;
1510
+ const imageMaxDimension = options.imageMaxDimension ?? DEFAULT_IMAGE_MAX_DIMENSION;
1511
+ const imageQuality = options.imageQuality ?? DEFAULT_IMAGE_QUALITY;
1512
+ const imageSelectionLimit = options.imageSelectionLimit ?? DEFAULT_IMAGE_SELECTION_LIMIT;
1513
+ const documentMaxFileSizeBytes = options.documentMaxFileSizeBytes ?? DEFAULT_DOCUMENT_MAX_FILE_SIZE_BYTES;
1514
+ const imageMaxFileSizeBytes = options.imageMaxFileSizeBytes ?? DEFAULT_IMAGE_MAX_FILE_SIZE_BYTES;
1515
+ const sources = [];
1516
+ if (canUseCameraSource(imagePicker)) sources.push("camera");
1517
+ if (imagePicker) sources.push("library");
1518
+ if (documentsPicker) sources.push("documents");
1519
+ debugFilePicker("adapter created", {
1520
+ platform: Platform2.OS,
1521
+ hasImagePicker: Boolean(imagePicker),
1522
+ hasDocumentsPicker: Boolean(documentsPicker),
1523
+ hasFileReader: Boolean(fileReader),
1524
+ sources
1525
+ });
1526
+ if (sources.length === 0) return void 0;
1527
+ const imageOptions = {
1528
+ mediaType: "photo",
1529
+ includeBase64: true,
1530
+ maxWidth: imageMaxDimension,
1531
+ maxHeight: imageMaxDimension,
1532
+ quality: imageQuality,
1533
+ saveToPhotos: false
1534
+ };
1535
+ async function pickFromCamera() {
1536
+ debugFilePicker("camera pick requested", {
1537
+ platform: Platform2.OS,
1538
+ imageMaxDimension,
1539
+ imageQuality,
1540
+ imageMaxFileSizeBytes
1541
+ });
1542
+ await ensureSagepilotAndroidCameraPermission();
1543
+ if (!imagePicker) {
1544
+ throw new SagepilotFilePickerError("camera_unavailable", "The camera picker is not available in this build.");
1545
+ }
1546
+ debugFilePicker("launching image-picker camera", { platform: Platform2.OS });
1547
+ return imageAssetsToPickedFiles(await imagePicker.launchCamera(imageOptions), imageMaxFileSizeBytes);
1548
+ }
1549
+ async function pickFromLibrary(multiple) {
1550
+ if (!imagePicker) return [];
1551
+ debugFilePicker("library pick requested", { multiple, imageSelectionLimit });
1552
+ return imageAssetsToPickedFiles(await imagePicker.launchImageLibrary({
1553
+ ...imageOptions,
1554
+ // Bounded multi-select: 0 (unlimited) lets a huge batch OOM the bridge.
1555
+ selectionLimit: multiple ? Math.max(0, imageSelectionLimit) : 1
1556
+ }), imageMaxFileSizeBytes);
1557
+ }
1558
+ async function pickDocuments(multiple) {
1559
+ if (!documentsPicker) return [];
1560
+ debugFilePicker("document pick requested", { multiple, documentMaxFileSizeBytes });
1561
+ let picked;
1562
+ try {
1563
+ picked = await documentsPicker.pick({ allowMultiSelection: multiple });
1564
+ } catch (error) {
1565
+ if (error && typeof error === "object" && error.code === "OPERATION_CANCELED") {
1566
+ return [];
1567
+ }
1568
+ throw error;
1569
+ }
1570
+ const files = [];
1571
+ for (const file of picked) {
1572
+ if (documentMaxFileSizeBytes > 0 && typeof file.size === "number" && file.size > documentMaxFileSizeBytes) {
1573
+ throw new SagepilotFilePickerError(
1574
+ "file_too_large",
1575
+ `"${file.name || "File"}" is too large (max ${bytesToMb(documentMaxFileSizeBytes)}MB).`
1576
+ );
1577
+ }
1578
+ let readableUri = file.uri;
1579
+ if (documentsPicker.keepLocalCopy) {
1580
+ const fileName = file.name || `file-${Date.now()}`;
1581
+ const [copy] = await documentsPicker.keepLocalCopy({
1582
+ files: [{ uri: file.uri, fileName }],
1583
+ destination: "cachesDirectory"
1584
+ });
1585
+ if (copy?.status === "success" && copy.localUri) {
1586
+ readableUri = copy.localUri;
1587
+ }
1588
+ }
1589
+ let dataBase64;
1590
+ try {
1591
+ dataBase64 = await readUriAsBase64(readableUri, fileReader);
1592
+ } catch (error) {
1593
+ throw new SagepilotFilePickerError(
1594
+ "read_failed",
1595
+ error instanceof Error && error.message ? error.message : "Could not read the selected file."
1596
+ );
1597
+ }
1598
+ files.push({
1599
+ file_name: file.name || `file-${Date.now()}`,
1600
+ mime_type: file.type || DEFAULT_DOCUMENT_MIME_TYPE,
1601
+ size: file.size ?? estimateBase64ByteSize(dataBase64),
1602
+ data_base64: dataBase64
1603
+ });
1604
+ }
1605
+ return files;
1606
+ }
1607
+ return {
1608
+ sources,
1609
+ async pickFiles(request) {
1610
+ debugFilePicker("pickFiles request", request);
1611
+ if (request.source === "camera") return pickFromCamera();
1612
+ if (request.source === "library") return pickFromLibrary(request.multiple);
1613
+ return pickDocuments(request.multiple);
1614
+ }
1147
1615
  };
1148
1616
  }
1149
1617
 
1150
1618
  // src/ui/SagepilotChatProvider.ts
1151
1619
  import { createElement, useCallback, useEffect, useRef, useState } from "react";
1620
+ import { Camera, FileText, Images, X } from "lucide-react-native";
1152
1621
  import {
1153
1622
  ActivityIndicator,
1623
+ AppState,
1154
1624
  KeyboardAvoidingView,
1625
+ Linking as Linking2,
1155
1626
  Modal,
1156
- Platform,
1627
+ Platform as Platform3,
1157
1628
  Pressable,
1158
1629
  SafeAreaView,
1159
1630
  StyleSheet,
@@ -1163,6 +1634,40 @@ import {
1163
1634
  import { WebView } from "react-native-webview";
1164
1635
 
1165
1636
  // src/core/webview/mobileBridge.ts
1637
+ var FILE_PICKER_PROTOCOL_VERSION = 2;
1638
+ function buildBridgeCapabilitiesPayload(capabilities) {
1639
+ return { ...capabilities, filePickerProtocol: FILE_PICKER_PROTOCOL_VERSION };
1640
+ }
1641
+ function buildBridgeCapabilitiesScript(capabilities) {
1642
+ const payload = buildBridgeCapabilitiesPayload(capabilities);
1643
+ return [
1644
+ "(function(){",
1645
+ "try {",
1646
+ "if (window.SagepilotMobileBridge) {",
1647
+ `window.SagepilotMobileBridge.capabilities = Object.assign({}, window.SagepilotMobileBridge.capabilities, ${JSON.stringify(payload)});`,
1648
+ "}",
1649
+ "} catch (_) {}",
1650
+ "true;",
1651
+ "})();"
1652
+ ].join("\n");
1653
+ }
1654
+ function buildLivenessPingScript(nonce, staleMs) {
1655
+ return [
1656
+ "(function(){",
1657
+ "try {",
1658
+ "var hb = window.__sagepilotWidgetHeartbeat;",
1659
+ "var supports = typeof hb === 'number';",
1660
+ `var alive = !supports ? true : (Date.now() - hb < ${Math.max(0, Math.floor(staleMs))});`,
1661
+ "var send = function(){",
1662
+ " var msg = JSON.stringify({ type: 'sagepilot:pong', nonce: " + JSON.stringify(nonce) + ", alive: alive, supportsHeartbeat: supports });",
1663
+ " if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) { window.ReactNativeWebView.postMessage(msg); }",
1664
+ "};",
1665
+ "send();",
1666
+ "} catch (_) {}",
1667
+ "true;",
1668
+ "})();"
1669
+ ].join("\n");
1670
+ }
1166
1671
  function isHostedBridgeMessage(value) {
1167
1672
  if (!value || typeof value !== "object") return false;
1168
1673
  const message = value;
@@ -1183,9 +1688,23 @@ function parseHostedBridgeMessage(rawData) {
1183
1688
  return null;
1184
1689
  }
1185
1690
  }
1186
- var mobileWebViewBridgeScript = `
1691
+ function buildMobileWebViewBridgeScript(capabilities) {
1692
+ const payload = buildBridgeCapabilitiesPayload(capabilities);
1693
+ return `
1187
1694
  (function () {
1188
- if (window.__sagepilotRnBridgeInstalled) return true;
1695
+ var bridgeCapabilities = ${JSON.stringify(payload)};
1696
+ var applyBridgeCapabilities = function () {
1697
+ try {
1698
+ if (window.SagepilotMobileBridge) {
1699
+ window.SagepilotMobileBridge.capabilities = Object.assign({}, window.SagepilotMobileBridge.capabilities, bridgeCapabilities);
1700
+ }
1701
+ } catch (error) {}
1702
+ };
1703
+
1704
+ if (window.__sagepilotRnBridgeInstalled) {
1705
+ applyBridgeCapabilities();
1706
+ return true;
1707
+ }
1189
1708
  window.__sagepilotRnBridgeInstalled = true;
1190
1709
 
1191
1710
  var ensureViewport = function () {
@@ -1226,9 +1745,11 @@ var mobileWebViewBridgeScript = `
1226
1745
  };
1227
1746
 
1228
1747
  window.SagepilotMobileBridge = {
1748
+ capabilities: bridgeCapabilities,
1229
1749
  postMessage: sendToReactNative,
1230
1750
  ready: function () { sendToReactNative({ type: "sagepilot:ready" }); }
1231
1751
  };
1752
+ applyBridgeCapabilities();
1232
1753
 
1233
1754
  document.addEventListener("click", function (event) {
1234
1755
  try {
@@ -1269,6 +1790,7 @@ var mobileWebViewBridgeScript = `
1269
1790
  return true;
1270
1791
  })();
1271
1792
  `;
1793
+ }
1272
1794
 
1273
1795
  // src/specs/SagepilotInsetsViewNativeComponent.ts
1274
1796
  import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent";
@@ -1285,12 +1807,133 @@ function readPresentationState() {
1285
1807
  presentation: internalSagepilotChat.getConfig()?.presentation
1286
1808
  };
1287
1809
  }
1810
+ function getBridgeCapabilities() {
1811
+ return {
1812
+ nativeFilePicker: Boolean(internalSagepilotChat.getConfig()?.filePicker)
1813
+ };
1814
+ }
1288
1815
  function getInjectedWebViewScript() {
1816
+ const capabilities = getBridgeCapabilities();
1289
1817
  return [
1290
1818
  internalSagepilotChat.getHostedAuthScript(),
1291
- mobileWebViewBridgeScript
1819
+ buildMobileWebViewBridgeScript(capabilities),
1820
+ buildBridgeCapabilitiesScript(capabilities)
1292
1821
  ].filter(Boolean).join("\n");
1293
1822
  }
1823
+ var FILE_PICKER_SOURCE_LABELS = {
1824
+ camera: "Take photo",
1825
+ library: "Choose from gallery",
1826
+ documents: "Browse files"
1827
+ };
1828
+ var FILE_PICKER_SOURCE_DESCRIPTIONS = {
1829
+ camera: "Use the in-app camera",
1830
+ library: "Photos and videos on this device",
1831
+ documents: "PDFs, documents, and Drive files"
1832
+ };
1833
+ var FILE_PICKER_SOURCE_ICONS = {
1834
+ camera: Camera,
1835
+ library: Images,
1836
+ documents: FileText
1837
+ };
1838
+ var FILE_PICKER_SOURCE_ICON_COLORS = {
1839
+ camera: "#0284c7",
1840
+ library: "#16a34a",
1841
+ documents: "#7c3aed"
1842
+ };
1843
+ var DEBUG_PREFIX2 = "[SagepilotSDK][Provider]";
1844
+ var DELIVERY_RETRY_MS = 1500;
1845
+ var DELIVERY_LEGACY_FALLBACK_ATTEMPTS = 2;
1846
+ var DELIVERY_MAX_ATTEMPTS = 8;
1847
+ var PERSIST_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
1848
+ var PENDING_FILES_CACHE_NAMESPACE = "sagepilot_rn_picker";
1849
+ var PENDING_FILES_CACHE_KEY = "pending_batch";
1850
+ var LIVENESS_HEARTBEAT_STALE_MS = 4e3;
1851
+ var LIVENESS_PONG_TIMEOUT_MS = 1500;
1852
+ var LIVENESS_MAX_PING_ATTEMPTS = 2;
1853
+ var LIVENESS_PICKER_GRACE_MS = 8e3;
1854
+ var LIVENESS_MAX_REMOUNTS = 2;
1855
+ var batchSequence = 0;
1856
+ function nextBatchId() {
1857
+ batchSequence += 1;
1858
+ const entropy = Math.random().toString(36).slice(2, 8);
1859
+ return `b_${Date.now().toString(36)}_${batchSequence}_${entropy}`;
1860
+ }
1861
+ function totalBase64Bytes(files) {
1862
+ return files.reduce((sum, file) => sum + (file.data_base64?.length ?? 0), 0);
1863
+ }
1864
+ function storageKeyFor(batchId, index) {
1865
+ return `${batchId}_${index}.bin`;
1866
+ }
1867
+ function renderFilePickerSourceIcon(source) {
1868
+ const Icon = FILE_PICKER_SOURCE_ICONS[source];
1869
+ return createElement(
1870
+ View,
1871
+ { style: getFilePickerSourceIconStyle(source) },
1872
+ createElement(Icon, {
1873
+ color: FILE_PICKER_SOURCE_ICON_COLORS[source],
1874
+ size: 23,
1875
+ strokeWidth: 2.35
1876
+ })
1877
+ );
1878
+ }
1879
+ function getFilePickerSourceIconStyle(source) {
1880
+ if (source === "camera") return [styles.sheetOptionIcon, styles.cameraOptionIcon];
1881
+ if (source === "library") return [styles.sheetOptionIcon, styles.libraryOptionIcon];
1882
+ return [styles.sheetOptionIcon, styles.documentsOptionIcon];
1883
+ }
1884
+ function getSheetOptionStyle(pressed) {
1885
+ return [styles.sheetOption, pressed && styles.sheetOptionPressed];
1886
+ }
1887
+ function getSheetCancelStyle(pressed) {
1888
+ return [styles.sheetCancelButton, pressed && styles.sheetCancelButtonPressed];
1889
+ }
1890
+ function buildDispatchScript(messageLiteral) {
1891
+ return [
1892
+ "(function(){",
1893
+ "try {",
1894
+ `var message = ${messageLiteral};`,
1895
+ "window.dispatchEvent(new MessageEvent('message', { data: message, origin: window.location.origin, source: window.parent || window }));",
1896
+ "} catch (_) {}",
1897
+ "true;",
1898
+ "})();"
1899
+ ].join("\n");
1900
+ }
1901
+ function buildFilesPickedScript(batch) {
1902
+ return buildDispatchScript(JSON.stringify({
1903
+ type: "sagepilot:files_picked",
1904
+ batch_id: batch.batchId,
1905
+ chat_id: batch.target?.chatId,
1906
+ conversation_id: batch.target?.chatId,
1907
+ files: batch.files
1908
+ }));
1909
+ }
1910
+ function buildFilePickerErrorScript(message, code) {
1911
+ return buildDispatchScript(
1912
+ `{ type: "sagepilot:file_picker_error", message: ${JSON.stringify(message)}${code ? `, code: ${JSON.stringify(code)}` : ""} }`
1913
+ );
1914
+ }
1915
+ function buildFilePickerCancelledScript() {
1916
+ return buildDispatchScript(`{ type: "sagepilot:file_picker_cancelled" }`);
1917
+ }
1918
+ function readFilePickerError(error) {
1919
+ if (error && typeof error === "object") {
1920
+ const candidate = error;
1921
+ const message = typeof candidate.message === "string" && candidate.message ? candidate.message : "Could not attach the selected file.";
1922
+ const code = typeof candidate.code === "string" ? candidate.code : void 0;
1923
+ return { message, code };
1924
+ }
1925
+ return { message: "Could not attach the selected file." };
1926
+ }
1927
+ function debugProvider(message, details) {
1928
+ console.log(`${DEBUG_PREFIX2} ${message}`, details ?? "");
1929
+ }
1930
+ function describeAttachmentTarget(target) {
1931
+ return {
1932
+ targetChatId: target?.chatId,
1933
+ targetRouteChatId: target?.routeChatId,
1934
+ targetScreen: target?.hostedChatView.screen
1935
+ };
1936
+ }
1294
1937
  function getHostedIdentityDispatchScript() {
1295
1938
  const message = internalSagepilotChat.getHostedIdentityMessage();
1296
1939
  if (!message) return "";
@@ -1313,10 +1956,13 @@ function readUrlOrigin(url) {
1313
1956
  return null;
1314
1957
  }
1315
1958
  }
1959
+ function isInternalWebViewScheme(url) {
1960
+ return url.startsWith("about:") || url.startsWith("data:") || url.startsWith("blob:") || url.startsWith("javascript:") || url.startsWith("file:");
1961
+ }
1316
1962
  var hostedChatWebViewProps = {
1317
1963
  allowFileAccess: true,
1318
1964
  allowFileAccessFromFileURLs: true,
1319
- ...Platform.OS === "ios" ? {
1965
+ ...Platform3.OS === "ios" ? {
1320
1966
  automaticallyAdjustContentInsets: false,
1321
1967
  contentInsetAdjustmentBehavior: "never",
1322
1968
  hideKeyboardAccessoryView: true
@@ -1331,7 +1977,7 @@ var hostedChatWebViewProps = {
1331
1977
  sharedCookiesEnabled: true,
1332
1978
  thirdPartyCookiesEnabled: true
1333
1979
  };
1334
- var AndroidInsetsView = Platform.OS === "android" ? SagepilotInsetsViewNativeComponent_default : View;
1980
+ var AndroidInsetsView = Platform3.OS === "android" ? SagepilotInsetsViewNativeComponent_default : View;
1335
1981
  var emptyAndroidInsets = { top: 0, bottom: 0 };
1336
1982
  function SagepilotChatProvider({
1337
1983
  children,
@@ -1340,13 +1986,389 @@ function SagepilotChatProvider({
1340
1986
  }) {
1341
1987
  const [state, setState] = useState(readPresentationState);
1342
1988
  const [androidModalInsets, setAndroidModalInsets] = useState(emptyAndroidInsets);
1989
+ const [nativeWebViewKey, setNativeWebViewKey] = useState(0);
1990
+ const [preloadWebViewKey, setPreloadWebViewKey] = useState(0);
1343
1991
  const webFrameRef = useRef(null);
1344
1992
  const nativeWebViewRef = useRef(null);
1993
+ const pendingBatchesRef = useRef([]);
1994
+ const widgetReadyRef = useRef(false);
1995
+ const deliveryTimerRef = useRef(null);
1996
+ const deliveryAttemptsRef = useRef(0);
1997
+ const pendingPingRef = useRef(null);
1998
+ const pingTimeoutRef = useRef(null);
1999
+ const livenessResumeTimerRef = useRef(null);
2000
+ const livenessPausedUntilRef = useRef(0);
2001
+ const pickerInFlightRef = useRef(false);
2002
+ const livenessRemountCountRef = useRef(0);
2003
+ const appStateRef = useRef(AppState.currentState);
2004
+ const didReconcileRef = useRef(false);
2005
+ const [androidRepaintTick, setAndroidRepaintTick] = useState(0);
2006
+ const [sourceChooser, setSourceChooser] = useState(null);
2007
+ const getPendingCache = useCallback(() => {
2008
+ const cacheStorage = internalSagepilotChat.getConfig()?.cacheStorage;
2009
+ if (!cacheStorage) return null;
2010
+ return createJsonCache(cacheStorage, PENDING_FILES_CACHE_NAMESPACE);
2011
+ }, []);
2012
+ const writeManifest = useCallback(() => {
2013
+ const cache = getPendingCache();
2014
+ if (!cache) return;
2015
+ const queue = pendingBatchesRef.current;
2016
+ if (queue.length === 0) {
2017
+ void cache.remove(PENDING_FILES_CACHE_KEY).catch(() => void 0);
2018
+ return;
2019
+ }
2020
+ const hasFileStore = Boolean(internalSagepilotChat.getConfig()?.fileStore);
2021
+ if (hasFileStore) {
2022
+ const manifest = queue.filter((batch) => batch.storageKeys && batch.storageKeys.length === batch.files.length).map((batch) => ({
2023
+ batchId: batch.batchId,
2024
+ target: batch.target,
2025
+ files: batch.files.map((file, index) => ({
2026
+ file_name: file.file_name,
2027
+ mime_type: file.mime_type,
2028
+ size: file.size,
2029
+ storageKey: batch.storageKeys[index]
2030
+ }))
2031
+ }));
2032
+ if (manifest.length === 0) {
2033
+ void cache.remove(PENDING_FILES_CACHE_KEY).catch(() => void 0);
2034
+ } else {
2035
+ void cache.set(PENDING_FILES_CACHE_KEY, manifest).catch(() => void 0);
2036
+ }
2037
+ return;
2038
+ }
2039
+ const totalBytes = queue.reduce((sum, batch) => sum + totalBase64Bytes(batch.files), 0);
2040
+ if (totalBytes <= PERSIST_MAX_TOTAL_BYTES) {
2041
+ const manifest = queue.map((batch) => ({
2042
+ batchId: batch.batchId,
2043
+ target: batch.target,
2044
+ files: batch.files.map((file) => ({
2045
+ file_name: file.file_name,
2046
+ mime_type: file.mime_type,
2047
+ size: file.size,
2048
+ data_base64: file.data_base64
2049
+ }))
2050
+ }));
2051
+ void cache.set(PENDING_FILES_CACHE_KEY, manifest).catch(() => void 0);
2052
+ } else {
2053
+ void cache.remove(PENDING_FILES_CACHE_KEY).catch(() => void 0);
2054
+ }
2055
+ }, [getPendingCache]);
2056
+ const writeBatchBytes = useCallback(async (batch) => {
2057
+ const fileStore = internalSagepilotChat.getConfig()?.fileStore;
2058
+ if (!fileStore) return;
2059
+ const entries = batch.files.map((file, index) => ({
2060
+ key: storageKeyFor(batch.batchId, index),
2061
+ base64: file.data_base64
2062
+ }));
2063
+ try {
2064
+ await Promise.all(entries.map((entry) => fileStore.write(entry.key, entry.base64)));
2065
+ } catch {
2066
+ return;
2067
+ }
2068
+ if (!pendingBatchesRef.current.some((queued) => queued.batchId === batch.batchId)) {
2069
+ void Promise.all(entries.map((entry) => fileStore.remove(entry.key).catch(() => void 0)));
2070
+ }
2071
+ }, []);
2072
+ const discardBatchFiles = useCallback((batch) => {
2073
+ if (!batch?.storageKeys) return;
2074
+ const fileStore = internalSagepilotChat.getConfig()?.fileStore;
2075
+ if (!fileStore) return;
2076
+ void Promise.all(batch.storageKeys.map((key) => fileStore.remove(key).catch(() => void 0)));
2077
+ }, []);
2078
+ const pumpDelivery = useCallback(() => {
2079
+ if (deliveryTimerRef.current) {
2080
+ clearTimeout(deliveryTimerRef.current);
2081
+ deliveryTimerRef.current = null;
2082
+ }
2083
+ const batch = pendingBatchesRef.current[0];
2084
+ if (!batch) return;
2085
+ if (internalSagepilotChat.restoreHostedAttachmentTarget(batch.target)) {
2086
+ debugProvider("restored hosted attachment target", {
2087
+ batchId: batch.batchId,
2088
+ ...describeAttachmentTarget(batch.target)
2089
+ });
2090
+ widgetReadyRef.current = false;
2091
+ deliveryTimerRef.current = setTimeout(() => pumpDelivery(), DELIVERY_RETRY_MS);
2092
+ return;
2093
+ }
2094
+ deliveryAttemptsRef.current += 1;
2095
+ const attempts = deliveryAttemptsRef.current;
2096
+ const ref = nativeWebViewRef.current;
2097
+ const shouldDeliver = ref && (widgetReadyRef.current || attempts >= DELIVERY_LEGACY_FALLBACK_ATTEMPTS);
2098
+ if (shouldDeliver && ref) {
2099
+ debugProvider("delivering native picked files", {
2100
+ batchId: batch.batchId,
2101
+ attempt: attempts,
2102
+ widgetReady: widgetReadyRef.current,
2103
+ count: batch.files.length,
2104
+ ...describeAttachmentTarget(batch.target)
2105
+ });
2106
+ ref.injectJavaScript(buildFilesPickedScript(batch));
2107
+ }
2108
+ if (attempts < DELIVERY_MAX_ATTEMPTS) {
2109
+ deliveryTimerRef.current = setTimeout(() => pumpDelivery(), DELIVERY_RETRY_MS);
2110
+ }
2111
+ }, []);
2112
+ const startDelivery = useCallback(() => {
2113
+ deliveryAttemptsRef.current = 0;
2114
+ pumpDelivery();
2115
+ }, [pumpDelivery]);
2116
+ const ensureDelivery = useCallback(() => {
2117
+ if (pendingBatchesRef.current.length === 0) return;
2118
+ if (deliveryTimerRef.current) return;
2119
+ startDelivery();
2120
+ }, [startDelivery]);
2121
+ const acknowledgeHeadBatch = useCallback(() => {
2122
+ const head = pendingBatchesRef.current[0];
2123
+ pendingBatchesRef.current = pendingBatchesRef.current.slice(1);
2124
+ deliveryAttemptsRef.current = 0;
2125
+ if (deliveryTimerRef.current) {
2126
+ clearTimeout(deliveryTimerRef.current);
2127
+ deliveryTimerRef.current = null;
2128
+ }
2129
+ discardBatchFiles(head);
2130
+ writeManifest();
2131
+ if (pendingBatchesRef.current.length > 0) startDelivery();
2132
+ }, [discardBatchFiles, writeManifest, startDelivery]);
2133
+ const queuePickedFiles = useCallback((files) => {
2134
+ const batchId = nextBatchId();
2135
+ const hasFileStore = Boolean(internalSagepilotChat.getConfig()?.fileStore);
2136
+ const storageKeys = hasFileStore ? files.map((_, index) => storageKeyFor(batchId, index)) : void 0;
2137
+ const batch = {
2138
+ batchId,
2139
+ files,
2140
+ storageKeys,
2141
+ target: internalSagepilotChat.getHostedAttachmentTarget()
2142
+ };
2143
+ debugProvider("queued native picked files", {
2144
+ batchId,
2145
+ count: files.length,
2146
+ totalBase64Bytes: totalBase64Bytes(files),
2147
+ persistedToFileStore: hasFileStore,
2148
+ ...describeAttachmentTarget(batch.target)
2149
+ });
2150
+ pendingBatchesRef.current = [...pendingBatchesRef.current, batch];
2151
+ ensureDelivery();
2152
+ writeManifest();
2153
+ void writeBatchBytes(batch);
2154
+ }, [ensureDelivery, writeManifest, writeBatchBytes]);
2155
+ const recoverNativeWebView = useCallback((reasonOrEvent) => {
2156
+ const reason = typeof reasonOrEvent === "string" ? reasonOrEvent : "webview_render_process";
2157
+ debugProvider("recovering native WebView", { reason });
2158
+ widgetReadyRef.current = false;
2159
+ setNativeWebViewKey((key) => key + 1);
2160
+ }, []);
2161
+ const recoverPreloadWebView = useCallback(() => {
2162
+ setPreloadWebViewKey((key) => key + 1);
2163
+ }, []);
2164
+ const pauseLivenessAfterPicker = useCallback((durationMs) => {
2165
+ if (Platform3.OS !== "android") return;
2166
+ livenessPausedUntilRef.current = Math.max(livenessPausedUntilRef.current, Date.now() + durationMs);
2167
+ }, []);
2168
+ const runNativeFilePicker = useCallback((source, multiple) => {
2169
+ const filePicker = internalSagepilotChat.getConfig()?.filePicker;
2170
+ if (!filePicker) {
2171
+ debugProvider("native file picker requested but no adapter is configured", { source, multiple });
2172
+ return;
2173
+ }
2174
+ if (pickerInFlightRef.current) {
2175
+ debugProvider("native file picker ignored because another picker is in flight", { source, multiple });
2176
+ return;
2177
+ }
2178
+ debugProvider("native file picker starting", {
2179
+ source,
2180
+ multiple,
2181
+ configuredSources: filePicker.sources
2182
+ });
2183
+ pauseLivenessAfterPicker(6e4);
2184
+ pickerInFlightRef.current = true;
2185
+ filePicker.pickFiles({ source, multiple }).then((files) => {
2186
+ debugProvider("native file picker resolved", {
2187
+ source,
2188
+ count: files.length,
2189
+ files: files.map((file) => ({
2190
+ fileName: file.file_name,
2191
+ mimeType: file.mime_type,
2192
+ size: file.size,
2193
+ base64Length: file.data_base64.length
2194
+ }))
2195
+ });
2196
+ if (files.length === 0) {
2197
+ nativeWebViewRef.current?.injectJavaScript(buildFilePickerCancelledScript());
2198
+ return;
2199
+ }
2200
+ queuePickedFiles(files);
2201
+ }).catch((error) => {
2202
+ const { message, code } = readFilePickerError(error);
2203
+ debugProvider("native file picker failed", { source, code, message });
2204
+ nativeWebViewRef.current?.injectJavaScript(buildFilePickerErrorScript(message, code));
2205
+ }).finally(() => {
2206
+ pickerInFlightRef.current = false;
2207
+ pauseLivenessAfterPicker(LIVENESS_PICKER_GRACE_MS);
2208
+ });
2209
+ }, [pauseLivenessAfterPicker, queuePickedFiles]);
2210
+ const openNativeFilePicker = useCallback((multiple) => {
2211
+ const filePicker = internalSagepilotChat.getConfig()?.filePicker;
2212
+ if (!filePicker || filePicker.sources.length === 0) {
2213
+ debugProvider("open native file picker ignored", {
2214
+ multiple,
2215
+ hasAdapter: Boolean(filePicker),
2216
+ sources: filePicker?.sources ?? []
2217
+ });
2218
+ return;
2219
+ }
2220
+ debugProvider("open native file picker", { multiple, sources: filePicker.sources });
2221
+ const [onlySource] = filePicker.sources;
2222
+ if (filePicker.sources.length === 1 && onlySource && onlySource !== "camera") {
2223
+ runNativeFilePicker(onlySource, multiple);
2224
+ return;
2225
+ }
2226
+ setSourceChooser({ multiple });
2227
+ }, [runNativeFilePicker]);
2228
+ const handleSourceChoice = useCallback((source) => {
2229
+ const chooser = sourceChooser;
2230
+ setSourceChooser(null);
2231
+ if (chooser) runNativeFilePicker(source, chooser.multiple);
2232
+ }, [sourceChooser, runNativeFilePicker]);
1345
2233
  useEffect(() => {
1346
2234
  return internalSagepilotChat.onStateChange(() => {
1347
2235
  setState(readPresentationState());
1348
2236
  });
1349
2237
  }, []);
2238
+ useEffect(() => {
2239
+ let cancelled = false;
2240
+ const cache = getPendingCache();
2241
+ if (!cache) return;
2242
+ const reconcile = async () => {
2243
+ if (didReconcileRef.current) return;
2244
+ const manifest = await cache.get(PENDING_FILES_CACHE_KEY).catch(() => null);
2245
+ if (cancelled || didReconcileRef.current || !Array.isArray(manifest) || manifest.length === 0) return;
2246
+ const fileStore = internalSagepilotChat.getConfig()?.fileStore;
2247
+ const restored = [];
2248
+ for (const batch of manifest) {
2249
+ if (!batch || typeof batch.batchId !== "string" || !Array.isArray(batch.files) || batch.files.length === 0) continue;
2250
+ const files = [];
2251
+ const storageKeys = [];
2252
+ let intact = true;
2253
+ for (const file of batch.files) {
2254
+ let dataBase64 = null;
2255
+ if (typeof file.storageKey === "string" && fileStore) {
2256
+ try {
2257
+ dataBase64 = await fileStore.read(file.storageKey);
2258
+ storageKeys.push(file.storageKey);
2259
+ } catch {
2260
+ intact = false;
2261
+ }
2262
+ } else if (typeof file.data_base64 === "string" && file.data_base64) {
2263
+ dataBase64 = file.data_base64;
2264
+ }
2265
+ if (!dataBase64) {
2266
+ intact = false;
2267
+ break;
2268
+ }
2269
+ files.push({ file_name: file.file_name, mime_type: file.mime_type, size: file.size, data_base64: dataBase64 });
2270
+ }
2271
+ if (intact && files.length === batch.files.length) {
2272
+ restored.push({
2273
+ batchId: batch.batchId,
2274
+ files,
2275
+ storageKeys: storageKeys.length === files.length ? storageKeys : void 0,
2276
+ target: batch.target
2277
+ });
2278
+ }
2279
+ }
2280
+ if (cancelled || didReconcileRef.current) return;
2281
+ didReconcileRef.current = true;
2282
+ pendingBatchesRef.current = [...restored, ...pendingBatchesRef.current];
2283
+ writeManifest();
2284
+ if (fileStore) {
2285
+ const keep = pendingBatchesRef.current.flatMap(
2286
+ (batch) => batch.files.map((_, index) => storageKeyFor(batch.batchId, index))
2287
+ );
2288
+ void fileStore.prune(keep).catch(() => void 0);
2289
+ }
2290
+ startDelivery();
2291
+ };
2292
+ void reconcile();
2293
+ return () => {
2294
+ cancelled = true;
2295
+ };
2296
+ }, [getPendingCache, startDelivery, writeManifest]);
2297
+ const clearPing = useCallback(() => {
2298
+ pendingPingRef.current = null;
2299
+ if (pingTimeoutRef.current) {
2300
+ clearTimeout(pingTimeoutRef.current);
2301
+ pingTimeoutRef.current = null;
2302
+ }
2303
+ }, []);
2304
+ const clearDeferredLivenessProbe = useCallback(() => {
2305
+ if (livenessResumeTimerRef.current) {
2306
+ clearTimeout(livenessResumeTimerRef.current);
2307
+ livenessResumeTimerRef.current = null;
2308
+ }
2309
+ }, []);
2310
+ useEffect(() => {
2311
+ return () => {
2312
+ if (deliveryTimerRef.current) {
2313
+ clearTimeout(deliveryTimerRef.current);
2314
+ deliveryTimerRef.current = null;
2315
+ }
2316
+ if (pingTimeoutRef.current) {
2317
+ clearTimeout(pingTimeoutRef.current);
2318
+ pingTimeoutRef.current = null;
2319
+ }
2320
+ clearDeferredLivenessProbe();
2321
+ };
2322
+ }, [clearDeferredLivenessProbe]);
2323
+ const runLivenessProbe = useCallback(() => {
2324
+ if (Platform3.OS !== "android") return;
2325
+ const ref = nativeWebViewRef.current;
2326
+ if (!ref || !internalSagepilotChat.isPresented()) return;
2327
+ const pauseRemainingMs = livenessPausedUntilRef.current - Date.now();
2328
+ if (pickerInFlightRef.current || pauseRemainingMs > 0) {
2329
+ const retryDelayMs = pickerInFlightRef.current ? 500 : Math.max(0, pauseRemainingMs);
2330
+ debugProvider("liveness probe deferred after picker", {
2331
+ pickerInFlight: pickerInFlightRef.current,
2332
+ retryDelayMs
2333
+ });
2334
+ clearDeferredLivenessProbe();
2335
+ livenessResumeTimerRef.current = setTimeout(() => {
2336
+ livenessResumeTimerRef.current = null;
2337
+ runLivenessProbe();
2338
+ }, retryDelayMs);
2339
+ return;
2340
+ }
2341
+ setAndroidRepaintTick((tick) => tick + 1);
2342
+ const attempts = (pendingPingRef.current?.attempts ?? 0) + 1;
2343
+ const nonce = `${Date.now().toString(36)}_${attempts}`;
2344
+ pendingPingRef.current = { nonce, attempts };
2345
+ ref.injectJavaScript(buildLivenessPingScript(nonce, LIVENESS_HEARTBEAT_STALE_MS));
2346
+ if (pingTimeoutRef.current) clearTimeout(pingTimeoutRef.current);
2347
+ pingTimeoutRef.current = setTimeout(() => {
2348
+ if (attempts >= LIVENESS_MAX_PING_ATTEMPTS) {
2349
+ clearPing();
2350
+ recoverNativeWebView("liveness_timeout");
2351
+ return;
2352
+ }
2353
+ runLivenessProbe();
2354
+ }, LIVENESS_PONG_TIMEOUT_MS);
2355
+ }, [clearDeferredLivenessProbe, clearPing, recoverNativeWebView]);
2356
+ useEffect(() => {
2357
+ const subscription = AppState.addEventListener("change", (nextState) => {
2358
+ const prev = appStateRef.current;
2359
+ appStateRef.current = nextState;
2360
+ if (nextState === "active" && (prev === "background" || prev === "inactive")) {
2361
+ clearPing();
2362
+ livenessRemountCountRef.current = 0;
2363
+ runLivenessProbe();
2364
+ ensureDelivery();
2365
+ }
2366
+ });
2367
+ return () => {
2368
+ subscription.remove();
2369
+ clearPing();
2370
+ };
2371
+ }, [clearPing, runLivenessProbe, ensureDelivery]);
1350
2372
  const handleAndroidInsetsChange = useCallback((event) => {
1351
2373
  const nextBottomInset = event.nativeEvent?.bottom;
1352
2374
  const nextTopInset = event.nativeEvent?.top;
@@ -1360,11 +2382,11 @@ function SagepilotChatProvider({
1360
2382
  const presentationStyle = state.presentation?.style ?? "sheet";
1361
2383
  const isFullScreenModal = presentationStyle === "fullScreen";
1362
2384
  const animationType = presentationStyle === "fullScreen" || presentationStyle === "push" ? "slide" : "fade";
1363
- const ModalContainer = Platform.OS === "ios" && isFullScreenModal ? SafeAreaView : View;
1364
- const NativeModalContainer = Platform.OS === "android" ? AndroidInsetsView : ModalContainer;
1365
- const ChatContentContainer = Platform.OS === "ios" ? KeyboardAvoidingView : View;
1366
- const nativeModalContainerProps = Platform.OS === "android" ? { style: styles.container, onInsetsChange: handleAndroidInsetsChange } : { style: styles.container };
1367
- const chatContentContainerProps = Platform.OS === "ios" ? {
2385
+ const ModalContainer = Platform3.OS === "ios" && isFullScreenModal ? SafeAreaView : View;
2386
+ const NativeModalContainer = Platform3.OS === "android" ? AndroidInsetsView : ModalContainer;
2387
+ const ChatContentContainer = Platform3.OS === "ios" ? KeyboardAvoidingView : View;
2388
+ const nativeModalContainerProps = Platform3.OS === "android" ? { style: styles.container, onInsetsChange: handleAndroidInsetsChange } : { style: styles.container };
2389
+ const chatContentContainerProps = Platform3.OS === "ios" ? {
1368
2390
  behavior: "padding",
1369
2391
  enabled: true,
1370
2392
  keyboardVerticalOffset: 0,
@@ -1372,14 +2394,57 @@ function SagepilotChatProvider({
1372
2394
  } : {
1373
2395
  style: [
1374
2396
  styles.modalContent,
1375
- Platform.OS === "android" ? {
2397
+ Platform3.OS === "android" ? {
1376
2398
  paddingTop: androidModalInsets.top,
1377
2399
  paddingBottom: androidModalInsets.bottom
1378
2400
  } : null
1379
2401
  ].filter(Boolean)
1380
2402
  };
1381
2403
  const handleWebViewMessage = (event) => {
1382
- internalSagepilotChat.handleHostedBridgeMessage(parseHostedBridgeMessage(event.nativeEvent?.data));
2404
+ const message = parseHostedBridgeMessage(event.nativeEvent?.data);
2405
+ if (message?.type === "sagepilot:open_file_picker") {
2406
+ openNativeFilePicker(message.multiple ?? true);
2407
+ return;
2408
+ }
2409
+ if (message?.type === "sagepilot:widget_listener_ready") {
2410
+ widgetReadyRef.current = true;
2411
+ startDelivery();
2412
+ return;
2413
+ }
2414
+ if (message?.type === "sagepilot:files_received") {
2415
+ const ackBatchId = message.batch_id;
2416
+ const head = pendingBatchesRef.current[0];
2417
+ if (head && (!ackBatchId || ackBatchId === head.batchId)) {
2418
+ debugProvider("native picked files acknowledged", {
2419
+ batchId: head.batchId,
2420
+ ackBatchId,
2421
+ count: message.count
2422
+ });
2423
+ acknowledgeHeadBatch();
2424
+ }
2425
+ return;
2426
+ }
2427
+ if (message?.type === "sagepilot:pong") {
2428
+ const pending = pendingPingRef.current;
2429
+ if (pending && (!message.nonce || message.nonce === pending.nonce)) {
2430
+ if (message.alive === false) {
2431
+ clearPing();
2432
+ debugProvider("liveness probe reported dead widget", {
2433
+ supportsHeartbeat: message.supportsHeartbeat,
2434
+ remountCount: livenessRemountCountRef.current
2435
+ });
2436
+ if (livenessRemountCountRef.current < LIVENESS_MAX_REMOUNTS) {
2437
+ livenessRemountCountRef.current += 1;
2438
+ recoverNativeWebView("liveness_dead_pong");
2439
+ }
2440
+ } else {
2441
+ livenessRemountCountRef.current = 0;
2442
+ clearPing();
2443
+ }
2444
+ }
2445
+ return;
2446
+ }
2447
+ internalSagepilotChat.handleHostedBridgeMessage(message);
1383
2448
  };
1384
2449
  const postIdentityToWebFrame = () => {
1385
2450
  const message = internalSagepilotChat.getHostedIdentityMessage();
@@ -1392,8 +2457,31 @@ function SagepilotChatProvider({
1392
2457
  if (!script || !nativeWebViewRef.current) return;
1393
2458
  nativeWebViewRef.current.injectJavaScript(script);
1394
2459
  };
2460
+ const injectNativeBridgeToNativeWebView = () => {
2461
+ if (!nativeWebViewRef.current) return;
2462
+ nativeWebViewRef.current.injectJavaScript(getInjectedWebViewScript());
2463
+ };
2464
+ const handleNativeWebViewLoadEnd = () => {
2465
+ debugProvider("native WebView load end");
2466
+ injectNativeBridgeToNativeWebView();
2467
+ postIdentityToNativeWebView();
2468
+ widgetReadyRef.current = false;
2469
+ startDelivery();
2470
+ };
2471
+ const handleShouldStartLoadWithRequest = useCallback((request) => {
2472
+ const url = request?.url ?? "";
2473
+ if (!url || request?.isTopFrame === false || isInternalWebViewScheme(url)) {
2474
+ return true;
2475
+ }
2476
+ const widgetOrigin = readUrlOrigin(state.conversationUrl ?? state.preloadUrl);
2477
+ if (widgetOrigin && readUrlOrigin(url) === widgetOrigin) {
2478
+ return true;
2479
+ }
2480
+ Linking2.openURL(url).catch(() => void 0);
2481
+ return false;
2482
+ }, [state.conversationUrl, state.preloadUrl]);
1395
2483
  useEffect(() => {
1396
- if (Platform.OS !== "web" || typeof window === "undefined") return;
2484
+ if (Platform3.OS !== "web" || typeof window === "undefined") return;
1397
2485
  const trustedWidgetOrigin = readUrlOrigin(state.conversationUrl);
1398
2486
  if (!trustedWidgetOrigin) return;
1399
2487
  const handleWindowMessage = (event) => {
@@ -1404,14 +2492,14 @@ function SagepilotChatProvider({
1404
2492
  return () => window.removeEventListener("message", handleWindowMessage);
1405
2493
  }, [state.conversationUrl]);
1406
2494
  useEffect(() => {
1407
- if (Platform.OS !== "web" || !state.isPresented) return;
2495
+ if (Platform3.OS !== "web" || !state.isPresented) return;
1408
2496
  postIdentityToWebFrame();
1409
2497
  }, [state.isPresented, state.conversationUrl, state.configured]);
1410
2498
  useEffect(() => {
1411
- if (Platform.OS === "web" || !state.isPresented) return;
2499
+ if (Platform3.OS === "web" || !state.isPresented) return;
1412
2500
  postIdentityToNativeWebView();
1413
2501
  }, [state.isPresented, state.conversationUrl, state.configured]);
1414
- if (Platform.OS === "web") {
2502
+ if (Platform3.OS === "web") {
1415
2503
  return createElement(
1416
2504
  View,
1417
2505
  { style: styles.root },
@@ -1454,10 +2542,18 @@ function SagepilotChatProvider({
1454
2542
  children,
1455
2543
  state.configured && !state.isPresented && state.shouldPreload && state.preloadUrl ? createElement(WebView, {
1456
2544
  ...hostedChatWebViewProps,
2545
+ // Separate key from the hosted WebView: a hidden-preload renderer crash
2546
+ // must not bump the hosted key (which would reset widgetReadyRef and
2547
+ // disrupt an in-flight delivery). onRenderProcessGone must still be
2548
+ // handled here or an unhandled renderer kill crashes the whole app.
2549
+ key: `sagepilot-preload-webview-${preloadWebViewKey}`,
1457
2550
  source: { uri: state.preloadUrl },
1458
2551
  style: styles.preloadWebview,
1459
2552
  injectedJavaScriptBeforeContentLoaded: getInjectedWebViewScript(),
1460
- onMessage: handleWebViewMessage
2553
+ onMessage: handleWebViewMessage,
2554
+ onShouldStartLoadWithRequest: handleShouldStartLoadWithRequest,
2555
+ onRenderProcessGone: recoverPreloadWebView,
2556
+ onContentProcessDidTerminate: recoverPreloadWebView
1461
2557
  }) : null,
1462
2558
  createElement(
1463
2559
  Modal,
@@ -1465,8 +2561,8 @@ function SagepilotChatProvider({
1465
2561
  visible: state.configured && state.isPresented,
1466
2562
  animationType,
1467
2563
  presentationStyle: isFullScreenModal ? "fullScreen" : "pageSheet",
1468
- statusBarTranslucent: Platform.OS === "android",
1469
- navigationBarTranslucent: Platform.OS === "android",
2564
+ statusBarTranslucent: Platform3.OS === "android",
2565
+ navigationBarTranslucent: Platform3.OS === "android",
1470
2566
  onRequestClose: () => internalSagepilotChat.dismiss()
1471
2567
  },
1472
2568
  createElement(
@@ -1491,13 +2587,23 @@ function SagepilotChatProvider({
1491
2587
  ) : null,
1492
2588
  state.conversationUrl ? createElement(WebView, {
1493
2589
  ...hostedChatWebViewProps,
2590
+ key: `sagepilot-hosted-webview-${nativeWebViewKey}`,
1494
2591
  ref: nativeWebViewRef,
1495
2592
  source: { uri: state.conversationUrl },
1496
- style: styles.webview,
2593
+ // The imperceptible opacity toggle forces the Android WebView
2594
+ // surface to re-composite on resume, clearing the blank-but-alive
2595
+ // surface bug without a reload (see runLivenessProbe).
2596
+ style: Platform3.OS === "android" ? [styles.webview, { opacity: 1 - androidRepaintTick % 2 * 1e-3 }] : styles.webview,
1497
2597
  startInLoadingState: true,
1498
2598
  injectedJavaScriptBeforeContentLoaded: getInjectedWebViewScript(),
1499
2599
  onMessage: handleWebViewMessage,
1500
- onLoadEnd: postIdentityToNativeWebView,
2600
+ onLoadEnd: handleNativeWebViewLoadEnd,
2601
+ onShouldStartLoadWithRequest: handleShouldStartLoadWithRequest,
2602
+ // Android: render process killed (commonly while the camera/file
2603
+ // chooser activity is foregrounded). Remount to recover.
2604
+ onRenderProcessGone: recoverNativeWebView,
2605
+ // iOS equivalent: WKWebView content process terminated.
2606
+ onContentProcessDidTerminate: recoverNativeWebView,
1501
2607
  renderLoading: () => createElement(
1502
2608
  View,
1503
2609
  { style: styles.loading },
@@ -1507,6 +2613,59 @@ function SagepilotChatProvider({
1507
2613
  }) : null
1508
2614
  )
1509
2615
  )
2616
+ ),
2617
+ // Native attachment-source chooser. Replaces the Android Alert (capped at
2618
+ // 3 buttons) so camera/library/documents all show on every platform.
2619
+ createElement(
2620
+ Modal,
2621
+ {
2622
+ visible: sourceChooser !== null,
2623
+ transparent: true,
2624
+ animationType: "fade",
2625
+ statusBarTranslucent: true,
2626
+ onRequestClose: () => setSourceChooser(null)
2627
+ },
2628
+ createElement(
2629
+ Pressable,
2630
+ { style: styles.sheetBackdrop, onPress: () => setSourceChooser(null) },
2631
+ createElement(
2632
+ View,
2633
+ { style: styles.sheetCard },
2634
+ createElement(View, { style: styles.sheetHandle }),
2635
+ createElement(Text, { style: styles.sheetTitle }, "Add attachment"),
2636
+ ...(internalSagepilotChat.getConfig()?.filePicker?.sources ?? []).map(
2637
+ (source) => createElement(
2638
+ Pressable,
2639
+ {
2640
+ key: source,
2641
+ accessibilityRole: "button",
2642
+ android_ripple: { color: "rgba(15, 23, 42, 0.06)" },
2643
+ hitSlop: 4,
2644
+ style: ({ pressed }) => getSheetOptionStyle(pressed),
2645
+ onPress: () => handleSourceChoice(source)
2646
+ },
2647
+ renderFilePickerSourceIcon(source),
2648
+ createElement(
2649
+ View,
2650
+ { style: styles.sheetOptionText },
2651
+ createElement(Text, { style: styles.sheetButtonText }, FILE_PICKER_SOURCE_LABELS[source]),
2652
+ createElement(Text, { style: styles.sheetButtonDescription }, FILE_PICKER_SOURCE_DESCRIPTIONS[source])
2653
+ )
2654
+ )
2655
+ ),
2656
+ createElement(
2657
+ Pressable,
2658
+ {
2659
+ accessibilityRole: "button",
2660
+ android_ripple: { color: "rgba(15, 23, 42, 0.06)" },
2661
+ style: ({ pressed }) => getSheetCancelStyle(pressed),
2662
+ onPress: () => setSourceChooser(null)
2663
+ },
2664
+ createElement(X, { color: "#334155", size: 18, strokeWidth: 2.5, style: styles.sheetCancelIcon }),
2665
+ createElement(Text, { style: styles.sheetCancelText }, "Cancel")
2666
+ )
2667
+ )
2668
+ )
1510
2669
  )
1511
2670
  );
1512
2671
  }
@@ -1605,6 +2764,112 @@ var styles = StyleSheet.create({
1605
2764
  marginTop: 12,
1606
2765
  color: "#4b5563",
1607
2766
  fontSize: 14
2767
+ },
2768
+ sheetBackdrop: {
2769
+ flex: 1,
2770
+ alignItems: "center",
2771
+ justifyContent: "flex-end",
2772
+ backgroundColor: "rgba(15, 23, 42, 0.48)"
2773
+ },
2774
+ sheetCard: {
2775
+ width: "100%",
2776
+ maxWidth: 540,
2777
+ backgroundColor: "#ffffff",
2778
+ borderTopLeftRadius: 26,
2779
+ borderTopRightRadius: 26,
2780
+ paddingTop: 10,
2781
+ paddingBottom: 18,
2782
+ paddingHorizontal: 14,
2783
+ shadowColor: "#0f172a",
2784
+ shadowOpacity: 0.22,
2785
+ shadowRadius: 28,
2786
+ shadowOffset: { width: 0, height: -10 },
2787
+ elevation: 18
2788
+ },
2789
+ sheetHandle: {
2790
+ alignSelf: "center",
2791
+ width: 42,
2792
+ height: 5,
2793
+ borderRadius: 999,
2794
+ backgroundColor: "#d1d5db",
2795
+ marginBottom: 14
2796
+ },
2797
+ sheetTitle: {
2798
+ color: "#111827",
2799
+ fontSize: 17,
2800
+ fontWeight: "700",
2801
+ paddingBottom: 12,
2802
+ paddingHorizontal: 4
2803
+ },
2804
+ sheetOption: {
2805
+ minHeight: 70,
2806
+ flexDirection: "row",
2807
+ alignItems: "center",
2808
+ borderRadius: 18,
2809
+ paddingHorizontal: 12,
2810
+ marginBottom: 6,
2811
+ backgroundColor: "#ffffff"
2812
+ },
2813
+ sheetOptionPressed: {
2814
+ backgroundColor: "#f8fafc",
2815
+ transform: [{ scale: 0.985 }]
2816
+ },
2817
+ sheetOptionIcon: {
2818
+ width: 46,
2819
+ height: 46,
2820
+ borderRadius: 16,
2821
+ marginRight: 14,
2822
+ alignItems: "center",
2823
+ justifyContent: "center",
2824
+ position: "relative",
2825
+ overflow: "hidden"
2826
+ },
2827
+ sheetOptionText: {
2828
+ flex: 1,
2829
+ justifyContent: "center"
2830
+ },
2831
+ sheetButtonText: {
2832
+ color: "#0f172a",
2833
+ fontSize: 16,
2834
+ fontWeight: "700"
2835
+ },
2836
+ sheetButtonDescription: {
2837
+ color: "#64748b",
2838
+ fontSize: 13,
2839
+ fontWeight: "500",
2840
+ marginTop: 3
2841
+ },
2842
+ cameraOptionIcon: {
2843
+ backgroundColor: "#e0f2fe"
2844
+ },
2845
+ libraryOptionIcon: {
2846
+ backgroundColor: "#ecfdf5"
2847
+ },
2848
+ documentsOptionIcon: {
2849
+ backgroundColor: "#f5f3ff"
2850
+ },
2851
+ sheetCancelButton: {
2852
+ minHeight: 56,
2853
+ flexDirection: "row",
2854
+ alignItems: "center",
2855
+ justifyContent: "center",
2856
+ borderRadius: 18,
2857
+ marginTop: 8,
2858
+ backgroundColor: "#f1f5f9"
2859
+ },
2860
+ sheetCancelButtonPressed: {
2861
+ backgroundColor: "#e2e8f0",
2862
+ transform: [{ scale: 0.985 }]
2863
+ },
2864
+ sheetCancelIcon: {
2865
+ width: 18,
2866
+ height: 18,
2867
+ marginRight: 8
2868
+ },
2869
+ sheetCancelText: {
2870
+ color: "#0f172a",
2871
+ fontSize: 16,
2872
+ fontWeight: "700"
1608
2873
  }
1609
2874
  });
1610
2875
 
@@ -1658,10 +2923,18 @@ function useSagepilotChat() {
1658
2923
  };
1659
2924
  }
1660
2925
  export {
2926
+ SAGEPILOT_DEFAULT_IMAGE_MAX_DIMENSION,
2927
+ SAGEPILOT_DEFAULT_IMAGE_MAX_FILE_SIZE_BYTES,
2928
+ SAGEPILOT_DEFAULT_IMAGE_QUALITY,
1661
2929
  SagepilotChat,
1662
2930
  SagepilotChatError,
1663
2931
  SagepilotChatProvider,
2932
+ SagepilotFilePickerError,
1664
2933
  createAsyncStorageCacheStorage,
1665
2934
  createKeychainTokenStorage,
2935
+ createSagepilotFilePicker,
2936
+ createSagepilotFileStore,
2937
+ ensureSagepilotAndroidCameraPermission,
2938
+ promptOpenSagepilotCameraSettings,
1666
2939
  useSagepilotChat
1667
2940
  };