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.
pull/19545/head
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,7 +2613,7 @@ export default function DictationScreen() {
)}
</View>
{/* Record button */}
{hasPendingPermission ? null : (
<View style={styles.controlsRow} onLayout={handleControlsLayout}>
<Pressable
onPressIn={handlePressIn}
@ -2421,6 +2667,7 @@ export default function DictationScreen() {
</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>
<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>
<Text
style={[
styles.settingsTextRowAction,
transcriptionMode === "bulk" && styles.settingsTextRowActionActive,
]}
>
{transcriptionMode === "bulk" ? "Selected" : "Use"}
</Text>
</Pressable>
<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>
<Switch
value={autoSendOnDictationEnd}
onValueChange={setAutoSendOnDictationEnd}
disabled={dictationSettingsLocked}
trackColor={{ false: "#2D2D31", true: "#6A3A33" }}
thumbColor={autoSendOnDictationEnd ? "#FF6B56" : "#F2F2F2"}
ios_backgroundColor="#2D2D31"
/>
</View>
<Text
style={[
styles.settingsTextRowAction,
transcriptionMode === "realtime" && styles.settingsTextRowActionActive,
]}
>
{transcriptionMode === "realtime" ? "Selected" : "Use"}
</Text>
</Pressable>
</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) => ({
.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)
}