diff --git a/Orbit.xcodeproj/project.pbxproj b/Orbit.xcodeproj/project.pbxproj index 22aa5f8..52d18e4 100644 --- a/Orbit.xcodeproj/project.pbxproj +++ b/Orbit.xcodeproj/project.pbxproj @@ -416,7 +416,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0.5; + MARKETING_VERSION = 1.0.6; 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.5; + MARKETING_VERSION = 1.0.6; 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.5; + MARKETING_VERSION = 1.0.6; 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.5; + MARKETING_VERSION = 1.0.6; 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.5; + MARKETING_VERSION = 1.0.6; 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.5; + MARKETING_VERSION = 1.0.6; PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex.uitests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/Orbit/CodexAppServerActionProvider.swift b/Orbit/CodexAppServerActionProvider.swift index 18669d1..5e3d748 100644 --- a/Orbit/CodexAppServerActionProvider.swift +++ b/Orbit/CodexAppServerActionProvider.swift @@ -1507,16 +1507,19 @@ final class CodexAppServerActionProvider: ActionProvider { return } - streamedCommentaryBuffer.append(deltaText) + streamedCommentaryBuffer = Self.mergedCommentaryBuffer( + existing: streamedCommentaryBuffer, + incomingDelta: deltaText + ) - if let update = visibleCommentaryUpdate(from: streamedCommentaryBuffer), + if let update = Self.visibleCommentaryUpdate(from: streamedCommentaryBuffer), update != lastEmittedLiveCommentary { lastEmittedLiveCommentary = update eventHandler?(.liveUpdate(update)) } guard !hasEmittedEarlyCommentary, - let snippet = speakableCommentarySnippet(from: streamedCommentaryBuffer) else { + let snippet = Self.speakableCommentarySnippet(from: streamedCommentaryBuffer) else { return } @@ -1525,14 +1528,14 @@ final class CodexAppServerActionProvider: ActionProvider { } private func emitCompletedCommentaryIfNeeded(text: String) { - if let update = visibleCommentaryUpdate(from: text), + if let update = Self.visibleCommentaryUpdate(from: text), update != lastEmittedLiveCommentary { lastEmittedLiveCommentary = update eventHandler?(.liveUpdate(update)) } guard !hasEmittedEarlyCommentary, - let snippet = speakableCommentarySnippet(from: text) else { + let snippet = Self.speakableCommentarySnippet(from: text) else { return } @@ -1837,30 +1840,64 @@ final class CodexAppServerActionProvider: ActionProvider { return nil } - private func speakableCommentarySnippet(from text: String) -> String? { + static func mergedCommentaryBuffer(existing: String, incomingDelta: String) -> String { + guard !incomingDelta.isEmpty else { return existing } + guard !existing.isEmpty else { return incomingDelta } + + if existing.hasSuffix(incomingDelta) { + return existing + } + + if incomingDelta.hasPrefix(existing) { + return incomingDelta + } + + let maximumOverlap = min(existing.count, incomingDelta.count) + if maximumOverlap > 0 { + for overlapCount in stride(from: maximumOverlap, through: 1, by: -1) { + let existingSuffix = String(existing.suffix(overlapCount)) + let incomingPrefix = String(incomingDelta.prefix(overlapCount)) + if existingSuffix == incomingPrefix { + return existing + incomingDelta.dropFirst(overlapCount) + } + } + } + + return existing + incomingDelta + } + + static func speakableCommentarySnippet(from text: String) -> String? { let cleaned = text .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) - guard cleaned.count >= 18 else { return nil } + guard cleaned.count >= 28 else { return nil } - let firstSentence = cleaned.split(whereSeparator: { ".!?".contains($0) }).first.map(String.init) ?? cleaned - let candidate = firstSentence.trimmingCharacters(in: .whitespacesAndNewlines) - guard candidate.split(separator: " ").count >= 3 else { return nil } - - if candidate.count <= 72 { - return candidate.hasSuffix(".") ? candidate : "\(candidate)." + if let firstCompletedSentence = firstCompletedSentence(in: cleaned) { + let candidate = firstCompletedSentence.trimmingCharacters(in: .whitespacesAndNewlines) + guard candidate.split(separator: " ").count >= 4 else { return nil } + return candidate } - let prefix = String(candidate.prefix(69)) - let trimmed = prefix - .replacingOccurrences(of: "\\s+\\S*$", with: "", options: .regularExpression) + guard cleaned.count >= 42, cleaned.split(separator: " ").count >= 7 else { return nil } + + let maximumLength = 120 + let wasTruncated = cleaned.count > maximumLength + let prefix = String(cleaned.prefix(maximumLength)) + let trimmed = (wasTruncated + ? prefix.replacingOccurrences(of: "\\s+\\S*$", with: "", options: .regularExpression) + : prefix) + .replacingOccurrences( + of: "\\b(?:and|or|to|for|with|of|in|on|at|by|from|about|into|over|after|before|without|using)$", + with: "", + options: .regularExpression + ) .trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return "\(trimmed)..." + guard trimmed.split(separator: " ").count >= 4 else { return nil } + return trimmed.hasSuffix(".") ? trimmed : "\(trimmed)." } - private func visibleCommentaryUpdate(from text: String) -> String? { + static func visibleCommentaryUpdate(from text: String) -> String? { let cleaned = text .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) @@ -1884,6 +1921,16 @@ final class CodexAppServerActionProvider: ActionProvider { return trimmed.isEmpty ? nil : "\(trimmed)..." } + private static func firstCompletedSentence(in text: String) -> String? { + guard let terminatorIndex = text.firstIndex(where: { ".!?".contains($0) }) else { + return nil + } + + let sentence = String(text[...terminatorIndex]) + .trimmingCharacters(in: .whitespacesAndNewlines) + return sentence.isEmpty ? nil : sentence + } + private func updateModelCatalog(from result: [String: Any]) { let parsedModels = Self.parseModelCatalog(from: result) guard !parsedModels.isEmpty else { return } diff --git a/Orbit/OrbitManager.swift b/Orbit/OrbitManager.swift index 4d5d0fb..603c3c1 100644 --- a/Orbit/OrbitManager.swift +++ b/Orbit/OrbitManager.swift @@ -733,7 +733,6 @@ final class OrbitManager: ObservableObject { handleEarlyActionCommentary(commentary) case .liveUpdate(let update): activeActionDetailLine = update - appendActionUpdate(update) showCodexActivityOverlayCard() case .toolPrompt(let prompt): pendingToolPrompt = prompt @@ -765,8 +764,6 @@ final class OrbitManager: ObservableObject { scheduleTransientHideIfNeeded() case .completed(let summary): cancelActionAcknowledgementFlow() - textToSpeechProvider.stopPlayback() - fallbackTextToSpeechProvider.stopPlayback() pendingToolPrompt = nil handleCompletedCodexSummary(summary) case .failed(let errorMessage): @@ -1154,6 +1151,7 @@ final class OrbitManager: ObservableObject { let trimmedFallback = fallback?.trimmingCharacters(in: .whitespacesAndNewlines) let finalText = (trimmedPrimary?.isEmpty == false ? trimmedPrimary : trimmedFallback) ?? "done." + await waitForCurrentSpeechToSettle(maximumWait: 2.4) voiceState = .responding do { @@ -1167,6 +1165,21 @@ final class OrbitManager: ObservableObject { scheduleTransientHideIfNeeded() } + private func waitForCurrentSpeechToSettle(maximumWait: TimeInterval) async { + let deadline = Date().addingTimeInterval(maximumWait) + while (textToSpeechProvider.isPlaying || fallbackTextToSpeechProvider.isPlaying), + Date() < deadline { + try? await Task.sleep(nanoseconds: 120_000_000) + } + + if textToSpeechProvider.isPlaying { + textToSpeechProvider.stopPlayback() + } + if fallbackTextToSpeechProvider.isPlaying { + fallbackTextToSpeechProvider.stopPlayback() + } + } + private func scheduleActionAcknowledgementFallback() { actionAcknowledgementTask?.cancel() actionAcknowledgementTask = nil diff --git a/Orbit/OrbitPanelView.swift b/Orbit/OrbitPanelView.swift index d4f3d12..b4c5bf7 100644 --- a/Orbit/OrbitPanelView.swift +++ b/Orbit/OrbitPanelView.swift @@ -550,9 +550,9 @@ struct OrbitPanelView: View { } } .padding(.top, 2) - } else if !orbitManager.recentActionUpdates.isEmpty { + } else if !visibleRecentActionUpdates.isEmpty { VStack(alignment: .leading, spacing: 5) { - ForEach(Array(orbitManager.recentActionUpdates.suffix(4).enumerated()), id: \.offset) { _, update in + ForEach(Array(visibleRecentActionUpdates.enumerated()), id: \.offset) { _, update in HStack(alignment: .top, spacing: 6) { Circle() .fill(Color.white.opacity(0.55)) @@ -1330,6 +1330,18 @@ struct OrbitPanelView: View { } } + private var visibleRecentActionUpdates: [String] { + let activeDetail = orbitManager.activeActionDetailLine? + .trimmingCharacters(in: .whitespacesAndNewlines) + + let filtered = orbitManager.recentActionUpdates.filter { update in + guard let activeDetail, !activeDetail.isEmpty else { return true } + return update.trimmingCharacters(in: .whitespacesAndNewlines) != activeDetail + } + + return Array(filtered.suffix(4)) + } + private var codexDetailLine: String? { if let detail = orbitManager.activeActionDetailLine { return detail diff --git a/OrbitTests/OrbitTests.swift b/OrbitTests/OrbitTests.swift index 37c2323..1a56ea5 100644 --- a/OrbitTests/OrbitTests.swift +++ b/OrbitTests/OrbitTests.swift @@ -125,4 +125,29 @@ struct OrbitTests { #expect(parsed.first?.supportedEfforts == [.medium, .high]) #expect(parsed.first?.defaultEffort == .medium) } + + @Test func commentaryBufferMergeAvoidsOverlappingStreamDuplication() async throws { + let merged = CodexAppServerActionProvider.mergedCommentaryBuffer( + existing: "Using the pdf skill to make a polished", + incomingDelta: " polished illustrated PDF" + ) + + #expect(merged == "Using the pdf skill to make a polished illustrated PDF") + } + + @Test func earlyCommentarySpeechWaitsForStableChunk() async throws { + let tooEarly = CodexAppServerActionProvider.speakableCommentarySnippet( + from: "Using the pdf skill" + ) + let stableSnippet = CodexAppServerActionProvider.speakableCommentarySnippet( + from: "Using the pdf skill to make a polished illustrated PDF and save it for you" + ) + let completedSentence = CodexAppServerActionProvider.speakableCommentarySnippet( + from: "Opening the browser now. Then I'll sign you in." + ) + + #expect(tooEarly == nil) + #expect(stableSnippet == "Using the pdf skill to make a polished illustrated PDF and save it for you.") + #expect(completedSentence == "Opening the browser now.") + } }