mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 06:45:22 +00:00
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:
578
packages/mobile-voice/ios/mobilevoice.xcodeproj/project.pbxproj
Normal file
578
packages/mobile-voice/ios/mobilevoice.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
92
packages/mobile-voice/ios/mobilevoice/Info.plist
Normal file
92
packages/mobile-voice/ios/mobilevoice/Info.plist
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
256
packages/mobile-voice/src/lib/pending-permissions.ts
Normal file
256
packages/mobile-voice/src/lib/pending-permissions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user