update mobile dictation controls

Add mobile permission approval flow, simplify dictation settings into toggles, and remove oversized Whisper models while syncing the iOS project with the current runtime configuration.
This commit is contained in:
Ryan Vogel
2026-03-30 13:01:14 -04:00
parent abf79ae24c
commit bcf7817127
9 changed files with 1823 additions and 183 deletions

View File

@@ -0,0 +1,578 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
4318840A4939F9117F5CB295 /* libPods-mobilevoice.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D01178681A4FA33E647BDA6 /* libPods-mobilevoice.a */; };
5BCC3D8ACE1241D9ADF08705 /* expo.icon in Resources */ = {isa = PBXBuildFile; fileRef = F1C6EB0F46C84143B321E16B /* expo.icon */; };
7F4CD5196803F15ED532DB9D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5883CCA53017A900D5BA6B8 /* ExpoModulesProvider.swift */; };
A9D355AEC99B2C485E3E171E /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = BFF46FE16E7CF5862CD6C307 /* PrivacyInfo.xcprivacy */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* mobilevoice.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mobilevoice.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = mobilevoice/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = mobilevoice/Info.plist; sourceTree = "<group>"; };
2EF11B4CD2A527AE1396409B /* Pods-mobilevoice.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-mobilevoice.release.xcconfig"; path = "Target Support Files/Pods-mobilevoice/Pods-mobilevoice.release.xcconfig"; sourceTree = "<group>"; };
791CA91656B547FFB3774C02 /* Pods-mobilevoice.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-mobilevoice.debug.xcconfig"; path = "Target Support Files/Pods-mobilevoice/Pods-mobilevoice.debug.xcconfig"; sourceTree = "<group>"; };
9D01178681A4FA33E647BDA6 /* libPods-mobilevoice.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-mobilevoice.a"; sourceTree = BUILT_PRODUCTS_DIR; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = mobilevoice/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
BFF46FE16E7CF5862CD6C307 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = mobilevoice/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
E5883CCA53017A900D5BA6B8 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-mobilevoice/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = mobilevoice/AppDelegate.swift; sourceTree = "<group>"; };
F11748442D0722820044C1D9 /* mobilevoice-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "mobilevoice-Bridging-Header.h"; path = "mobilevoice/mobilevoice-Bridging-Header.h"; sourceTree = "<group>"; };
F1C6EB0F46C84143B321E16B /* expo.icon */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = expo.icon; path = mobilevoice/expo.icon; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4318840A4939F9117F5CB295 /* libPods-mobilevoice.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
13B07FAE1A68108700A75B9A /* mobilevoice */ = {
isa = PBXGroup;
children = (
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
F11748442D0722820044C1D9 /* mobilevoice-Bridging-Header.h */,
BB2F792B24A3F905000567C9 /* Supporting */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
F1C6EB0F46C84143B321E16B /* expo.icon */,
BFF46FE16E7CF5862CD6C307 /* PrivacyInfo.xcprivacy */,
);
name = mobilevoice;
sourceTree = "<group>";
};
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
9D01178681A4FA33E647BDA6 /* libPods-mobilevoice.a */,
);
name = Frameworks;
sourceTree = "<group>";
};
6FA8507500F4DE261E8F6EB0 /* Pods */ = {
isa = PBXGroup;
children = (
791CA91656B547FFB3774C02 /* Pods-mobilevoice.debug.xcconfig */,
2EF11B4CD2A527AE1396409B /* Pods-mobilevoice.release.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup;
children = (
);
name = Libraries;
sourceTree = "<group>";
};
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
13B07FAE1A68108700A75B9A /* mobilevoice */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
6FA8507500F4DE261E8F6EB0 /* Pods */,
8768F75DE2083C7536A3A210 /* ExpoModulesProviders */,
);
indentWidth = 2;
sourceTree = "<group>";
tabWidth = 2;
usesTabs = 0;
};
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* mobilevoice.app */,
);
name = Products;
sourceTree = "<group>";
};
8768F75DE2083C7536A3A210 /* ExpoModulesProviders */ = {
isa = PBXGroup;
children = (
C0F0F32FD747A33E1176E0FD /* mobilevoice */,
);
name = ExpoModulesProviders;
sourceTree = "<group>";
};
BB2F792B24A3F905000567C9 /* Supporting */ = {
isa = PBXGroup;
children = (
BB2F792C24A3F905000567C9 /* Expo.plist */,
);
name = Supporting;
path = mobilevoice/Supporting;
sourceTree = "<group>";
};
C0F0F32FD747A33E1176E0FD /* mobilevoice */ = {
isa = PBXGroup;
children = (
E5883CCA53017A900D5BA6B8 /* ExpoModulesProvider.swift */,
);
name = mobilevoice;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
13B07F861A680F5B00A75B9A /* mobilevoice */ = {
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "mobilevoice" */;
buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
92A85735D2EE37AB89277662 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
B09C32BB889007E7F37BBC9C /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = mobilevoice;
productName = mobilevoice;
productReference = 13B07F961A680F5B00A75B9A /* mobilevoice.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1130;
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250;
DevelopmentTeam = "9G68SMNHEU";
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "mobilevoice" */;
compatibilityVersion = "Xcode 3.2";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* mobilevoice */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
13B07F8E1A680F5B00A75B9A /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
5BCC3D8ACE1241D9ADF08705 /* expo.icon in Resources */,
A9D355AEC99B2C485E3E171E /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
);
name = "Bundle React Native code and images";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
};
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-mobilevoice-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-mobilevoice/Pods-mobilevoice-resources.sh",
"${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoNotifications/ExpoNotifications_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoTaskManager/ExpoTaskManager_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
);
name = "[CP] Copy Pods Resources";
outputPaths = (
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoTaskManager_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-mobilevoice/Pods-mobilevoice-resources.sh\"\n";
showEnvVarsInLog = 0;
};
92A85735D2EE37AB89277662 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
"$(SRCROOT)/mobilevoice/mobilevoice.entitlements",
"$(SRCROOT)/Pods/Target Support Files/Pods-mobilevoice/expo-configure-project.sh",
);
name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Pods/Target Support Files/Pods-mobilevoice/ExpoModulesProvider.swift",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-mobilevoice/expo-configure-project.sh\"\n";
};
B09C32BB889007E7F37BBC9C /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-mobilevoice/Pods-mobilevoice-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/RNAudioAPI/libavcodec.framework/libavcodec",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/RNAudioAPI/libavformat.framework/libavformat",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/RNAudioAPI/libavutil.framework/libavutil",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/RNAudioAPI/libswresample.framework/libswresample",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermesvm.framework/hermesvm",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/react-native-executorch/ExecutorchLib.framework/ExecutorchLib",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavcodec.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavformat.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavutil.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libswresample.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermesvm.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ExecutorchLib.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-mobilevoice/Pods-mobilevoice-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
13B07F871A680F5B00A75B9A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
7F4CD5196803F15ED532DB9D /* ExpoModulesProvider.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 791CA91656B547FFB3774C02 /* Pods-mobilevoice.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = expo;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = mobilevoice/mobilevoice.entitlements;
CURRENT_PROJECT_VERSION = 1;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"FB_SONARKIT_ENABLED=1",
);
INFOPLIST_FILE = mobilevoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.anomalyco.mobilevoice;
PRODUCT_NAME = mobilevoice;
SWIFT_OBJC_BRIDGING_HEADER = "mobilevoice/mobilevoice-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
DEVELOPMENT_TEAM = "9G68SMNHEU";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
};
name = Debug;
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 2EF11B4CD2A527AE1396409B /* Pods-mobilevoice.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = expo;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = mobilevoice/mobilevoice.entitlements;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = mobilevoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.anomalyco.mobilevoice;
PRODUCT_NAME = mobilevoice;
SWIFT_OBJC_BRIDGING_HEADER = "mobilevoice/mobilevoice-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
DEVELOPMENT_TEAM = "9G68SMNHEU";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
};
name = Release;
};
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_CFLAGS = "$(inherited)";
OTHER_CPLUSPLUSFLAGS = "$(inherited)";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
};
name = Debug;
};
83CBBA211A601CBA00E9B192 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_CFLAGS = "$(inherited)";
OTHER_CPLUSPLUSFLAGS = "$(inherited)";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "mobilevoice" */ = {
isa = XCConfigurationList;
buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */,
13B07F951A680F5B00A75B9A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "mobilevoice" */ = {
isa = XCConfigurationList;
buildConfigurations = (
83CBBA201A601CBA00E9B192 /* Debug */,
83CBBA211A601CBA00E9B192 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
}

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Control</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>mobilevoice</string>
<string>com.anomalyco.mobilevoice</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>ts.net</key>
<dict>
<key>NSIncludesSubdomains</key>
<true/>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSMicrophoneUsageDescription</key>
<string>This app needs microphone access for live speech-to-text dictation.</string>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array>
<key>RCTNewArchEnabled</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
<true/>
</dict>
</plist>

View File

@@ -11,6 +11,7 @@ import {
LayoutChangeEvent,
Linking,
Platform,
Switch,
} from "react-native"
import Animated, {
useSharedValue,
@@ -34,8 +35,9 @@ import { AudioPcmStreamAdapter } from "whisper.rn/src/realtime-transcription/ada
import { AudioManager } from "react-native-audio-api"
import * as FileSystem from "expo-file-system/legacy"
import { fetch as expoFetch } from "expo/fetch"
import { buildPermissionCardModel } from "@/lib/pending-permissions"
import { unregisterRelayDevice } from "@/lib/relay-client"
import { useMonitoring, type MonitorJob } from "@/hooks/use-monitoring"
import { useMonitoring, type MonitorJob, type PermissionDecision } from "@/hooks/use-monitoring"
import { looksLikeLocalHost, useServerSessions } from "@/hooks/use-server-sessions"
import { ensureNotificationPermissions, getDevicePushToken } from "@/notifications/monitoring-notifications"
@@ -77,15 +79,6 @@ const WHISPER_MODELS = [
"ggml-medium-q5_0.bin",
"ggml-medium-q8_0.bin",
"ggml-medium.bin",
"ggml-large-v1.bin",
"ggml-large-v2-q5_0.bin",
"ggml-large-v2-q8_0.bin",
"ggml-large-v2.bin",
"ggml-large-v3-q5_0.bin",
"ggml-large-v3-turbo-q5_0.bin",
"ggml-large-v3-turbo-q8_0.bin",
"ggml-large-v3-turbo.bin",
"ggml-large-v3.bin",
] as const
type WhisperModelID = (typeof WHISPER_MODELS)[number]
@@ -119,15 +112,6 @@ const WHISPER_MODEL_LABELS: Record<WhisperModelID, string> = {
"ggml-medium-q5_0.bin": "medium q5_0",
"ggml-medium-q8_0.bin": "medium q8_0",
"ggml-medium.bin": "medium",
"ggml-large-v1.bin": "large-v1",
"ggml-large-v2-q5_0.bin": "large-v2 q5_0",
"ggml-large-v2-q8_0.bin": "large-v2 q8_0",
"ggml-large-v2.bin": "large-v2",
"ggml-large-v3-q5_0.bin": "large-v3 q5_0",
"ggml-large-v3-turbo-q5_0.bin": "large-v3 turbo q5_0",
"ggml-large-v3-turbo-q8_0.bin": "large-v3 turbo q8_0",
"ggml-large-v3-turbo.bin": "large-v3 turbo",
"ggml-large-v3.bin": "large-v3",
}
const WHISPER_MODEL_SIZES: Record<WhisperModelID, number> = {
@@ -155,15 +139,6 @@ const WHISPER_MODEL_SIZES: Record<WhisperModelID, number> = {
"ggml-medium-q5_0.bin": 539212467,
"ggml-medium-q8_0.bin": 823369779,
"ggml-medium.bin": 1533763059,
"ggml-large-v1.bin": 3094623691,
"ggml-large-v2-q5_0.bin": 1080732091,
"ggml-large-v2-q8_0.bin": 1656129691,
"ggml-large-v2.bin": 3094623691,
"ggml-large-v3-q5_0.bin": 1081140203,
"ggml-large-v3-turbo-q5_0.bin": 574041195,
"ggml-large-v3-turbo-q8_0.bin": 874188075,
"ggml-large-v3-turbo.bin": 1624555275,
"ggml-large-v3.bin": 3095033483,
}
function isWhisperModelID(value: unknown): value is WhisperModelID {
@@ -271,6 +246,7 @@ type Scan = {
type WhisperSavedState = {
defaultModel: WhisperModelID
mode: TranscriptionMode
autoSendOnDictationEnd: boolean
}
type OnboardingSavedState = {
@@ -402,6 +378,7 @@ export default function DictationScreen() {
const [downloadProgress, setDownloadProgress] = useState(0)
const [isPreparingWhisperModel, setIsPreparingWhisperModel] = useState(true)
const [transcriptionMode, setTranscriptionMode] = useState<TranscriptionMode>(DEFAULT_TRANSCRIPTION_MODE)
const [autoSendOnDictationEnd, setAutoSendOnDictationEnd] = useState(false)
const [isTranscribingBulk, setIsTranscribingBulk] = useState(false)
const [whisperError, setWhisperError] = useState("")
const [transcribedText, setTranscribedText] = useState("")
@@ -413,6 +390,7 @@ export default function DictationScreen() {
const [agentStateDismissed, setAgentStateDismissed] = useState(false)
const [dropdownMode, setDropdownMode] = useState<DropdownMode>("none")
const [dropdownRenderMode, setDropdownRenderMode] = useState<Exclude<DropdownMode, "none">>("server")
const [sessionCreateMode, setSessionCreateMode] = useState<"same" | "root" | null>(null)
const [scanOpen, setScanOpen] = useState(false)
const [camGranted, setCamGranted] = useState(false)
const [waveformLevels, setWaveformLevels] = useState<number[]>(Array.from({ length: 24 }, () => 0))
@@ -437,6 +415,7 @@ export default function DictationScreen() {
const bulkAudioChunksRef = useRef<Uint8Array[]>([])
const bulkTranscriptionJobRef = useRef(0)
const downloadProgressRef = useRef(0)
const autoSendSignatureRef = useRef("")
const waveformPulseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const sendSettleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const scanLockRef = useRef(false)
@@ -462,15 +441,20 @@ export default function DictationScreen() {
selectSession,
removeServer,
addServer,
createSession,
findServerForSession,
} = useServerSessions()
const {
beginMonitoring,
activePermissionRequest,
devicePushToken,
latestAssistantResponse,
monitorJob,
monitorStatus,
pendingPermissionCount,
respondingPermissionID,
respondToPermission,
setDevicePushToken,
setMonitorStatus,
} = useMonitoring({
@@ -727,6 +711,7 @@ export default function DictationScreen() {
let nextDefaultModel: WhisperModelID = DEFAULT_WHISPER_MODEL
let nextMode: TranscriptionMode = DEFAULT_TRANSCRIPTION_MODE
let nextAutoSendOnDictationEnd = false
try {
const data = await FileSystem.readAsStringAsync(WHISPER_SETTINGS_FILE)
if (data) {
@@ -737,6 +722,9 @@ export default function DictationScreen() {
if (isTranscriptionMode(parsed.mode)) {
nextMode = parsed.mode
}
if (parsed.autoSendOnDictationEnd === true) {
nextAutoSendOnDictationEnd = true
}
}
} catch {
// Use default settings if state file is missing or invalid.
@@ -747,6 +735,7 @@ export default function DictationScreen() {
whisperRestoredRef.current = true
setDefaultWhisperModel(nextDefaultModel)
setTranscriptionMode(nextMode)
setAutoSendOnDictationEnd(nextAutoSendOnDictationEnd)
await refreshInstalledWhisperModels()
@@ -768,9 +757,13 @@ export default function DictationScreen() {
useEffect(() => {
if (!whisperRestoredRef.current) return
const payload: WhisperSavedState = { defaultModel: defaultWhisperModel, mode: transcriptionMode }
const payload: WhisperSavedState = {
defaultModel: defaultWhisperModel,
mode: transcriptionMode,
autoSendOnDictationEnd,
}
void FileSystem.writeAsStringAsync(WHISPER_SETTINGS_FILE, JSON.stringify(payload)).catch(() => {})
}, [defaultWhisperModel, transcriptionMode])
}, [autoSendOnDictationEnd, defaultWhisperModel, transcriptionMode])
useEffect(() => {
return () => {
@@ -1140,6 +1133,26 @@ export default function DictationScreen() {
setAgentStateDismissed(true)
}, [])
const handlePermissionDecision = useCallback(
(reply: PermissionDecision) => {
if (!activePermissionRequest || !activeServerId) return
void Haptics.selectionAsync().catch(() => {})
void respondToPermission({
serverID: activeServerId,
sessionID: activePermissionRequest.sessionID,
requestID: activePermissionRequest.id,
reply,
}).catch((error) => {
Alert.alert(
"Could not send decision",
error instanceof Error ? error.message : "OpenCode did not accept that decision.",
)
})
},
[activePermissionRequest, activeServerId, respondToPermission],
)
const resetTranscriptState = useCallback(() => {
if (isRecordingRef.current) {
stopRecording()
@@ -1454,6 +1467,7 @@ export default function DictationScreen() {
const modelDownloading = downloadingModelID !== null
const modelLoading = isPreparingWhisperModel || activeWhisperModel == null || modelDownloading || isTranscribingBulk
const dictationSettingsLocked = isRecording || isTranscribingBulk || isSending
let modelLoadingState: "downloading" | "loading" | "ready" = "ready"
if (modelDownloading) {
modelLoadingState = "downloading"
@@ -1466,20 +1480,29 @@ export default function DictationScreen() {
: WHISPER_MODEL_LABELS[defaultWhisperModel]
const hasTranscript = transcribedText.trim().length > 0
const hasAssistantResponse = latestAssistantResponse.trim().length > 0
const activePermissionCard = activePermissionRequest ? buildPermissionCardModel(activePermissionRequest) : null
const hasPendingPermission = activePermissionRequest !== null && activePermissionCard !== null
const hasAgentActivity = hasAssistantResponse || monitorStatus.trim().length > 0 || monitorJob !== null
const shouldShowAgentStateCard = hasAgentActivity && !agentStateDismissed
const shouldShowAgentStateCard = !hasPendingPermission && hasAgentActivity && !agentStateDismissed
const showsCompleteState = monitorStatus.toLowerCase().includes("complete")
let agentStateIcon: "loading" | "done" = "loading"
if (monitorJob === null && (hasAssistantResponse || showsCompleteState)) {
agentStateIcon = "done"
}
const agentStateText = hasAssistantResponse ? latestAssistantResponse : "Waiting for agent…"
const shouldShowSend = hasCompletedSession && hasTranscript
const shouldShowSend = hasCompletedSession && hasTranscript && !hasPendingPermission
const activeServer = servers.find((s) => s.id === activeServerId) ?? null
const activeSession = activeServer?.sessions.find((s) => s.id === activeSessionId) ?? null
const canSendToSession = !!activeServer && activeServer.status === "online" && !!activeSession
const isReplyingToActivePermission =
activePermissionRequest !== null && respondingPermissionID === activePermissionRequest.id
const displayedTranscript = isSending ? "" : transcribedText
const isDropdownOpen = dropdownMode !== "none"
const effectiveDropdownMode = isDropdownOpen ? dropdownMode : dropdownRenderMode
const isCreatingSession = sessionCreateMode !== null
const showSessionCreationChoices =
effectiveDropdownMode === "session" && !!activeServer && activeServer.status === "online"
const sessionCreationChoiceCount = showSessionCreationChoices ? (activeSession ? 2 : 1) : 0
const headerTitle = activeServer?.name ?? "No server configured"
let headerDotStyle = styles.serverStatusOffline
if (activeServer?.status === "online") {
@@ -1534,6 +1557,46 @@ export default function DictationScreen() {
})
}, [shouldShowSend, sendVisibility])
useEffect(() => {
const text = transcribedText.trim()
if (!hasCompletedSession || text.length === 0) {
autoSendSignatureRef.current = ""
return
}
if (
!autoSendOnDictationEnd ||
isRecording ||
isTranscribingBulk ||
isSending ||
hasPendingPermission ||
!activeServerId ||
!activeSessionId
) {
return
}
const signature = `${activeServerId}:${activeSessionId}:${transcriptionMode}:${text}`
if (autoSendSignatureRef.current === signature) {
return
}
autoSendSignatureRef.current = signature
void handleSendTranscript()
}, [
activeServerId,
activeSessionId,
autoSendOnDictationEnd,
handleSendTranscript,
hasCompletedSession,
hasPendingPermission,
isRecording,
isSending,
isTranscribingBulk,
transcriptionMode,
transcribedText,
])
// Parent clips outer half of center-stroke, so only inner half is visible.
// borderWidth 6 → 3px visible inward, borderWidth 12 → 6px visible inward.
const animatedBorderStyle = useAnimatedStyle(() => {
@@ -1590,8 +1653,15 @@ export default function DictationScreen() {
const menuRows =
effectiveDropdownMode === "server" ? Math.max(servers.length, 1) : Math.max(activeServer?.sessions.length ?? 0, 1)
const expandedRowsHeight = Math.min(menuRows, DROPDOWN_VISIBLE_ROWS) * 42
const addServerExtraHeight = effectiveDropdownMode === "server" ? 38 : 8
const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + addServerExtraHeight
const dropdownFooterExtraHeight =
effectiveDropdownMode === "server"
? 38
: sessionCreationChoiceCount === 2
? 72
: sessionCreationChoiceCount === 1
? 38
: 8
const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + dropdownFooterExtraHeight
const animatedHeaderStyle = useAnimatedStyle(() => ({
height: interpolate(serverMenuProgress.value, [0, 1], [51, expandedHeaderHeight], Extrapolation.CLAMP),
@@ -1711,6 +1781,49 @@ export default function DictationScreen() {
[selectSession],
)
const handleCreateRootSession = useCallback(() => {
if (!activeServer || activeServer.status !== "online" || isCreatingSession) {
return
}
setSessionCreateMode("root")
void createSession(activeServer.id)
.then((created) => {
if (!created) {
Alert.alert("Could not create session", "Please check that your server is online and try again.")
return
}
setDropdownMode("none")
})
.finally(() => {
setSessionCreateMode(null)
})
}, [activeServer, createSession, isCreatingSession])
const handleCreateSessionLikeCurrent = useCallback(() => {
if (!activeServer || activeServer.status !== "online" || !activeSession || isCreatingSession) {
return
}
setSessionCreateMode("same")
void createSession(activeServer.id, {
directory: activeSession.directory,
workspaceID: activeSession.workspaceID,
})
.then((created) => {
if (!created) {
Alert.alert("Could not create session", "Please check that your server is online and try again.")
return
}
setDropdownMode("none")
})
.finally(() => {
setSessionCreateMode(null)
})
}, [activeServer, activeSession, createSession, isCreatingSession])
const handleDeleteServer = useCallback(
(id: string) => {
const server = serversRef.current.find((s) => s.id === id)
@@ -2212,6 +2325,55 @@ export default function DictationScreen() {
<Pressable onPress={() => void handleStartScan()} style={styles.addServerButton}>
<Text style={styles.addServerButtonText}>Add server by scanning QR code</Text>
</Pressable>
) : effectiveDropdownMode === "session" && activeServer?.status === "online" ? (
<View style={styles.sessionMenuActions}>
{activeSession ? (
<Pressable
onPress={handleCreateSessionLikeCurrent}
disabled={isCreatingSession}
style={({ pressed }) => [
styles.serverRow,
styles.sessionMenuActionRow,
isCreatingSession && styles.sessionMenuActionButtonDisabled,
pressed && styles.clearButtonPressed,
]}
>
<View style={styles.sessionMenuActionInner}>
<View style={styles.sessionMenuActionIconSlot}>
<SymbolView
name={{ ios: "folder.badge.plus", android: "create_new_folder", web: "create_new_folder" }}
size={12}
tintColor="#9BA3B5"
/>
</View>
<Text style={styles.sessionMenuActionText}>
{sessionCreateMode === "same" ? "Creating workspace session..." : "New session with workspace"}
</Text>
</View>
</Pressable>
) : null}
<Pressable
onPress={handleCreateRootSession}
disabled={isCreatingSession}
style={({ pressed }) => [
styles.serverRow,
styles.sessionMenuActionRow,
styles.serverRowLast,
isCreatingSession && styles.sessionMenuActionButtonDisabled,
pressed && styles.clearButtonPressed,
]}
>
<View style={styles.sessionMenuActionInner}>
<View style={styles.sessionMenuActionIconSlot}>
<SymbolView name={{ ios: "plus", android: "add", web: "add" }} size={12} tintColor="#9BA3B5" />
</View>
<Text style={styles.sessionMenuActionText}>
{sessionCreateMode === "root" ? "Creating new session..." : "New session"}
</Text>
</View>
</Pressable>
</View>
) : null}
</Animated.View>
</Animated.View>
@@ -2219,7 +2381,91 @@ export default function DictationScreen() {
{/* Transcription area */}
<View style={styles.transcriptionArea}>
{shouldShowAgentStateCard ? (
{hasPendingPermission && activePermissionCard ? (
<View style={[styles.splitCard, styles.permissionCard]}>
<View style={styles.permissionHeaderRow}>
<View style={styles.permissionStatusDot} />
<View style={styles.permissionHeaderCopy}>
<Text style={styles.replyCardLabel}>Permission</Text>
<Text style={styles.permissionStatusText}>
{isReplyingToActivePermission
? monitorStatus || "Sending decision…"
: pendingPermissionCount > 1
? `${pendingPermissionCount} requests pending`
: "Action needed"}
</Text>
</View>
</View>
<ScrollView style={styles.permissionScroll} contentContainerStyle={styles.permissionContent}>
<Text style={styles.permissionEyebrow}>{activePermissionCard.eyebrow}</Text>
<Text style={styles.permissionTitle}>{activePermissionCard.title}</Text>
<Text style={styles.permissionBody}>{activePermissionCard.body}</Text>
{activePermissionCard.sections.map((section, index) => (
<View
key={`permission-section-${section.label}-${index}`}
style={[
styles.permissionSection,
index === activePermissionCard.sections.length - 1 && styles.permissionSectionLast,
]}
>
<Text style={styles.permissionSectionLabel}>{section.label}</Text>
<Text style={[styles.permissionSectionText, section.mono && styles.permissionSectionTextMono]}>
{section.text}
</Text>
</View>
))}
</ScrollView>
<View style={styles.permissionFooter}>
<Pressable
onPress={() => handlePermissionDecision("once")}
disabled={isReplyingToActivePermission}
style={({ pressed }) => [
styles.permissionPrimaryButton,
isReplyingToActivePermission && styles.permissionActionDisabled,
pressed && styles.clearButtonPressed,
]}
>
{isReplyingToActivePermission ? (
<ActivityIndicator color="#FFFFFF" size="small" />
) : (
<Text style={styles.permissionPrimaryButtonText}>Allow once</Text>
)}
</Pressable>
<View style={styles.permissionSecondaryRow}>
{activePermissionRequest.always.length > 0 ? (
<Pressable
onPress={() => handlePermissionDecision("always")}
disabled={isReplyingToActivePermission}
style={({ pressed }) => [
styles.permissionSecondaryButton,
isReplyingToActivePermission && styles.permissionActionDisabled,
pressed && styles.clearButtonPressed,
]}
>
<Text style={styles.permissionSecondaryButtonText}>Always allow</Text>
</Pressable>
) : null}
<Pressable
onPress={() => handlePermissionDecision("reject")}
disabled={isReplyingToActivePermission}
style={({ pressed }) => [
styles.permissionRejectButton,
activePermissionRequest.always.length === 0 && styles.permissionRejectButtonWide,
isReplyingToActivePermission && styles.permissionActionDisabled,
pressed && styles.clearButtonPressed,
]}
>
<Text style={styles.permissionRejectButtonText}>Reject</Text>
</Pressable>
</View>
</View>
</View>
) : shouldShowAgentStateCard ? (
<View style={styles.splitCardStack}>
<View style={[styles.splitCard, styles.replyCard]}>
<View style={styles.agentStateHeaderRow}>
@@ -2282,9 +2528,9 @@ export default function DictationScreen() {
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
>
<Animated.View style={animatedTranscriptSendStyle}>
{transcribedText ? (
<Text style={styles.transcriptionText}>{transcribedText}</Text>
) : (
{displayedTranscript ? (
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
) : isSending ? null : (
<Text style={styles.placeholderText}>Your transcription will appear here</Text>
)}
</Animated.View>
@@ -2342,9 +2588,9 @@ export default function DictationScreen() {
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
>
<Animated.View style={animatedTranscriptSendStyle}>
{transcribedText ? (
<Text style={styles.transcriptionText}>{transcribedText}</Text>
) : (
{displayedTranscript ? (
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
) : isSending ? null : (
<Text style={styles.placeholderText}>Your transcription will appear here</Text>
)}
</Animated.View>
@@ -2367,60 +2613,61 @@ export default function DictationScreen() {
)}
</View>
{/* Record button */}
<View style={styles.controlsRow} onLayout={handleControlsLayout}>
<Pressable
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={!permissionGranted || modelLoading}
style={[styles.recordPressable, !permissionGranted && styles.recordButtonDisabled]}
>
<View style={styles.recordButton}>
{isTranscribingBulk ? (
<View style={styles.recordBusyCenter}>
<ActivityIndicator color="#FF2E3F" size="small" />
</View>
) : modelLoadingState !== "ready" ? (
<>
<View
style={[
styles.loadFill,
modelLoadingState === "loading" && styles.loadFillPending,
{ width: modelLoadingState === "downloading" ? `${Math.max(pct, 3)}%` : "100%" },
]}
/>
<View style={styles.loadOverlay} pointerEvents="none">
<Text style={styles.loadText}>
{modelLoadingState === "downloading"
? `Downloading ${loadingModelLabel} ${pct}%`
: `Loading ${loadingModelLabel}`}
</Text>
</View>
</>
) : (
<>
<Animated.View style={[styles.recordBorder, animatedBorderStyle]} pointerEvents="none" />
<Animated.View style={[styles.recordDot, animatedDotStyle]} />
</>
)}
</View>
</Pressable>
<Animated.View style={[styles.sendSlot, animatedSendStyle]} pointerEvents={shouldShowSend ? "auto" : "none"}>
{hasPendingPermission ? null : (
<View style={styles.controlsRow} onLayout={handleControlsLayout}>
<Pressable
onPress={handleSendTranscript}
style={({ pressed }) => [
styles.sendButton,
(isSending || !hasTranscript || !canSendToSession) && styles.sendButtonDisabled,
pressed && styles.clearButtonPressed,
]}
disabled={isSending || !hasTranscript || !canSendToSession}
hitSlop={8}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={!permissionGranted || modelLoading}
style={[styles.recordPressable, !permissionGranted && styles.recordButtonDisabled]}
>
<Text style={styles.sendIcon}></Text>
<View style={styles.recordButton}>
{isTranscribingBulk ? (
<View style={styles.recordBusyCenter}>
<ActivityIndicator color="#FF2E3F" size="small" />
</View>
) : modelLoadingState !== "ready" ? (
<>
<View
style={[
styles.loadFill,
modelLoadingState === "loading" && styles.loadFillPending,
{ width: modelLoadingState === "downloading" ? `${Math.max(pct, 3)}%` : "100%" },
]}
/>
<View style={styles.loadOverlay} pointerEvents="none">
<Text style={styles.loadText}>
{modelLoadingState === "downloading"
? `Downloading ${loadingModelLabel} ${pct}%`
: `Loading ${loadingModelLabel}`}
</Text>
</View>
</>
) : (
<>
<Animated.View style={[styles.recordBorder, animatedBorderStyle]} pointerEvents="none" />
<Animated.View style={[styles.recordDot, animatedDotStyle]} />
</>
)}
</View>
</Pressable>
</Animated.View>
</View>
<Animated.View style={[styles.sendSlot, animatedSendStyle]} pointerEvents={shouldShowSend ? "auto" : "none"}>
<Pressable
onPress={handleSendTranscript}
style={({ pressed }) => [
styles.sendButton,
(isSending || !hasTranscript || !canSendToSession) && styles.sendButtonDisabled,
pressed && styles.clearButtonPressed,
]}
disabled={isSending || !hasTranscript || !canSendToSession}
hitSlop={8}
>
<Text style={styles.sendIcon}></Text>
</Pressable>
</Animated.View>
</View>
)}
<Modal
visible={whisperSettingsOpen}
@@ -2464,55 +2711,42 @@ export default function DictationScreen() {
<Text style={styles.settingsTextRowValue}>{WHISPER_MODEL_LABELS[defaultWhisperModel]}</Text>
</View>
<Pressable
onPress={() => setTranscriptionMode("bulk")}
disabled={isRecording || isTranscribingBulk}
style={({ pressed }) => [
styles.settingsTextRow,
(isRecording || isTranscribingBulk) && styles.settingsInlinePressableDisabled,
pressed && styles.clearButtonPressed,
]}
>
<View style={styles.settingsTextRow}>
<View style={styles.settingsOptionCopy}>
<Text style={styles.settingsTextRowTitle}>On Release</Text>
<Text style={styles.settingsTextRowMeta}>Transcribe after release</Text>
<Text style={styles.settingsTextRowTitle}>Realtime dictation</Text>
<Text style={styles.settingsTextRowMeta}>Turn off to transcribe after release</Text>
</View>
<Text
style={[
styles.settingsTextRowAction,
transcriptionMode === "bulk" && styles.settingsTextRowActionActive,
]}
>
{transcriptionMode === "bulk" ? "Selected" : "Use"}
</Text>
</Pressable>
<Switch
value={transcriptionMode === "realtime"}
onValueChange={(enabled) => setTranscriptionMode(enabled ? "realtime" : "bulk")}
disabled={dictationSettingsLocked}
trackColor={{ false: "#2D2D31", true: "#6A3A33" }}
thumbColor={transcriptionMode === "realtime" ? "#FF6B56" : "#F2F2F2"}
ios_backgroundColor="#2D2D31"
/>
</View>
<Pressable
onPress={() => setTranscriptionMode("realtime")}
disabled={isRecording || isTranscribingBulk}
style={({ pressed }) => [
styles.settingsTextRow,
(isRecording || isTranscribingBulk) && styles.settingsInlinePressableDisabled,
pressed && styles.clearButtonPressed,
]}
>
<View style={styles.settingsTextRow}>
<View style={styles.settingsOptionCopy}>
<Text style={styles.settingsTextRowTitle}>Realtime</Text>
<Text style={styles.settingsTextRowMeta}>Transcribe while you speak</Text>
<Text style={styles.settingsTextRowTitle}>Auto send on dictation end</Text>
<Text style={styles.settingsTextRowMeta}>Send the transcript as soon as recording finishes</Text>
</View>
<Text
style={[
styles.settingsTextRowAction,
transcriptionMode === "realtime" && styles.settingsTextRowActionActive,
]}
>
{transcriptionMode === "realtime" ? "Selected" : "Use"}
</Text>
</Pressable>
<Switch
value={autoSendOnDictationEnd}
onValueChange={setAutoSendOnDictationEnd}
disabled={dictationSettingsLocked}
trackColor={{ false: "#2D2D31", true: "#6A3A33" }}
thumbColor={autoSendOnDictationEnd ? "#FF6B56" : "#F2F2F2"}
ios_backgroundColor="#2D2D31"
/>
</View>
</View>
<View style={styles.settingsSection}>
<Text style={styles.settingsSectionLabel}>MODELS:</Text>
<View style={styles.settingsTextRow}>
<Text style={styles.settingsMutedText}>Mobile devices currently support models up to `medium`.</Text>
</View>
{WHISPER_MODELS.map((modelID) => {
const installed = installedWhisperModels.includes(modelID)
const isDefault = defaultWhisperModel === modelID
@@ -2995,6 +3229,35 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: "600",
},
sessionMenuActions: {
marginTop: 2,
borderTopWidth: 1,
borderTopColor: "#222733",
},
sessionMenuActionRow: {
paddingVertical: 9,
},
sessionMenuActionInner: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: 10,
},
sessionMenuActionIconSlot: {
width: 9,
height: 9,
alignItems: "center",
justifyContent: "center",
},
sessionMenuActionButtonDisabled: {
opacity: 0.55,
},
sessionMenuActionText: {
flex: 1,
color: "#D6DAE4",
fontSize: 14,
fontWeight: "500",
},
statusLeft: {
flexDirection: "row",
alignItems: "center",
@@ -3060,6 +3323,152 @@ const styles = StyleSheet.create({
replyCard: {
paddingTop: 16,
},
permissionCard: {
paddingTop: 16,
},
permissionHeaderRow: {
flexDirection: "row",
alignItems: "center",
gap: 10,
marginHorizontal: 20,
marginBottom: 12,
},
permissionHeaderCopy: {
flex: 1,
gap: 2,
},
permissionStatusDot: {
width: 10,
height: 10,
borderRadius: 999,
backgroundColor: "#FFB347",
},
permissionEyebrow: {
color: "#FFB347",
fontSize: 11,
fontWeight: "800",
letterSpacing: 1.1,
},
permissionStatusText: {
color: "#9099AA",
fontSize: 13,
fontWeight: "600",
},
permissionScroll: {
flex: 1,
},
permissionContent: {
paddingHorizontal: 20,
paddingBottom: 20,
gap: 14,
},
permissionTitle: {
color: "#F7F8FB",
fontSize: 30,
fontWeight: "800",
lineHeight: 36,
letterSpacing: -0.7,
},
permissionBody: {
color: "#B2BDCF",
fontSize: 17,
fontWeight: "500",
lineHeight: 24,
},
permissionSection: {
gap: 6,
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: "#242424",
},
permissionSectionLast: {
borderBottomWidth: 0,
},
permissionSectionLabel: {
color: "#7F8798",
fontSize: 11,
fontWeight: "700",
letterSpacing: 0.9,
textTransform: "uppercase",
},
permissionSectionText: {
color: "#E7E7E7",
fontSize: 14,
fontWeight: "500",
lineHeight: 20,
},
permissionSectionTextMono: {
fontFamily: Platform.select({ ios: "Menlo", android: "monospace", web: "monospace" }),
fontSize: 12,
lineHeight: 18,
color: "#D4D7DE",
},
permissionFooter: {
gap: 10,
paddingHorizontal: 20,
paddingBottom: 18,
paddingTop: 8,
borderTopWidth: 1,
borderTopColor: "#21252F",
},
permissionPrimaryButton: {
minHeight: 54,
borderRadius: 16,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#1D6FF4",
borderWidth: 2,
borderColor: "#1557C3",
paddingHorizontal: 16,
},
permissionPrimaryButtonText: {
color: "#FFFFFF",
fontSize: 16,
fontWeight: "800",
letterSpacing: 0.2,
},
permissionSecondaryRow: {
flexDirection: "row",
gap: 10,
},
permissionSecondaryButton: {
flex: 1,
minHeight: 48,
borderRadius: 14,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#1C1E22",
borderWidth: 1,
borderColor: "#32353D",
paddingHorizontal: 12,
},
permissionSecondaryButtonText: {
color: "#E0E3EA",
fontSize: 14,
fontWeight: "700",
},
permissionRejectButton: {
flex: 1,
minHeight: 48,
borderRadius: 14,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#31181C",
borderWidth: 1,
borderColor: "#5E2B34",
paddingHorizontal: 12,
},
permissionRejectButtonWide: {
flex: 1,
},
permissionRejectButtonText: {
color: "#FFCCD2",
fontSize: 14,
fontWeight: "700",
},
permissionActionDisabled: {
opacity: 0.6,
},
transcriptionPanel: {
flex: 1,
position: "relative",
@@ -3282,6 +3691,9 @@ const styles = StyleSheet.create({
borderBottomColor: "#242424",
paddingVertical: 10,
},
settingsToggleRow: {
alignItems: "flex-start",
},
settingsMutedText: {
color: "#868686",
fontSize: 12,
@@ -3318,6 +3730,38 @@ const styles = StyleSheet.create({
settingsTextRowActionActive: {
color: "#FFD8D2",
},
settingsModeToggle: {
flexDirection: "row",
backgroundColor: "#17181B",
borderWidth: 1,
borderColor: "#292A2E",
borderRadius: 14,
padding: 4,
gap: 4,
alignSelf: "stretch",
},
settingsModeToggleOption: {
flex: 1,
minHeight: 40,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 12,
},
settingsModeToggleOptionActive: {
backgroundColor: "#3F201B",
},
settingsModeToggleOptionPressed: {
opacity: 0.82,
},
settingsModeToggleText: {
color: "#9A9A9A",
fontSize: 13,
fontWeight: "700",
},
settingsModeToggleTextActive: {
color: "#FFF0EC",
},
settingsInlineRow: {
flexDirection: "row",
alignItems: "center",

View File

@@ -21,6 +21,11 @@ import {
type OpenCodeEvent,
type MonitorEventType,
} from "@/lib/opencode-events"
import {
parsePendingPermissionRequest,
parsePendingPermissionRequests,
type PendingPermissionRequest,
} from "@/lib/pending-permissions"
import { registerRelayDevice, unregisterRelayDevice } from "@/lib/relay-client"
import { parseSSEStream } from "@/lib/sse"
import { getDevicePushToken, onPushTokenChange } from "@/notifications/monitoring-notifications"
@@ -33,6 +38,8 @@ export type MonitorJob = {
startedAt: number
}
export type PermissionDecision = "once" | "always" | "reject"
type SessionRuntimeStatus = "idle" | "busy" | "retry"
type PermissionPromptState = "idle" | "pending" | "granted" | "denied"
@@ -114,6 +121,8 @@ export function useMonitoring({
const [monitorJob, setMonitorJob] = useState<MonitorJob | null>(null)
const [monitorStatus, setMonitorStatus] = useState("")
const [latestAssistantResponse, setLatestAssistantResponse] = useState("")
const [pendingPermissions, setPendingPermissions] = useState<PendingPermissionRequest[]>([])
const [replyingPermissionID, setReplyingPermissionID] = useState<string | null>(null)
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
const foregroundMonitorAbortRef = useRef<AbortController | null>(null)
@@ -127,6 +136,19 @@ export function useMonitoring({
const previousPushTokenRef = useRef<string | null>(null)
const previousAppStateRef = useRef<AppStateStatus>(AppState.currentState)
const latestAssistantRequestRef = useRef(0)
const latestPermissionRequestRef = useRef(0)
const upsertPendingPermission = useCallback(
(request: PendingPermissionRequest) => {
setPendingPermissions((current) => {
const next = current.filter((item) => item.id !== request.id)
return [request, ...next]
})
closeDropdown()
setAgentStateDismissed(false)
},
[closeDropdown, setAgentStateDismissed],
)
useEffect(() => {
monitorJobRef.current = monitorJob
@@ -243,6 +265,38 @@ export function useMonitoring({
[activeSessionIdRef, setAgentStateDismissed],
)
const loadPendingPermissions = useCallback(
async (baseURL: string, sessionID: string) => {
const requestID = latestPermissionRequestRef.current + 1
latestPermissionRequestRef.current = requestID
const base = baseURL.replace(/\/+$/, "")
try {
const response = await fetch(`${base}/permission`)
if (!response.ok) {
throw new Error(`Permission list failed (${response.status})`)
}
const payload = (await response.json()) as unknown
const requests = parsePendingPermissionRequests(payload).filter((item) => item.sessionID === sessionID)
if (latestPermissionRequestRef.current !== requestID) return
if (activeSessionIdRef.current !== sessionID) return
setPendingPermissions(requests)
if (requests.length > 0) {
closeDropdown()
setAgentStateDismissed(false)
}
} catch {
if (latestPermissionRequestRef.current !== requestID) return
if (activeSessionIdRef.current !== sessionID) return
}
},
[activeSessionIdRef, closeDropdown, setAgentStateDismissed],
)
const fetchSessionRuntimeStatus = useCallback(
async (baseURL: string, sessionID: string): Promise<SessionRuntimeStatus | null> => {
const base = baseURL.replace(/\/+$/, "")
@@ -278,6 +332,7 @@ export function useMonitoring({
if (eventType === "permission") {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(() => {})
void loadPendingPermissions(job.opencodeBaseURL, job.sessionID)
return
}
@@ -295,7 +350,7 @@ export function useMonitoring({
stopForegroundMonitor()
setMonitorJob(null)
},
[completePlayer, loadLatestAssistantResponse, stopForegroundMonitor],
[completePlayer, loadLatestAssistantResponse, loadPendingPermissions, stopForegroundMonitor],
)
const startForegroundMonitor = useCallback(
@@ -333,6 +388,13 @@ export function useMonitoring({
const sessionID = extractSessionID(parsed)
if (sessionID !== job.sessionID) continue
if (parsed.type === "permission.asked") {
const request = parsePendingPermissionRequest(parsed.properties)
if (request) {
upsertPendingPermission(request)
}
}
const eventType = classifyMonitorEvent(parsed)
if (!eventType) continue
@@ -345,7 +407,7 @@ export function useMonitoring({
}
})()
},
[handleMonitorEvent, stopForegroundMonitor],
[handleMonitorEvent, stopForegroundMonitor, upsertPendingPermission],
)
const beginMonitoring = useCallback(
@@ -381,13 +443,22 @@ export function useMonitoring({
useEffect(() => {
setLatestAssistantResponse("")
setPendingPermissions([])
setAgentStateDismissed(false)
if (!activeServerId || !activeSessionId) return
const server = serversRef.current.find((item) => item.id === activeServerId)
if (!server || server.status !== "online") return
void loadLatestAssistantResponse(server.url, activeSessionId)
}, [activeServerId, activeSessionId, loadLatestAssistantResponse, serversRef, setAgentStateDismissed])
void loadPendingPermissions(server.url, activeSessionId)
}, [
activeServerId,
activeSessionId,
loadLatestAssistantResponse,
loadPendingPermissions,
serversRef,
setAgentStateDismissed,
])
useEffect(() => {
return () => {
@@ -404,6 +475,7 @@ export function useMonitoring({
const runtimeStatus = await fetchSessionRuntimeStatus(server.url, input.sessionID)
await loadLatestAssistantResponse(server.url, input.sessionID)
await loadPendingPermissions(server.url, input.sessionID)
if (runtimeStatus === "busy" || runtimeStatus === "retry") {
const nextJob: MonitorJob = {
@@ -433,6 +505,7 @@ export function useMonitoring({
appState,
fetchSessionRuntimeStatus,
loadLatestAssistantResponse,
loadPendingPermissions,
refreshServerStatusAndSessions,
serversRef,
startForegroundMonitor,
@@ -548,6 +621,62 @@ export function useMonitoring({
void syncSessionState({ serverID, sessionID })
}, [activeServerIdRef, activeSessionIdRef, appState, syncSessionState])
const respondToPermission = useCallback(
async (input: { serverID: string; sessionID: string; requestID: string; reply: PermissionDecision }) => {
const server = serversRef.current.find((item) => item.id === input.serverID)
if (!server) {
throw new Error("Server unavailable")
}
const base = server.url.replace(/\/+$/, "")
setReplyingPermissionID(input.requestID)
setMonitorStatus(input.reply === "reject" ? "Rejecting request…" : "Sending approval…")
let removed: PendingPermissionRequest | undefined
setPendingPermissions((current) => {
removed = current.find((item) => item.id === input.requestID)
return current.filter((item) => item.id !== input.requestID)
})
try {
const response = await fetch(`${base}/permission/${input.requestID}/reply`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ reply: input.reply }),
})
if (!response.ok) {
throw new Error(`Permission reply failed (${response.status})`)
}
await syncSessionState({
serverID: input.serverID,
sessionID: input.sessionID,
})
} catch (error) {
if (removed) {
setPendingPermissions((current) => {
const restored = removed
if (!restored) {
return current
}
if (current.some((item) => item.id === restored.id)) {
return current
}
return [restored, ...current]
})
}
throw error
} finally {
setReplyingPermissionID((current) => (current === input.requestID ? null : current))
}
},
[serversRef, syncSessionState],
)
const activePermissionRequest = pendingPermissions[0] ?? null
const relayServersKey = useMemo(
() =>
servers
@@ -658,6 +787,10 @@ export function useMonitoring({
monitorStatus,
setMonitorStatus,
latestAssistantResponse,
activePermissionRequest,
pendingPermissionCount: pendingPermissions.length,
respondingPermissionID: replyingPermissionID,
respondToPermission,
beginMonitoring,
}
}

View File

@@ -320,6 +320,113 @@ export function useServerSessions() {
[refreshServerStatusAndSessions],
)
const createSession = useCallback(
async (
serverID: string,
options?: {
directory?: string
workspaceID?: string
title?: string
},
) => {
const server = serversRef.current.find((item) => item.id === serverID)
if (!server) {
return null
}
const base = server.url.replace(/\/+$/, "")
const params = new URLSearchParams()
const directory = options?.directory?.trim()
const workspaceID = options?.workspaceID?.trim()
const title = options?.title?.trim()
if (directory) {
params.set("directory", directory)
}
const body: {
workspaceID?: string
title?: string
} = {}
if (workspaceID) {
body.workspaceID = workspaceID
}
if (title) {
body.title = title
}
const query = params.toString()
const endpoint = `${base}/session${query ? `?${query}` : ""}`
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
if (!response.ok) {
console.log("[Server] session:create:http-error", {
id: server.id,
endpoint,
status: response.status,
})
return null
}
const payload = (await response.json()) as unknown
const parsed = parseSessionItems([payload])[0]
if (!parsed) {
void refreshServerStatusAndSessions(serverID)
return null
}
const created = parsed.updated > 0 ? parsed : { ...parsed, updated: Date.now() }
setServers((prev) =>
prev.map((item) => {
if (item.id !== serverID) return item
const sessions = [created, ...item.sessions.filter((session) => session.id !== created.id)].sort(
(a, b) => b.updated - a.updated,
)
return {
...item,
status: "online",
sessionsLoading: false,
sessions,
}
}),
)
setActiveServerId(serverID)
setActiveSessionId(created.id)
console.log("[Server] session:create", {
id: server.id,
sessionID: created.id,
hasDirectory: Boolean(created.directory),
hasWorkspaceID: Boolean(created.workspaceID),
})
return created
} catch (err) {
console.log("[Server] session:create:error", {
id: server.id,
endpoint,
error: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
})
return null
}
},
[refreshServerStatusAndSessions],
)
const findServerForSession = useCallback(
async (sessionID: string, preferredServerID?: string | null): Promise<ServerItem | null> => {
if (!serversRef.current.length && !restoredRef.current) {
@@ -381,6 +488,7 @@ export function useServerSessions() {
selectSession,
removeServer,
addServer,
createSession,
findServerForSession,
}
}

View File

@@ -1,76 +1,81 @@
export type OpenCodeEvent = {
type: string;
properties?: Record<string, unknown>;
};
type: string
properties?: Record<string, unknown>
}
export type MonitorEventType = 'complete' | 'permission' | 'error';
export type MonitorEventType = "complete" | "permission" | "error"
export function extractSessionID(event: OpenCodeEvent): string | null {
const props = event.properties ?? {};
const props = event.properties ?? {}
const fromDirect = props.sessionID;
if (typeof fromDirect === 'string' && fromDirect.length > 0) return fromDirect;
const fromDirect = props.sessionID
if (typeof fromDirect === "string" && fromDirect.length > 0) return fromDirect
const info = props.info;
if (info && typeof info === 'object') {
const infoSessionID = (info as Record<string, unknown>).sessionID;
if (typeof infoSessionID === 'string' && infoSessionID.length > 0) return infoSessionID;
const info = props.info
if (info && typeof info === "object") {
const infoSessionID = (info as Record<string, unknown>).sessionID
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
}
const part = props.part;
if (part && typeof part === 'object') {
const partSessionID = (part as Record<string, unknown>).sessionID;
if (typeof partSessionID === 'string' && partSessionID.length > 0) return partSessionID;
const part = props.part
if (part && typeof part === "object") {
const partSessionID = (part as Record<string, unknown>).sessionID
if (typeof partSessionID === "string" && partSessionID.length > 0) return partSessionID
}
return null;
return null
}
export function classifyMonitorEvent(event: OpenCodeEvent): MonitorEventType | null {
const type = event.type;
const lowerType = type.toLowerCase();
const type = event.type
const lowerType = type.toLowerCase()
if (lowerType.includes('permission')) {
return 'permission';
if (lowerType === "permission.asked" || lowerType === "permission") {
return "permission"
}
if (lowerType.includes('error')) {
return 'error';
if (lowerType.includes("error")) {
return "error"
}
if (type === 'session.status') {
const status = event.properties?.status;
if (status && typeof status === 'object') {
const statusType = (status as Record<string, unknown>).type;
if (statusType === 'idle') {
return 'complete';
if (type === "session.status") {
const status = event.properties?.status
if (status && typeof status === "object") {
const statusType = (status as Record<string, unknown>).type
if (statusType === "idle") {
return "complete"
}
}
}
if (type === 'message.updated') {
const info = event.properties?.info;
if (info && typeof info === 'object') {
const role = (info as Record<string, unknown>).role;
const time = (info as Record<string, unknown>).time;
if (role === 'assistant' && time && typeof time === 'object' && 'completed' in (time as Record<string, unknown>)) {
return 'complete';
if (type === "message.updated") {
const info = event.properties?.info
if (info && typeof info === "object") {
const role = (info as Record<string, unknown>).role
const time = (info as Record<string, unknown>).time
if (
role === "assistant" &&
time &&
typeof time === "object" &&
"completed" in (time as Record<string, unknown>)
) {
return "complete"
}
}
}
return null;
return null
}
export function formatMonitorEventLabel(eventType: MonitorEventType): string {
switch (eventType) {
case 'complete':
return 'Session complete';
case 'permission':
return 'Action needed';
case 'error':
return 'Session error';
case "complete":
return "Session complete"
case "permission":
return "Action needed"
case "error":
return "Session error"
default:
return 'Session update';
return "Session update"
}
}

View File

@@ -0,0 +1,256 @@
export type PendingPermissionRequest = {
id: string
sessionID: string
permission: string
patterns: string[]
metadata: Record<string, unknown>
always: string[]
tool?: {
messageID: string
callID: string
}
}
export type PermissionCardSection = {
label: string
text: string
mono?: boolean
}
export type PermissionCardModel = {
eyebrow: string
title: string
body: string
sections: PermissionCardSection[]
}
function record(input: unknown): Record<string, unknown> | null {
if (!input || typeof input !== "object") return null
return input as Record<string, unknown>
}
function maybeString(input: unknown): string | undefined {
return typeof input === "string" && input.trim().length > 0 ? input : undefined
}
function stringList(input: unknown): string[] {
if (!Array.isArray(input)) return []
return input.filter((item): item is string => typeof item === "string" && item.length > 0)
}
function previewText(input: string, options?: { maxLines?: number; maxChars?: number }): string {
const maxLines = options?.maxLines ?? 18
const maxChars = options?.maxChars ?? 1200
const normalized = input.replace(/\r\n/g, "\n").trim()
if (!normalized) return ""
const lines = normalized.split("\n")
const sliced = lines.slice(0, maxLines)
let text = sliced.join("\n")
if (text.length > maxChars) {
text = `${text.slice(0, maxChars).trimEnd()}\n…`
} else if (lines.length > maxLines) {
text = `${text}\n…`
}
return text
}
function formatPath(input: string): string {
return input.replace(/\\/g, "/")
}
function permissionTool(input: unknown): PendingPermissionRequest["tool"] | undefined {
const value = record(input)
if (!value) return
const messageID = maybeString(value.messageID)
const callID = maybeString(value.callID)
if (!messageID || !callID) return
return {
messageID,
callID,
}
}
function parsePendingPermissionRequest(input: unknown): PendingPermissionRequest | null {
const value = record(input)
if (!value) return null
const id = maybeString(value.id)
const sessionID = maybeString(value.sessionID)
const permission = maybeString(value.permission)
if (!id || !sessionID || !permission) return null
return {
id,
sessionID,
permission,
patterns: stringList(value.patterns),
metadata: record(value.metadata) ?? {},
always: stringList(value.always),
tool: permissionTool(value.tool),
}
}
export { parsePendingPermissionRequest }
export function parsePendingPermissionRequests(payload: unknown): PendingPermissionRequest[] {
if (!Array.isArray(payload)) return []
return payload
.map((item) => parsePendingPermissionRequest(item))
.filter((item): item is PendingPermissionRequest => item !== null)
}
function firstPattern(request: PendingPermissionRequest): string | undefined {
return request.patterns.find((item) => item.trim().length > 0)
}
function externalDirectory(request: PendingPermissionRequest): string | undefined {
const fromMetadata = maybeString(request.metadata.parentDir) ?? maybeString(request.metadata.filepath)
if (fromMetadata) return fromMetadata
const pattern = firstPattern(request)
if (!pattern) return
return pattern.endsWith("/*") ? pattern.slice(0, -2) : pattern
}
function allowScopeSection(request: PendingPermissionRequest): PermissionCardSection | null {
if (request.always.length === 0) return null
if (
request.always.length === request.patterns.length &&
request.always.every((item, index) => item === request.patterns[index])
) {
return null
}
if (request.always.length === 1 && request.always[0] === "*") {
return {
label: "Always allow",
text: "Applies to all future requests of this permission until OpenCode restarts.",
}
}
return {
label: "Always allow scope",
text: previewText(request.always.join("\n"), { maxLines: 8, maxChars: 600 }),
mono: true,
}
}
export function buildPermissionCardModel(request: PendingPermissionRequest): PermissionCardModel {
const filepath = maybeString(request.metadata.filepath)
const diff = maybeString(request.metadata.diff)
const commandText = previewText(request.patterns.join("\n"), { maxLines: 6, maxChars: 700 })
const scope = allowScopeSection(request)
if (request.permission === "edit") {
const sections: PermissionCardSection[] = []
if (filepath) {
sections.push({ label: "File", text: formatPath(filepath), mono: true })
}
if (diff) {
sections.push({ label: "Diff preview", text: previewText(diff), mono: true })
}
if (scope) sections.push(scope)
return {
eyebrow: "EDIT",
title: "Allow file edit?",
body: "OpenCode wants to change a file in this session.",
sections,
}
}
if (request.permission === "bash") {
const sections: PermissionCardSection[] = []
if (commandText) {
sections.push({ label: "Command", text: commandText, mono: true })
}
if (scope) sections.push(scope)
return {
eyebrow: "BASH",
title: "Allow shell command?",
body: "OpenCode wants to run a shell command for this session.",
sections,
}
}
if (request.permission === "read") {
const sections: PermissionCardSection[] = []
const path = firstPattern(request)
if (path) {
sections.push({ label: "Path", text: formatPath(path), mono: true })
}
if (scope) sections.push(scope)
return {
eyebrow: "READ",
title: "Allow file read?",
body: "OpenCode wants to read a path from your machine.",
sections,
}
}
if (request.permission === "external_directory") {
const sections: PermissionCardSection[] = []
const dir = externalDirectory(request)
if (dir) {
sections.push({ label: "Directory", text: formatPath(dir), mono: true })
}
if (request.patterns.length > 0) {
sections.push({
label: "Patterns",
text: previewText(request.patterns.join("\n"), { maxLines: 8, maxChars: 600 }),
mono: true,
})
}
if (scope) sections.push(scope)
return {
eyebrow: "DIRECTORY",
title: "Allow external access?",
body: "OpenCode wants to work with files outside the current project directory.",
sections,
}
}
if (request.permission === "task") {
const sections: PermissionCardSection[] = []
if (request.patterns.length > 0) {
sections.push({
label: "Patterns",
text: previewText(request.patterns.join("\n"), { maxLines: 8, maxChars: 600 }),
mono: true,
})
}
if (scope) sections.push(scope)
return {
eyebrow: "TASK",
title: "Allow delegated task?",
body: "OpenCode wants to launch another task as part of this session.",
sections,
}
}
const sections: PermissionCardSection[] = []
if (request.patterns.length > 0) {
sections.push({
label: "Patterns",
text: previewText(request.patterns.join("\n"), { maxLines: 8, maxChars: 600 }),
mono: true,
})
}
if (scope) sections.push(scope)
return {
eyebrow: request.permission.toUpperCase(),
title: `Allow ${request.permission}?`,
body: "OpenCode needs your permission before it can continue this session.",
sections,
}
}

View File

@@ -8,11 +8,17 @@ export type SessionItem = {
id: string
title: string
updated: number
directory?: string
workspaceID?: string
projectID?: string
}
type ServerSessionPayload = {
id?: unknown
title?: unknown
directory?: unknown
workspaceID?: unknown
projectID?: unknown
time?: {
updated?: unknown
}
@@ -50,11 +56,21 @@ export function parseSessionItems(payload: unknown): SessionItem[] {
return payload
.filter((item): item is ServerSessionPayload => !!item && typeof item === "object")
.map((item) => ({
id: String(item.id ?? ""),
title: String(item.title ?? item.id ?? "Untitled session"),
updated: Number(item.time?.updated ?? 0),
}))
.map((item) => {
const directory = typeof item.directory === "string" && item.directory.length > 0 ? item.directory : undefined
const workspaceID =
typeof item.workspaceID === "string" && item.workspaceID.length > 0 ? item.workspaceID : undefined
const projectID = typeof item.projectID === "string" && item.projectID.length > 0 ? item.projectID : undefined
return {
id: String(item.id ?? ""),
title: String(item.title ?? item.id ?? "Untitled session"),
updated: Number(item.time?.updated ?? 0),
directory,
workspaceID,
projectID,
}
})
.filter((item) => item.id.length > 0)
.sort((a, b) => b.updated - a.updated)
}