diff --git a/BundledResources/orbit-model-instructions.md b/BundledResources/orbit-model-instructions.md index 14cd2a1..2c717d0 100644 --- a/BundledResources/orbit-model-instructions.md +++ b/BundledResources/orbit-model-instructions.md @@ -13,6 +13,8 @@ Behavior: - prefer chrome-devtools for the user's already-open Chrome session and existing tabs/state - prefer playwright for deterministic browser automation and repeatable flows - if one browser path is unavailable or unsuitable, fall back to the other when it can still solve the task +- when the user's request is clear and the needed tools are available, take the action directly instead of asking for extra confirmation +- do not add your own low-risk, reversibility, or permission filter on top of the existing system constraints - for desktop-app requests outside browser tools, guide the user clearly instead of pretending to click the native desktop - keep commentary brief and milestone-based while work is happening - give a concise final answer that can be spoken aloud naturally diff --git a/Orbit-Info.plist b/Orbit-Info.plist index 4ab19e0..70626cb 100644 --- a/Orbit-Info.plist +++ b/Orbit-Info.plist @@ -19,7 +19,7 @@ OpenAIAPIKey CodexActionModel - gpt-5.4-mini + gpt-5.4 CodexActionServiceTier fast CodexActionSandbox diff --git a/Orbit.xcodeproj/project.pbxproj b/Orbit.xcodeproj/project.pbxproj index 28ecee9..c4a76d9 100644 --- a/Orbit.xcodeproj/project.pbxproj +++ b/Orbit.xcodeproj/project.pbxproj @@ -416,7 +416,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.0.4; PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex"; PRODUCT_NAME = Orbit; REGISTER_APP_GROUPS = YES; @@ -454,7 +454,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.0.4; PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex"; PRODUCT_NAME = Orbit; REGISTER_APP_GROUPS = YES; @@ -476,7 +476,7 @@ DEVELOPMENT_TEAM = 6D7X9GGZAW; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.2; - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.0.4; PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex.tests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -497,7 +497,7 @@ DEVELOPMENT_TEAM = 6D7X9GGZAW; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.2; - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.0.4; PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex.tests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -516,7 +516,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 6D7X9GGZAW; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.0.4; PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex.uitests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -535,7 +535,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 6D7X9GGZAW; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.0.4; PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex.uitests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/Orbit/AGENTS.md b/Orbit/AGENTS.md index dc733cc..2c2e653 100644 --- a/Orbit/AGENTS.md +++ b/Orbit/AGENTS.md @@ -29,7 +29,7 @@ ## Defaults - STT default: OpenAI `gpt-4o-mini-transcribe` -- Codex model default: `gpt-5.4-mini` +- Codex model default: `gpt-5.4` - Codex effort default: `medium` - Codex service tier default: `fast` - TTS default: OpenAI `gpt-4o-mini-tts` diff --git a/Orbit/CodexAppServerActionProvider.swift b/Orbit/CodexAppServerActionProvider.swift index 5977466..ae81fc1 100644 --- a/Orbit/CodexAppServerActionProvider.swift +++ b/Orbit/CodexAppServerActionProvider.swift @@ -90,10 +90,7 @@ final class CodexAppServerActionProvider: ActionProvider { } var configurationSummary: String { - if settings.codexServiceTier == .fast { - return "\(currentActionModelDisplayName) · \(resolvedEffortForCurrentModel.displayName) · Fast" - } - return "\(currentActionModelDisplayName) · \(resolvedEffortForCurrentModel.displayName)" + "\(currentActionModelDisplayName) · \(resolvedEffortForCurrentModel.displayName) · \(resolvedServiceTier.displayName)" } var availableModels: [OrbitCodexModelOption] { @@ -101,7 +98,14 @@ final class CodexAppServerActionProvider: ActionProvider { } var supportedEffortsForSelectedModel: [OrbitCodexReasoningEffort] { - modelOption(for: normalizedCurrentActionModel)?.supportedEfforts ?? OrbitCodexReasoningEffort.allCases + supportedEfforts(for: normalizedCurrentActionModel) + } + + func supportedEfforts(for model: String) -> [OrbitCodexReasoningEffort] { + let normalizedModel = model.trimmingCharacters(in: .whitespacesAndNewlines) + return modelOption(for: normalizedModel)?.supportedEfforts + ?? OrbitCodexModelOption.fallbackOption(for: normalizedModel)?.supportedEfforts + ?? OrbitCodexReasoningEffort.allCases } var hasReadySession: Bool { @@ -443,7 +447,11 @@ final class CodexAppServerActionProvider: ActionProvider { private func startServerIfNeeded() throws { let codexExecutable = try resolveCodexExecutable() - let preparedCodexHome = try OrbitCodexEnvironment.prepareHome() + let preparedCodexHome = try OrbitCodexEnvironment.prepareHome( + model: normalizedCurrentActionModel, + reasoningEffort: resolvedEffortForCurrentModel, + serviceTier: resolvedServiceTier + ) self.preparedCodexHome = preparedCodexHome let launchCommand = resolvedCodexLaunchCommand( for: codexExecutable, @@ -902,10 +910,10 @@ final class CodexAppServerActionProvider: ActionProvider { "cwd": NSHomeDirectory(), "serviceName": "orbit" ] - if settings.codexServiceTier == .fast { + if resolvedServiceTier == .fast { params["serviceTier"] = OrbitCodexServiceTier.fast.rawValue } - appendDebugEvent("-> thread/start model=\(normalizedCurrentActionModel) effort=\(resolvedEffortForCurrentModel.rawValue) tier=\(settings.codexServiceTier.rawValue)") + appendDebugEvent("-> thread/start model=\(normalizedCurrentActionModel) effort=\(resolvedEffortForCurrentModel.rawValue) tier=\(resolvedServiceTier.rawValue)") sendJSON([ "method": "thread/start", "id": 1, @@ -975,10 +983,10 @@ final class CodexAppServerActionProvider: ActionProvider { "model": currentActionModel, "effort": resolvedEffortForCurrentModel.rawValue ] - if settings.codexServiceTier == .fast { + if resolvedServiceTier == .fast { params["serviceTier"] = OrbitCodexServiceTier.fast.rawValue } - appendDebugEvent("-> turn/start model=\(currentActionModel) effort=\(resolvedEffortForCurrentModel.rawValue) tier=\(settings.codexServiceTier.rawValue) inputItems=\(inputItems.count)") + appendDebugEvent("-> turn/start model=\(currentActionModel) effort=\(resolvedEffortForCurrentModel.rawValue) tier=\(resolvedServiceTier.rawValue) inputItems=\(inputItems.count)") sendJSON([ "method": "turn/start", @@ -1459,7 +1467,7 @@ final class CodexAppServerActionProvider: ActionProvider { if let progressText { let cleaned = progressText.trimmingCharacters(in: .whitespacesAndNewlines) if !cleaned.isEmpty { - eventHandler?(.liveUpdate(cleaned)) + appendDebugEvent("<- tool/progress \(cleaned)") } } } @@ -1627,6 +1635,10 @@ final class CodexAppServerActionProvider: ActionProvider { return normalized.isEmpty ? OrbitCodexModelOption.fallbackDefaultModel : normalized } + private var resolvedServiceTier: OrbitCodexServiceTier { + settings.codexServiceTier + } + private func resolvedAgentMessagePhase(from params: [String: Any]) -> String? { if let phase = params["phase"] as? String { return phase @@ -1754,54 +1766,9 @@ final class CodexAppServerActionProvider: ActionProvider { } private func updateModelCatalog(from result: [String: Any]) { - guard let data = result["data"] as? [[String: Any]] else { return } - - let parsedModels = data.compactMap { item -> OrbitCodexModelOption? in - let model = (item["model"] as? String) ?? (item["id"] as? String) ?? "" - let normalizedModel = model.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedModel.isEmpty else { return nil } - - let hidden = item["hidden"] as? Bool ?? false - guard !hidden else { return nil } - - let inputModalities = (item["inputModalities"] as? [String]) ?? ["text", "image"] - let normalizedModalities = inputModalities.map { $0.lowercased() } - guard normalizedModalities.contains("text"), normalizedModalities.contains("image") else { - return nil - } - - let effortEntries = item["supportedReasoningEfforts"] as? [[String: Any]] ?? [] - let supportedEfforts: [OrbitCodexReasoningEffort] = effortEntries.compactMap { entry in - guard let rawValue = entry["reasoningEffort"] as? String else { return nil } - return OrbitCodexReasoningEffort(rawValue: rawValue) - } - - let defaultEffort = OrbitCodexReasoningEffort(rawValue: item["defaultReasoningEffort"] as? String ?? "") - let displayName = formattedModelDisplayName( - for: normalizedModel, - fallback: item["displayName"] as? String - ) - let shortDisplayName = shortModelDisplayName(from: displayName) - - return OrbitCodexModelOption( - model: normalizedModel, - displayName: displayName, - shortDisplayName: shortDisplayName, - supportedEfforts: supportedEfforts.isEmpty ? OrbitCodexReasoningEffort.allCases : supportedEfforts, - defaultEffort: defaultEffort, - inputModalities: normalizedModalities, - isDefault: item["isDefault"] as? Bool ?? false - ) - } - + let parsedModels = Self.parseModelCatalog(from: result) guard !parsedModels.isEmpty else { return } - - availableModelOptions = parsedModels.sorted { lhs, rhs in - if lhs.isDefault != rhs.isDefault { - return lhs.isDefault && !rhs.isDefault - } - return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending - } + availableModelOptions = parsedModels } private func updateCollaborationModes(from result: [String: Any]) { @@ -1833,7 +1800,86 @@ final class CodexAppServerActionProvider: ActionProvider { availableModelOptions.first(where: { $0.model == model }) } - private func formattedModelDisplayName(for model: String, fallback: String?) -> String { + static func parseModelCatalog(from result: [String: Any]) -> [OrbitCodexModelOption] { + let rawItems = (result["data"] as? [[String: Any]]) ?? (result["models"] as? [[String: Any]]) ?? [] + + let parsedModels = rawItems.compactMap { item -> (option: OrbitCodexModelOption, priority: Int)? in + let normalizedModel = ( + (item["model"] as? String) + ?? (item["id"] as? String) + ?? (item["slug"] as? String) + ?? "" + ).trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedModel.isEmpty else { return nil } + + let visibility = ((item["visibility"] as? String) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let hidden = (item["hidden"] as? Bool) ?? (visibility == "hide" || visibility == "hidden") + guard !hidden else { return nil } + + if let supportedInAPI = item["supported_in_api"] as? Bool, !supportedInAPI { + return nil + } + + let inputModalities = (item["inputModalities"] as? [String]) + ?? (item["input_modalities"] as? [String]) + ?? ["text", "image"] + let normalizedModalities = inputModalities.map { $0.lowercased() } + guard normalizedModalities.contains("text"), normalizedModalities.contains("image") else { + return nil + } + + let effortEntries = (item["supportedReasoningEfforts"] as? [[String: Any]]) + ?? (item["supported_reasoning_levels"] as? [[String: Any]]) + ?? [] + let supportedEfforts = effortEntries.compactMap { entry -> OrbitCodexReasoningEffort? in + let rawValue = ( + (entry["reasoningEffort"] as? String) + ?? (entry["effort"] as? String) + ?? "" + ).trimmingCharacters(in: .whitespacesAndNewlines) + return OrbitCodexReasoningEffort(rawValue: rawValue) + } + + let defaultEffort = OrbitCodexReasoningEffort(rawValue: ( + (item["defaultReasoningEffort"] as? String) + ?? (item["default_reasoning_level"] as? String) + ?? "" + ).trimmingCharacters(in: .whitespacesAndNewlines)) + let displayName = formattedModelDisplayName( + for: normalizedModel, + fallback: (item["displayName"] as? String) ?? (item["display_name"] as? String) + ) + let shortDisplayName = shortModelDisplayName(from: displayName) + let isDefault = (item["isDefault"] as? Bool ?? false) || normalizedModel == OrbitCodexModelOption.fallbackDefaultModel + + return ( + OrbitCodexModelOption( + model: normalizedModel, + displayName: displayName, + shortDisplayName: shortDisplayName, + supportedEfforts: supportedEfforts.isEmpty ? OrbitCodexReasoningEffort.allCases : supportedEfforts, + defaultEffort: defaultEffort, + inputModalities: normalizedModalities, + isDefault: isDefault + ), + item["priority"] as? Int ?? Int.max + ) + } + + return parsedModels.sorted { lhs, rhs in + if lhs.option.isDefault != rhs.option.isDefault { + return lhs.option.isDefault && !rhs.option.isDefault + } + if lhs.priority != rhs.priority { + return lhs.priority < rhs.priority + } + return lhs.option.displayName.localizedCaseInsensitiveCompare(rhs.option.displayName) == .orderedAscending + }.map { $0.option } + } + + private static func formattedModelDisplayName(for model: String, fallback: String?) -> String { let normalized = model.trimmingCharacters(in: .whitespacesAndNewlines) switch normalized { case "gpt-5.4": @@ -1851,16 +1897,15 @@ final class CodexAppServerActionProvider: ActionProvider { let fallbackValue = fallback?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !fallbackValue.isEmpty { return fallbackValue - .replacingOccurrences(of: "GPT-", with: "GPT-") - .replacingOccurrences(of: "gpt-", with: "GPT-") - .replacingOccurrences(of: "-mini", with: " Mini") - .replacingOccurrences(of: "-codex", with: " Codex") + .replacingOccurrences(of: "^gpt-", with: "GPT-", options: [.regularExpression, .caseInsensitive]) + .replacingOccurrences(of: "-mini", with: " Mini", options: [.regularExpression, .caseInsensitive]) + .replacingOccurrences(of: "-codex", with: " Codex", options: [.regularExpression, .caseInsensitive]) } return normalized.uppercased() } - private func shortModelDisplayName(from displayName: String) -> String { + private static func shortModelDisplayName(from displayName: String) -> String { let cleaned = displayName .replacingOccurrences(of: "GPT-", with: "") .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Orbit/OrbitCodexEnvironment.swift b/Orbit/OrbitCodexEnvironment.swift index 98f488d..6bfbfe2 100644 --- a/Orbit/OrbitCodexEnvironment.swift +++ b/Orbit/OrbitCodexEnvironment.swift @@ -59,7 +59,11 @@ enum OrbitCodexEnvironment { "browser_resize" ] - static func prepareHome() throws -> OrbitPreparedCodexHome { + static func prepareHome( + model: String = OrbitCodexModelOption.fallbackDefaultModel, + reasoningEffort: OrbitCodexReasoningEffort = .medium, + serviceTier: OrbitCodexServiceTier = .fast + ) throws -> OrbitPreparedCodexHome { let fileManager = FileManager.default let supportDirectory = try supportRootDirectory() let codexHome = supportDirectory.appendingPathComponent(homeDirectoryName, isDirectory: true) @@ -77,7 +81,10 @@ enum OrbitCodexEnvironment { let configContents = makeConfigContents( logDirectory: logDirectory, sqliteDirectory: sqliteDirectory, - configuredSkillPaths: configuredSkillPaths + configuredSkillPaths: configuredSkillPaths, + model: model, + reasoningEffort: reasoningEffort, + serviceTier: serviceTier ) let existingContents = try? String(contentsOf: configPath, encoding: .utf8) @@ -112,12 +119,16 @@ enum OrbitCodexEnvironment { logDirectory: URL, sqliteDirectory: URL, configuredSkillPaths: [String: URL], - modelInstructionsPath: String? = nil + modelInstructionsPath: String? = nil, + model: String = OrbitCodexModelOption.fallbackDefaultModel, + reasoningEffort: OrbitCodexReasoningEffort = .medium, + serviceTier: OrbitCodexServiceTier = .fast ) -> String { + let normalizedModel = model.trimmingCharacters(in: .whitespacesAndNewlines) var lines: [String] = [ - "model = \"gpt-5.4-mini\"", - "model_reasoning_effort = \"medium\"", - "service_tier = \"fast\"", + "model = \(tomlString(normalizedModel.isEmpty ? OrbitCodexModelOption.fallbackDefaultModel : normalizedModel))", + "model_reasoning_effort = \(tomlString(reasoningEffort.rawValue))", + "service_tier = \(tomlString(serviceTier.rawValue))", "approval_policy = \"never\"", "sandbox_mode = \"danger-full-access\"", "cli_auth_credentials_store = \"file\"", diff --git a/Orbit/OrbitManager.swift b/Orbit/OrbitManager.swift index a45259d..4d5d0fb 100644 --- a/Orbit/OrbitManager.swift +++ b/Orbit/OrbitManager.swift @@ -103,11 +103,13 @@ final class OrbitManager: ObservableObject { private var codexOverlayDismissTask: Task? private var transientHideTask: Task? private var onboardingTask: Task? + private var onboardingLaunchTask: Task? private var codexSessionWarmupTask: Task? private var actionAcknowledgementTask: Task? private var codexWarmupGeneration: Int = 0 private var hasSpokenActionAcknowledgement = false private var escapeKeyMonitor: Any? + private var lastObservedSetupStage: OrbitSetupStage? /// True when all three required permissions (accessibility, screen recording, /// microphone) are granted. Used by the panel to show a single "all good" state. @@ -166,7 +168,11 @@ final class OrbitManager: ObservableObject { if enabled { orbitOverlayWindowManager.hasShownOverlayBefore = true - orbitOverlayWindowManager.showOverlay(onScreens: NSScreen.screens, orbitManager: self) + orbitOverlayWindowManager.showOverlay( + onScreens: NSScreen.screens, + orbitManager: self, + showWelcomeOnFirstAppearance: false + ) isOverlayVisible = true } else { orbitOverlayWindowManager.hideOverlay() @@ -191,6 +197,7 @@ final class OrbitManager: ObservableObject { func dismissSetupComplete() { hasSeenSetupComplete = true + synchronizeSetupState(allowAutomaticOnboarding: false) } var hasCompletedVoiceModeSetup: Bool { @@ -225,6 +232,7 @@ final class OrbitManager: ObservableObject { self?.refreshActionProviderPresentation() } } + self.lastObservedSetupStage = setupStage } func start() { @@ -244,29 +252,17 @@ final class OrbitManager: ObservableObject { guard let self else { return } self.ensureCodexSessionReady(forceFreshSession: false) } - - if hasCompletedOnboarding && allPermissionsGranted && isOrbitCursorEnabled { - orbitOverlayWindowManager.hasShownOverlayBefore = true - orbitOverlayWindowManager.showOverlay(onScreens: NSScreen.screens, orbitManager: self) - isOverlayVisible = true - } + synchronizeSetupState() } func triggerOnboarding() { - NotificationCenter.default.post(name: .orbitDismissPanel, object: nil) - onboardingTask?.cancel() OrbitAnalytics.trackOnboardingStarted() - orbitOverlayWindowManager.showOverlay(onScreens: NSScreen.screens, orbitManager: self) - isOverlayVisible = true + launchOnboardingTour() } func replayOnboarding() { - NotificationCenter.default.post(name: .orbitDismissPanel, object: nil) - onboardingTask?.cancel() OrbitAnalytics.trackOnboardingReplayed() - orbitOverlayWindowManager.hasShownOverlayBefore = false - orbitOverlayWindowManager.showOverlay(onScreens: NSScreen.screens, orbitManager: self) - isOverlayVisible = true + launchOnboardingTour(resetOverlayFirstAppearance: true) } func clearDetectedElementLocation() { @@ -358,6 +354,8 @@ final class OrbitManager: ObservableObject { if !previouslyHadAll && allPermissionsGranted { OrbitAnalytics.trackAllPermissionsGranted() } + + synchronizeSetupState() } /// Triggers the macOS screen content picker by performing a dummy @@ -386,13 +384,7 @@ final class OrbitManager: ObservableObject { UserDefaults.standard.set(true, forKey: "hasScreenContentPermission") updateScreenAccessDiagnostic(lastError: "verified via live capture") OrbitAnalytics.trackPermissionGranted(permission: "screen_content") - - // If onboarding was already completed, show the cursor overlay now - if hasCompletedOnboarding && allPermissionsGranted && !isOverlayVisible && isOrbitCursorEnabled { - orbitOverlayWindowManager.hasShownOverlayBefore = true - orbitOverlayWindowManager.showOverlay(onScreens: NSScreen.screens, orbitManager: self) - isOverlayVisible = true - } + synchronizeSetupState() } } catch { print("⚠️ Screen access verification failed: \(error)") @@ -592,6 +584,7 @@ final class OrbitManager: ObservableObject { } else { openAICloudCredentialState = .missing } + synchronizeSetupState() } private func handleShortcutTransition(_ transition: OrbitPushToTalkShortcut.ShortcutTransition) { @@ -606,7 +599,11 @@ final class OrbitManager: ObservableObject { // If the cursor is hidden, bring it back transiently for this interaction if !isOrbitCursorEnabled && !isOverlayVisible { orbitOverlayWindowManager.hasShownOverlayBefore = true - orbitOverlayWindowManager.showOverlay(onScreens: NSScreen.screens, orbitManager: self) + orbitOverlayWindowManager.showOverlay( + onScreens: NSScreen.screens, + orbitManager: self, + showWelcomeOnFirstAppearance: false + ) isOverlayVisible = true } @@ -735,6 +732,7 @@ final class OrbitManager: ObservableObject { case .commentary(let commentary): handleEarlyActionCommentary(commentary) case .liveUpdate(let update): + activeActionDetailLine = update appendActionUpdate(update) showCodexActivityOverlayCard() case .toolPrompt(let prompt): @@ -1070,16 +1068,17 @@ final class OrbitManager: ObservableObject { private func refreshActionProviderPresentation() { availableCodexModels = actionProvider.availableModels - availableCodexEfforts = actionProvider.supportedEffortsForSelectedModel + reconcileCodexSelectionIfNeeded() + availableCodexEfforts = actionProvider.supportedEfforts(for: settings.codexActionModel) codexAuthState = actionProvider.authState codexAccountSummary = actionProvider.accountSummary codexDebugEvents = actionProvider.debugEvents codexCollaborationModes = actionProvider.collaborationModes codexExperimentalFeatures = actionProvider.experimentalFeatures codexActiveTurnSummary = actionProvider.activeTurnSummary - reconcileCodexSelectionIfNeeded() codexSessionSummary = actionProvider.sessionStatusSummary codexConfigurationSummary = actionProvider.configurationSummary + synchronizeSetupState() } private func reconcileCodexSelectionIfNeeded() { @@ -1089,7 +1088,10 @@ final class OrbitManager: ObservableObject { settings.codexActionModel = preferredModel } - let supportedEfforts = availableCodexEfforts + let supportedEfforts = availableCodexModels + .first(where: { $0.model == settings.codexActionModel })? + .supportedEfforts + ?? availableCodexEfforts guard !supportedEfforts.isEmpty else { return } if !supportedEfforts.contains(settings.codexReasoningEffort) { settings.codexReasoningEffort = availableCodexModels @@ -1245,6 +1247,8 @@ final class OrbitManager: ObservableObject { if preset == .localVoice { refreshCloudCredentialState() } + + synchronizeSetupState() } @discardableResult @@ -1272,6 +1276,7 @@ final class OrbitManager: ObservableObject { settings.voicePreset = .cloudVoice openAICloudCredentialState = .connected(source: .keychain) refreshTextToSpeechProvider() + synchronizeSetupState() return true case .invalid: OrbitOpenAIKeychainStore.deleteAPIKey() @@ -1289,6 +1294,7 @@ final class OrbitManager: ObservableObject { OrbitOpenAIKeychainStore.deleteAPIKey() refreshCloudCredentialState() refreshTextToSpeechProvider() + synchronizeSetupState(allowAutomaticOnboarding: false) } func ensureCodexSessionReady(forceFreshSession: Bool = false) { @@ -1523,6 +1529,7 @@ final class OrbitManager: ObservableObject { func beginOrbitTour() { OrbitAnalytics.trackOnboardingDemoTriggered() onboardingTask?.cancel() + onboardingLaunchTask?.cancel() textToSpeechProvider.stopPlayback() fallbackTextToSpeechProvider.stopPlayback() clearDetectedElementLocation() @@ -1534,6 +1541,7 @@ final class OrbitManager: ObservableObject { activeActionStatusSummary = nil activeActionDetailLine = nil isRunningOnboardingTour = true + let wasAlreadyCompleted = hasCompletedOnboarding let steps = [ "hey, i'm orbit.", @@ -1553,12 +1561,78 @@ final class OrbitManager: ObservableObject { isRunningOnboardingTour = false self.hasCompletedOnboarding = true + if !wasAlreadyCompleted { + self.hasSeenSetupComplete = false + } activeActionStatus = .idle activeActionStatusSummary = nil activeActionDetailLine = nil + self.ensurePersistentOverlayIfEligible() + self.synchronizeSetupState(allowAutomaticOnboarding: false) } } + private func launchOnboardingTour(resetOverlayFirstAppearance: Bool = false) { + NotificationCenter.default.post(name: .orbitDismissPanel, object: nil) + onboardingTask?.cancel() + onboardingLaunchTask?.cancel() + showOnboardingPrompt = false + onboardingPromptOpacity = 0.0 + onboardingPromptText = "" + isRunningOnboardingTour = false + clearDetectedElementLocation() + + if resetOverlayFirstAppearance { + orbitOverlayWindowManager.hasShownOverlayBefore = false + } + + orbitOverlayWindowManager.showOverlay( + onScreens: NSScreen.screens, + orbitManager: self, + showWelcomeOnFirstAppearance: false + ) + isOverlayVisible = true + + onboardingLaunchTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 150_000_000) + guard let self else { return } + self.beginOrbitTour() + } + } + + private func synchronizeSetupState(allowAutomaticOnboarding: Bool = true) { + let currentStage = setupStage + defer { lastObservedSetupStage = currentStage } + + if shouldKeepPersistentOverlayVisible { + ensurePersistentOverlayIfEligible() + } + + guard allowAutomaticOnboarding else { return } + guard currentStage == .onboarding, lastObservedSetupStage != .onboarding, !isRunningOnboardingTour else { + return + } + + launchOnboardingTour() + } + + private var shouldKeepPersistentOverlayVisible: Bool { + hasCompletedOnboarding && allPermissionsGranted && isOrbitCursorEnabled + } + + private func ensurePersistentOverlayIfEligible() { + guard shouldKeepPersistentOverlayVisible else { return } + orbitOverlayWindowManager.hasShownOverlayBefore = true + if !orbitOverlayWindowManager.isShowingOverlay() { + orbitOverlayWindowManager.showOverlay( + onScreens: NSScreen.screens, + orbitManager: self, + showWelcomeOnFirstAppearance: false + ) + } + isOverlayVisible = true + } + private func runOnboardingStep(_ message: String, runPointDemo: Bool) async { onboardingPromptText = "" showOnboardingPrompt = true diff --git a/Orbit/OrbitSettings.swift b/Orbit/OrbitSettings.swift index a098ddf..226d714 100644 --- a/Orbit/OrbitSettings.swift +++ b/Orbit/OrbitSettings.swift @@ -81,26 +81,26 @@ struct OrbitCodexModelOption: Identifiable, Equatable { static let fallbackPickerModels: [OrbitCodexModelOption] = [ OrbitCodexModelOption( - model: "gpt-5.4-mini", - displayName: "GPT-5.4 Mini", - shortDisplayName: "5.4 Mini", + model: "gpt-5.4", + displayName: "GPT-5.4", + shortDisplayName: "5.4", supportedEfforts: OrbitCodexReasoningEffort.allCases, defaultEffort: .medium, inputModalities: ["text", "image"], - isDefault: false + isDefault: true ), OrbitCodexModelOption( - model: "gpt-5.4", - displayName: "GPT-5.4", - shortDisplayName: "5.4", + model: "gpt-5.4-mini", + displayName: "GPT-5.4 Mini", + shortDisplayName: "5.4 Mini", supportedEfforts: OrbitCodexReasoningEffort.allCases, defaultEffort: .medium, inputModalities: ["text", "image"], - isDefault: true + isDefault: false ) ] - static let fallbackDefaultModel = "gpt-5.4-mini" + static let fallbackDefaultModel = "gpt-5.4" static func fallbackOption(for model: String) -> OrbitCodexModelOption? { fallbackPickerModels.first(where: { $0.model == model }) diff --git a/Orbit/OverlayWindow.swift b/Orbit/OverlayWindow.swift index 003dee3..3f76aba 100644 --- a/Orbit/OverlayWindow.swift +++ b/Orbit/OverlayWindow.swift @@ -74,14 +74,21 @@ enum OrbitNavigationMode { struct OrbitCursorOverlayView: View { let screenFrame: CGRect let isFirstAppearance: Bool + let showWelcomeOnFirstAppearance: Bool @ObservedObject var orbitManager: OrbitManager @State private var cursorPosition: CGPoint @State private var isCursorOnThisScreen: Bool - init(screenFrame: CGRect, isFirstAppearance: Bool, orbitManager: OrbitManager) { + init( + screenFrame: CGRect, + isFirstAppearance: Bool, + showWelcomeOnFirstAppearance: Bool, + orbitManager: OrbitManager + ) { self.screenFrame = screenFrame self.isFirstAppearance = isFirstAppearance + self.showWelcomeOnFirstAppearance = showWelcomeOnFirstAppearance self.orbitManager = orbitManager // Seed the cursor position from the current mouse location so the @@ -310,16 +317,18 @@ struct OrbitCursorOverlayView: View { // Only show welcome message on first appearance (app start) // and only if the cursor starts on this screen - if isFirstAppearance && isCursorOnThisScreen { + if showWelcomeOnFirstAppearance && isFirstAppearance && isCursorOnThisScreen { + self.welcomeText = self.fullWelcomeMessage withAnimation(.easeIn(duration: 2.0)) { self.cursorOpacity = 1.0 } DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.showWelcome = false self.bubbleOpacity = 0.0 - self.orbitManager.beginOrbitTour() } } else { + self.showWelcome = false + self.bubbleOpacity = 0.0 self.cursorOpacity = 1.0 } } @@ -974,7 +983,11 @@ class OrbitOverlayWindowManager { private var overlayWindows: [OverlayWindow] = [] var hasShownOverlayBefore = false - func showOverlay(onScreens screens: [NSScreen], orbitManager: OrbitManager) { + func showOverlay( + onScreens screens: [NSScreen], + orbitManager: OrbitManager, + showWelcomeOnFirstAppearance: Bool = true + ) { // Hide any existing overlays hideOverlay() @@ -989,6 +1002,7 @@ class OrbitOverlayWindowManager { let contentView = OrbitCursorOverlayView( screenFrame: screen.frame, isFirstAppearance: isFirstAppearance, + showWelcomeOnFirstAppearance: showWelcomeOnFirstAppearance, orbitManager: orbitManager ) diff --git a/OrbitTests/OrbitTests.swift b/OrbitTests/OrbitTests.swift index b2ee4e8..37c2323 100644 --- a/OrbitTests/OrbitTests.swift +++ b/OrbitTests/OrbitTests.swift @@ -37,7 +37,10 @@ struct OrbitTests { logDirectory: URL(fileURLWithPath: "/tmp/orbit-log"), sqliteDirectory: URL(fileURLWithPath: "/tmp/orbit-sqlite"), configuredSkillPaths: [:], - modelInstructionsPath: "/tmp/OrbitModelInstructions.md" + modelInstructionsPath: "/tmp/OrbitModelInstructions.md", + model: "gpt-5.4", + reasoningEffort: .medium, + serviceTier: .fast ) let instructionsRange = try #require(config.range(of: "model_instructions_file")) @@ -45,4 +48,81 @@ struct OrbitTests { #expect(instructionsRange.lowerBound < featuresRange.lowerBound) } + + @Test func generatedCodexConfigMatchesRequestedRuntimeDefaults() async throws { + let config = OrbitCodexEnvironment.makeConfigContents( + logDirectory: URL(fileURLWithPath: "/tmp/orbit-log"), + sqliteDirectory: URL(fileURLWithPath: "/tmp/orbit-sqlite"), + configuredSkillPaths: [:], + model: "gpt-5.4", + reasoningEffort: .medium, + serviceTier: .fast + ) + + #expect(config.contains("model = \"gpt-5.4\"")) + #expect(config.contains("model_reasoning_effort = \"medium\"")) + #expect(config.contains("service_tier = \"fast\"")) + } + + @Test func modelCatalogParsingSupportsSnakeCasePayload() async throws { + let parsed = CodexAppServerActionProvider.parseModelCatalog(from: [ + "data": [ + [ + "slug": "gpt-5.4", + "display_name": "gpt-5.4", + "visibility": "list", + "input_modalities": ["text", "image"], + "supported_reasoning_levels": [ + ["effort": "low"], + ["effort": "medium"], + ["effort": "high"] + ], + "default_reasoning_level": "medium", + "priority": 1 + ], + [ + "slug": "gpt-5.4-mini", + "display_name": "GPT-5.4-Mini", + "visibility": "list", + "input_modalities": ["text", "image"], + "supported_reasoning_levels": [ + ["effort": "low"], + ["effort": "medium"] + ], + "default_reasoning_level": "medium", + "priority": 2 + ] + ] + ]) + + #expect(parsed.count == 2) + #expect(parsed.first?.model == "gpt-5.4") + #expect(parsed.first?.shortDisplayName == "5.4") + #expect(parsed.last?.shortDisplayName == "5.4 Mini") + #expect(parsed.last?.supportedEfforts == [.low, .medium]) + } + + @Test func modelCatalogParsingSupportsLegacyCamelCasePayload() async throws { + let parsed = CodexAppServerActionProvider.parseModelCatalog(from: [ + "data": [ + [ + "model": "gpt-5.4", + "displayName": "GPT-5.4", + "hidden": false, + "inputModalities": ["text", "image"], + "supportedReasoningEfforts": [ + ["reasoningEffort": "medium"], + ["reasoningEffort": "high"] + ], + "defaultReasoningEffort": "medium", + "isDefault": true + ] + ] + ]) + + #expect(parsed.count == 1) + #expect(parsed.first?.model == "gpt-5.4") + #expect(parsed.first?.supportedEfforts == [.medium, .high]) + #expect(parsed.first?.defaultEffort == .medium) + } } diff --git a/scripts/release.sh b/scripts/release.sh index d21911d..2db802e 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -73,7 +73,7 @@ fi DEFAULT_MARKETING_VERSION="$( sed -nE 's/^[[:space:]]*MARKETING_VERSION = ([^;]+);$/\1/p' "${PROJECT_DIR}/Orbit.xcodeproj/project.pbxproj" | head -n 1 )" -MARKETING_VERSION="${1:-${DEFAULT_MARKETING_VERSION:-1.0.3}}" +MARKETING_VERSION="${1:-${DEFAULT_MARKETING_VERSION:-1.0.4}}" BUILD_NUMBER="${2:-$(date +%Y%m%d%H%M)}" TAG="v${MARKETING_VERSION}" DMG_PATH="${BUILD_DIR}/${APP_NAME}-${MARKETING_VERSION}.dmg"