From a8125005db8d02ab16b65bf6cea568710d753317 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Wed, 29 Apr 2026 20:45:43 -0500 Subject: [PATCH 01/22] Add dictation audio route telemetry --- .../Observability/AnalyticsEventPolicy.swift | 55 +++++- Sources/Observability/SentryEventPolicy.swift | 10 + Sources/Speech/ParakeetEngine.swift | 186 ++++++++++++++++-- Sources/Speech/STTRouter.swift | 3 + .../Overlay/DictationSessionController.swift | 85 +++++--- Tests/AnalyticsEventPolicyTests.swift | 52 ++++- Tests/SentryEventPolicyTests.swift | 10 + 7 files changed, 347 insertions(+), 54 deletions(-) diff --git a/Sources/Observability/AnalyticsEventPolicy.swift b/Sources/Observability/AnalyticsEventPolicy.swift index e2a8647a..47eaaedb 100644 --- a/Sources/Observability/AnalyticsEventPolicy.swift +++ b/Sources/Observability/AnalyticsEventPolicy.swift @@ -32,6 +32,27 @@ struct AnalyticsEventPolicy: Equatable { "voice_processing_active", ] + private static let dictationRouteDiagnosticProperties: Set = [ + "default_input_class", + "default_output_class", + "format_ready", + "hfp_suspected", + "input_channels", + "input_device_class", + "input_rate_hz", + "output_channels", + "output_device_class", + "output_rate_hz", + "recovery_latency_bucket", + "recovering", + "route_shape", + "sample_flow_started", + "selection_overrode_default", + "selection_reason", + "selected_input_class", + "was_recording", + ] + private static let allowedPolicies: [String: AnalyticsEventPolicy] = [ "app_launched": .init( name: "app_launched", @@ -297,40 +318,54 @@ struct AnalyticsEventPolicy: Equatable { ), "dictation_started": .init( name: "dictation_started", - allowedProperties: [ + allowedProperties: dictationRouteDiagnosticProperties.union(Set([ "trigger", - ] + ])) ), "dictation_start_failed": .init( name: "dictation_start_failed", - allowedProperties: [ + allowedProperties: dictationRouteDiagnosticProperties.union(Set([ "failure_kind", "trigger", - ] + ])) ), "dictation_completed": .init( name: "dictation_completed", - allowedProperties: [ + allowedProperties: dictationRouteDiagnosticProperties.union(Set([ "auto_send", "delivery", "duration_bucket", "trigger", "word_count_bucket", - ] + ])) ), "dictation_cancelled": .init( name: "dictation_cancelled", - allowedProperties: [ + allowedProperties: dictationRouteDiagnosticProperties.union(Set([ "duration_bucket", "trigger", - ] + ])) ), "dictation_no_speech": .init( name: "dictation_no_speech", - allowedProperties: [ + allowedProperties: dictationRouteDiagnosticProperties.union(Set([ "duration_bucket", "trigger", - ] + ])) + ), + "dictation_audio_route_changed": .init( + name: "dictation_audio_route_changed", + allowedProperties: dictationRouteDiagnosticProperties + ), + "dictation_audio_route_recovery_finished": .init( + name: "dictation_audio_route_recovery_finished", + allowedProperties: dictationRouteDiagnosticProperties.union(Set([ + "outcome", + ])) + ), + "dictation_audio_route_recovery_timeout": .init( + name: "dictation_audio_route_recovery_timeout", + allowedProperties: dictationRouteDiagnosticProperties ), "meeting_recording_started": .init( name: "meeting_recording_started", diff --git a/Sources/Observability/SentryEventPolicy.swift b/Sources/Observability/SentryEventPolicy.swift index fc8468b8..acf687a2 100644 --- a/Sources/Observability/SentryEventPolicy.swift +++ b/Sources/Observability/SentryEventPolicy.swift @@ -65,6 +65,16 @@ struct SentryEventPolicy: Equatable { event: "microphone_start_timeout", summary: "Dictation microphone start timed out." ), + "parakeet.device_change_recovery_timeout": .init( + engine: "parakeet", + event: "device_change_recovery_timeout", + summary: "Speech engine device-change recovery timed out." + ), + "parakeet.recording_interrupted": .init( + engine: "parakeet", + event: "recording_interrupted", + summary: "Dictation recording was interrupted by audio device recovery." + ), "meeting.meeting_start_failed": .init( engine: "meeting", event: "meeting_start_failed", diff --git a/Sources/Speech/ParakeetEngine.swift b/Sources/Speech/ParakeetEngine.swift index 13346943..1be6908b 100644 --- a/Sources/Speech/ParakeetEngine.swift +++ b/Sources/Speech/ParakeetEngine.swift @@ -421,6 +421,10 @@ class ParakeetEngine: ObservableObject { var isModelLoaded: Bool { asrManagerReady } var inputDeviceName: String { cachedInputDeviceName } + var currentAudioRouteAnalyticsContext: [String: String] { + dictationRouteAnalyticsContext(selection: Self.loadDictationInputDeviceSelection()) + } + init() { scheduleInputDeviceNameRefresh() } @@ -918,6 +922,15 @@ class ParakeetEngine: ObservableObject { cancelConfigRecoveryTimeout() let recoveryGeneration = recoveryState.beginConfigChange() publishRecoveryState() + AnalyticsReporter.track( + "dictation_audio_route_changed", + properties: dictationRouteAnalyticsContext( + selection: Self.loadDictationInputDeviceSelection(), + extra: [ + "was_recording": "\(configChangeWasRecording)" + ] + ) + ) scheduleConfigRecoveryTimeout( generation: recoveryGeneration, wasRecording: configChangeWasRecording @@ -967,6 +980,7 @@ class ParakeetEngine: ObservableObject { let shouldRestartRecording = configChangeWasRecording configChangeWasRecording = false let myGeneration = recoveryState.generation + let recoveryStartedAt = CFAbsoluteTimeGetCurrent() configRecoveryTask = Task { @MainActor [weak self] in // Wait for CoreAudio to finish settling the new device graph. @@ -1015,6 +1029,19 @@ class ParakeetEngine: ObservableObject { guard self.recoveryState.finishRecovery(success: true, generation: myGeneration) else { return } self.cancelConfigRecoveryTimeout() self.publishRecoveryState() + AnalyticsReporter.track( + "dictation_audio_route_recovery_finished", + properties: self.dictationRouteAnalyticsContext( + outputFormat: snapshot.outputFormat, + hwFormat: snapshot.hwFormat, + selection: snapshot.selection, + extra: [ + "outcome": "success", + "recovery_latency_bucket": AnalyticsReporter.durationBucket(seconds: CFAbsoluteTimeGetCurrent() - recoveryStartedAt), + "was_recording": "\(shouldRestartRecording)" + ] + ) + ) // If we were recording, try to restart on the new device. // The watchdog (via isRecoveryAttempt=false) catches silent @@ -1043,10 +1070,18 @@ class ParakeetEngine: ObservableObject { } if !restarted { self.recordingInterrupted = true - EventReporter.shared.capture(level: .warning, engine: "parakeet", + EventReporter.shared.capture(level: .error, engine: "parakeet", event: "recording_interrupted", message: "Recording could not restart after device change within retry budget", - context: ["audio_device": self.inputDeviceName]) + context: self.dictationRouteDiagnosticsContext( + outputFormat: snapshot.outputFormat, + hwFormat: snapshot.hwFormat, + selection: snapshot.selection, + extra: [ + "audio_device": self.inputDeviceName, + "reason": "recording_restart_budget_exhausted" + ] + )) } } } catch { @@ -1054,16 +1089,39 @@ class ParakeetEngine: ObservableObject { self.cancelConfigRecoveryTimeout() self.publishRecoveryState() } + AnalyticsReporter.track( + "dictation_audio_route_recovery_finished", + properties: self.dictationRouteAnalyticsContext( + selection: Self.loadDictationInputDeviceSelection(), + extra: [ + "outcome": "failed", + "recovery_latency_bucket": AnalyticsReporter.durationBucket(seconds: CFAbsoluteTimeGetCurrent() - recoveryStartedAt), + "was_recording": "\(shouldRestartRecording)" + ] + ) + ) if shouldRestartRecording { self.recordingInterrupted = true - EventReporter.shared.capture(level: .warning, engine: "parakeet", + EventReporter.shared.capture(level: .error, engine: "parakeet", event: "recording_interrupted", message: "Recording interrupted — engine rewarm failed after device change", - context: ["audio_device": self.inputDeviceName, "error": error.localizedDescription]) + context: self.dictationRouteDiagnosticsContext( + selection: Self.loadDictationInputDeviceSelection(), + extra: [ + "audio_device": self.inputDeviceName, + "error": error.localizedDescription + ] + )) } EventReporter.shared.capture(level: .error, engine: "parakeet", event: "device_change_rewarm_failed", - message: error.localizedDescription, context: ["audio_device": self.inputDeviceName]) + message: error.localizedDescription, + context: self.dictationRouteDiagnosticsContext( + selection: Self.loadDictationInputDeviceSelection(), + extra: [ + "audio_device": self.inputDeviceName + ] + )) await self.rebuildAudioEngine(reason: "device_change_rewarm_failed") self.prewarmRetryCount = 0 self.schedulePrewarmRetry() @@ -1080,29 +1138,47 @@ class ParakeetEngine: ObservableObject { self.configRecoveryTimeoutTask = nil self.publishRecoveryState() + AnalyticsReporter.track( + "dictation_audio_route_recovery_timeout", + properties: self.dictationRouteAnalyticsContext( + selection: Self.loadDictationInputDeviceSelection(), + extra: [ + "recovery_latency_bucket": AnalyticsReporter.durationBucket( + seconds: Double(TranscriptedConstants.audioDeviceRecoveryTimeout) / 1_000_000_000 + ), + "was_recording": "\(wasRecording)" + ] + ) + ) EventReporter.shared.capture( - level: .warning, + level: .error, engine: "parakeet", event: "device_change_recovery_timeout", message: "Audio device recovery timed out", - context: [ - "recovery_generation": "\(generation)", - "timeout_ms": "\(TranscriptedConstants.audioDeviceRecoveryTimeout / 1_000_000)", - "was_recording": "\(wasRecording)", - "audio_device": self.inputDeviceName - ] + context: self.dictationRouteDiagnosticsContext( + selection: Self.loadDictationInputDeviceSelection(), + extra: [ + "recovery_generation": "\(generation)", + "timeout_ms": "\(TranscriptedConstants.audioDeviceRecoveryTimeout / 1_000_000)", + "was_recording": "\(wasRecording)", + "audio_device": self.inputDeviceName + ] + ) ) if wasRecording { self.recordingInterrupted = true EventReporter.shared.capture( - level: .warning, + level: .error, engine: "parakeet", event: "recording_interrupted", message: "Recording interrupted because audio device recovery timed out", - context: [ - "audio_device": self.inputDeviceName, - "reason": "device_change_recovery_timeout" - ] + context: self.dictationRouteDiagnosticsContext( + selection: Self.loadDictationInputDeviceSelection(), + extra: [ + "audio_device": self.inputDeviceName, + "reason": "device_change_recovery_timeout" + ] + ) ) } await self.rebuildAudioEngine(reason: "device_change_recovery_timeout") @@ -1215,6 +1291,82 @@ class ParakeetEngine: ObservableObject { return context } + private func dictationRouteDiagnosticsContext( + outputFormat: ParakeetAudioFormatSummary? = nil, + hwFormat: ParakeetAudioFormatSummary? = nil, + selection: DictationInputDeviceSelection?, + extra: [String: String] = [:] + ) -> [String: String] { + var context = dictationRouteAnalyticsContext( + outputFormat: outputFormat, + hwFormat: hwFormat, + selection: selection + ) + context["recovering"] = "\(recoveryState.isRecovering)" + context["format_ready"] = "\(recoveryState.inputFormatReady)" + context["generation"] = "\(recoveryState.generation)" + + for (key, value) in extra { + context[key] = value + } + + return context + } + + private func dictationRouteAnalyticsContext( + outputFormat: ParakeetAudioFormatSummary? = nil, + hwFormat: ParakeetAudioFormatSummary? = nil, + selection: DictationInputDeviceSelection?, + extra: [String: String] = [:] + ) -> [String: String] { + let selectedClass = selectedInputClass(for: selection) + let defaultInputClass = selection.map { DictationInputDeviceSelectionPolicy.deviceClass(for: $0.defaultInput) } ?? "unknown" + let defaultOutputClass = selection?.defaultOutput.map { DictationInputDeviceSelectionPolicy.deviceClass(for: $0) } ?? "unknown" + let outputRate = outputFormat?.sampleRate + let inputRate = hwFormat?.sampleRate + + var context: [String: String] = [ + "default_input_class": defaultInputClass, + "default_output_class": defaultOutputClass, + "format_ready": "\(recoveryState.inputFormatReady)", + "hfp_suspected": "\(isLikelyBluetoothHandsFreeProfile(inputClass: selectedClass, inputRate: inputRate, outputRate: outputRate))", + "input_device_class": selectedClass, + "output_device_class": defaultOutputClass, + "recovering": "\(recoveryState.isRecovering)", + "route_shape": "\(selectedClass)_input_to_\(defaultOutputClass)_output", + "sample_flow_started": "\(didReceiveAudioSamples)", + "selection_overrode_default": "\(selection?.didOverrideDefault ?? false)", + "selection_reason": selection?.reason.rawValue ?? "unknown", + "selected_input_class": selectedClass, + ] + + if let outputFormat { + context["output_rate_hz"] = String(format: "%.0f", outputFormat.sampleRate) + context["output_channels"] = "\(outputFormat.channelCount)" + } + + if let hwFormat { + context["input_rate_hz"] = String(format: "%.0f", hwFormat.sampleRate) + context["input_channels"] = "\(hwFormat.channelCount)" + } + + for (key, value) in extra { + context[key] = value + } + + return context + } + + private func isLikelyBluetoothHandsFreeProfile( + inputClass: String, + inputRate: Double?, + outputRate: Double? + ) -> Bool { + guard inputClass == "bluetooth" else { return false } + guard let inputRate, let outputRate else { return false } + return inputRate <= 24_000 && outputRate >= 44_100 + } + private func audioInputSnapshot(operation: String) async throws -> ParakeetAudioInputSnapshot { let selection = Self.loadDictationInputDeviceSelection() if let selection, selection.didOverrideDefault { diff --git a/Sources/Speech/STTRouter.swift b/Sources/Speech/STTRouter.swift index 7e70ba58..694c1aad 100644 --- a/Sources/Speech/STTRouter.swift +++ b/Sources/Speech/STTRouter.swift @@ -28,6 +28,9 @@ class STTRouter: ObservableObject { } var inputDeviceName: String { parakeetEngine.inputDeviceName } + var dictationAudioRouteAnalyticsContext: [String: String] { + parakeetEngine.currentAudioRouteAnalyticsContext + } init() { parakeetEngine.$isRecording.assign(to: &$isRecording) diff --git a/Sources/UI/Overlay/DictationSessionController.swift b/Sources/UI/Overlay/DictationSessionController.swift index 2bd33cd3..16a6f502 100644 --- a/Sources/UI/Overlay/DictationSessionController.swift +++ b/Sources/UI/Overlay/DictationSessionController.swift @@ -189,19 +189,23 @@ class DictationSessionController: ObservableObject { ) AnalyticsReporter.track( "dictation_started", - properties: [ - "trigger": trigger.rawValue, - ] + properties: dictationAnalyticsProperties( + extra: [ + "trigger": trigger.rawValue, + ] + ) ) } private func trackDictationStartFailed(_ failureKind: String) { AnalyticsReporter.track( "dictation_start_failed", - properties: [ - "failure_kind": failureKind, - "trigger": currentDictationTrigger.rawValue, - ] + properties: dictationAnalyticsProperties( + extra: [ + "failure_kind": failureKind, + "trigger": currentDictationTrigger.rawValue, + ] + ) ) } @@ -579,16 +583,28 @@ class DictationSessionController: ObservableObject { guard let text = voiceText, !text.isEmpty else { appState.logger.log("DICTATION | no transcription, cancelling") - EventReporter.shared.capture(level: .warning, engine: "overlay", event: "no_voice_input", - message: "Dictation transcription empty") + EventReporter.shared.capture( + level: .warning, + engine: "overlay", + event: "no_voice_input", + message: "Dictation transcription empty", + context: self.dictationContext( + extra: [ + "duration_ms": "\(Int((CFAbsoluteTimeGetCurrent() - self.sessionStartTime) * 1000))", + "trigger": self.currentDictationTrigger.rawValue + ] + ) + ) AnalyticsReporter.track( "dictation_no_speech", - properties: [ - "duration_bucket": AnalyticsReporter.durationBucket( - seconds: CFAbsoluteTimeGetCurrent() - sessionStartTime - ), - "trigger": currentDictationTrigger.rawValue, - ] + properties: self.dictationAnalyticsProperties( + extra: [ + "duration_bucket": AnalyticsReporter.durationBucket( + seconds: CFAbsoluteTimeGetCurrent() - sessionStartTime + ), + "trigger": currentDictationTrigger.rawValue, + ] + ) ) NotificationCenter.default.post(name: .dictationNoSpeechDetected, object: nil) AppSoundPlayer.shared.play(.noSpeech) @@ -649,13 +665,15 @@ class DictationSessionController: ObservableObject { } AnalyticsReporter.track( "dictation_completed", - properties: [ - "delivery": pasteOutcome.delivery.rawValue, - "auto_send": autoSendOutcome.diagnosticName, - "duration_bucket": AnalyticsReporter.durationBucket(seconds: CFAbsoluteTimeGetCurrent() - sessionStartTime), - "trigger": currentDictationTrigger.rawValue, - "word_count_bucket": AnalyticsReporter.wordCountBucket(wordCount), - ] + properties: self.dictationAnalyticsProperties( + extra: [ + "delivery": pasteOutcome.delivery.rawValue, + "auto_send": autoSendOutcome.diagnosticName, + "duration_bucket": AnalyticsReporter.durationBucket(seconds: CFAbsoluteTimeGetCurrent() - sessionStartTime), + "trigger": currentDictationTrigger.rawValue, + "word_count_bucket": AnalyticsReporter.wordCountBucket(wordCount), + ] + ) ) } } @@ -686,10 +704,12 @@ class DictationSessionController: ObservableObject { } AnalyticsReporter.track( "dictation_cancelled", - properties: [ - "duration_bucket": AnalyticsReporter.durationBucket(seconds: CFAbsoluteTimeGetCurrent() - sessionStartTime), - "trigger": currentDictationTrigger.rawValue, - ] + properties: dictationAnalyticsProperties( + extra: [ + "duration_bucket": AnalyticsReporter.durationBucket(seconds: CFAbsoluteTimeGetCurrent() - sessionStartTime), + "trigger": currentDictationTrigger.rawValue, + ] + ) ) } @@ -1073,6 +1093,11 @@ class DictationSessionController: ObservableObject { var context: [String: String] = [ "audio_device": appState?.sttRouter.inputDeviceName ?? "" ] + if let routeContext = appState?.sttRouter.dictationAudioRouteAnalyticsContext { + for (key, value) in routeContext { + context[key] = value + } + } for (key, value) in extra { context[key] = value @@ -1080,6 +1105,14 @@ class DictationSessionController: ObservableObject { return context } + + private func dictationAnalyticsProperties(extra: [String: String] = [:]) -> [String: String] { + var properties = appState?.sttRouter.dictationAudioRouteAnalyticsContext ?? [:] + for (key, value) in extra { + properties[key] = value + } + return properties + } } private typealias DictationPasteOutcome = TextPasteOutcome diff --git a/Tests/AnalyticsEventPolicyTests.swift b/Tests/AnalyticsEventPolicyTests.swift index 67691d3f..bc9761ab 100644 --- a/Tests/AnalyticsEventPolicyTests.swift +++ b/Tests/AnalyticsEventPolicyTests.swift @@ -97,6 +97,9 @@ func testAnalyticsEventPolicy() { runSuite("AnalyticsEventPolicy preserves dictation auto-send attribution") { let dictationCompleted = AnalyticsEventPolicy.policy(forEvent: "dictation_completed") assertEqual(dictationCompleted?.allowedProperties.contains("auto_send"), true, "dictation completion should allow the existing auto_send property") + assertEqual(dictationCompleted?.allowedProperties.contains("input_device_class"), true, "dictation completion should preserve coarse input device class") + assertEqual(dictationCompleted?.allowedProperties.contains("hfp_suspected"), true, "dictation completion should preserve Bluetooth HFP suspicion only as a boolean") + assertEqual(dictationCompleted?.allowedProperties.contains("sample_flow_started"), true, "dictation completion should preserve whether audio samples ever flowed") } runSuite("AnalyticsEventPolicy allows dictation start failures with coarse attribution") { @@ -104,18 +107,65 @@ func testAnalyticsEventPolicy() { assertEqual(dictationStartFailed?.allowedProperties.contains("failure_kind"), true, "dictation start failures should preserve normalized failure kinds") assertEqual(dictationStartFailed?.allowedProperties.contains("trigger"), true, "dictation start failures should preserve trigger attribution") + assertEqual(dictationStartFailed?.allowedProperties.contains("route_shape"), true, "dictation start failures should preserve safe route shape") + assertEqual(dictationStartFailed?.allowedProperties.contains("selection_reason"), true, "dictation start failures should preserve coarse device-selection reason") let sanitized = AnalyticsPayloadSanitizer.sanitizeProperties( [ + "default_input_class": "bluetooth", + "default_output_class": "bluetooth", "failure_kind": "microphone_start_timeout", + "format_ready": "false", + "hfp_suspected": "true", + "input_channels": "1", + "input_device_class": "bluetooth", + "input_rate_hz": "24000", + "output_channels": "1", + "output_device_class": "bluetooth", + "output_rate_hz": "48000", + "recovering": "true", + "route_shape": "bluetooth_input_to_bluetooth_output", + "sample_flow_started": "false", + "selection_reason": "noBuiltInFallbackAvailable", "trigger": "hotkey", ], - allowedKeys: ["failure_kind", "trigger"] + allowedKeys: dictationStartFailed?.allowedProperties ?? [] ) assertEqual(sanitized["failure_kind"], "microphone_start_timeout", "dictation start failure kind should survive sanitization") + assertEqual(sanitized["input_device_class"], "bluetooth", "coarse dictation input class should survive sanitization") + assertEqual(sanitized["input_rate_hz"], "24000", "safe dictation input rate should survive sanitization") + assertEqual(sanitized["route_shape"], "bluetooth_input_to_bluetooth_output", "safe route shape should survive sanitization") + assertEqual(sanitized["hfp_suspected"], "true", "Bluetooth HFP suspicion should survive as a boolean") + assertEqual(sanitized["sample_flow_started"], "false", "sample flow state should survive as a boolean") assertEqual(sanitized["trigger"], "hotkey", "dictation start trigger should survive sanitization") } + runSuite("AnalyticsEventPolicy allows dictation audio route lifecycle events") { + let changed = AnalyticsEventPolicy.policy(forEvent: "dictation_audio_route_changed") + let finished = AnalyticsEventPolicy.policy(forEvent: "dictation_audio_route_recovery_finished") + let timeout = AnalyticsEventPolicy.policy(forEvent: "dictation_audio_route_recovery_timeout") + + assertEqual(changed?.allowedProperties.contains("was_recording"), true, "route change should preserve whether an active recording was interrupted") + assertEqual(changed?.allowedProperties.contains("selected_input_class"), true, "route change should preserve selected input class") + assertEqual(finished?.allowedProperties.contains("outcome"), true, "route recovery should preserve success/failure") + assertEqual(finished?.allowedProperties.contains("recovery_latency_bucket"), true, "route recovery should preserve latency as a bucket") + assertEqual(timeout?.allowedProperties.contains("hfp_suspected"), true, "route timeout should preserve Bluetooth HFP suspicion only as a boolean") + + let sanitized = AnalyticsPayloadSanitizer.sanitizeProperties( + [ + "outcome": "failed", + "recovery_latency_bucket": "2_9m", + "selected_input_class": "built_in", + "was_recording": "true", + ], + allowedKeys: finished?.allowedProperties ?? [] + ) + assertEqual(sanitized["outcome"], "failed", "route recovery outcome should survive sanitization") + assertEqual(sanitized["recovery_latency_bucket"], "2_9m", "route recovery latency bucket should survive sanitization") + assertEqual(sanitized["selected_input_class"], "built_in", "selected input class should survive sanitization") + assertEqual(sanitized["was_recording"], "true", "recording interruption state should survive sanitization") + } + runSuite("AnalyticsEventPolicy only permits reviewed analytics events") { let dictationStartFailed = AnalyticsEventPolicy.policy(forEvent: "dictation_start_failed") let dictationCompleted = AnalyticsEventPolicy.policy(forEvent: "dictation_completed") diff --git a/Tests/SentryEventPolicyTests.swift b/Tests/SentryEventPolicyTests.swift index 4893754e..04e7fd0b 100644 --- a/Tests/SentryEventPolicyTests.swift +++ b/Tests/SentryEventPolicyTests.swift @@ -18,6 +18,14 @@ func testSentryEventPolicy() { forEngine: "dictation", event: "microphone_start_timeout" ) + let deviceRecoveryTimeout = SentryEventPolicy.policy( + forEngine: "parakeet", + event: "device_change_recovery_timeout" + ) + let recordingInterrupted = SentryEventPolicy.policy( + forEngine: "parakeet", + event: "recording_interrupted" + ) let meetingStartFailed = SentryEventPolicy.policy( forEngine: "meeting", event: "meeting_start_failed" @@ -51,6 +59,8 @@ func testSentryEventPolicy() { assertEqual(hotkeyFailure?.summary, "Transcripted could not register a keyboard shortcut.", "capture failure should stay allowlisted") assertEqual(audioStartFailure?.summary, "Speech audio engine failed to start.", "audio-start failures should stay allowlisted with a privacy-safe summary") assertEqual(microphoneStartTimeout?.summary, "Dictation microphone start timed out.", "microphone start timeouts should be visible in Sentry without raw device names") + assertEqual(deviceRecoveryTimeout?.summary, "Speech engine device-change recovery timed out.", "device recovery timeouts should be visible in Sentry with privacy-safe route context") + assertEqual(recordingInterrupted?.summary, "Dictation recording was interrupted by audio device recovery.", "recording interruptions should be visible in Sentry with privacy-safe route context") assertEqual(meetingStartFailed?.summary, "Meeting recording could not start.", "meeting start failures should be visible without raw device names") assertEqual(meetingCaptureDegraded?.summary, "Meeting capture health degraded.", "degraded meeting capture should be visible without raw device names") assertEqual(meetingStopTimeout?.summary, "Meeting recording stop timed out.", "stop timeouts should be visible without raw device names") From 53e4b4ca6439a019262b48326e270e3e36b08df7 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Wed, 29 Apr 2026 21:10:13 -0500 Subject: [PATCH 02/22] Add support diagnostics and runtime shutdown tracking --- .../Meeting/MeetingSessionController.swift | 26 ++++ .../Observability/AnalyticsEventPolicy.swift | 35 +++++ Sources/Observability/CLAUDE.md | 4 + Sources/Observability/CrashReporter.swift | 16 ++ .../Observability/RuntimeDiagnostics.swift | 143 ++++++++++++++++++ .../RuntimeDiagnosticsStore.swift | 95 ++++++++++++ Sources/Observability/SentryEventPolicy.swift | 10 ++ Sources/TranscriptedApp.swift | 10 +- Sources/TranscriptedAppState.swift | 9 ++ Sources/UI/CLAUDE.md | 5 +- .../Overlay/DictationSessionController.swift | 29 ++++ .../TranscriptedSettingsActions.swift | 2 + .../Settings/TranscriptedSettingsView.swift | 43 ++++++ Sources/UI/Shared/FeedbackIssueBuilder.swift | 50 ++++-- .../UI/Shared/SupportDiagnosticsBundle.swift | 114 ++++++++++++++ .../Shared/TranscriptedSupportActions.swift | 101 ++++++++++++- Tests/AnalyticsEventPolicyTests.swift | 27 ++++ Tests/FastTests.manifest | 2 + Tests/FeedbackIssueBuilderTests.swift | 13 ++ Tests/RuntimeDiagnosticsStoreTests.swift | 52 +++++++ Tests/SentryEventPolicyTests.swift | 10 ++ Tests/SupportDiagnosticsBundleTests.swift | 76 ++++++++++ docs/storage-paths.md | 1 + scripts/entrypoints/run-tests.sh | 2 + 24 files changed, 860 insertions(+), 15 deletions(-) create mode 100644 Sources/Observability/RuntimeDiagnostics.swift create mode 100644 Sources/Observability/RuntimeDiagnosticsStore.swift create mode 100644 Sources/UI/Shared/SupportDiagnosticsBundle.swift create mode 100644 Tests/RuntimeDiagnosticsStoreTests.swift create mode 100644 Tests/SupportDiagnosticsBundleTests.swift diff --git a/Sources/Meeting/MeetingSessionController.swift b/Sources/Meeting/MeetingSessionController.swift index a49a7701..45deef85 100644 --- a/Sources/Meeting/MeetingSessionController.swift +++ b/Sources/Meeting/MeetingSessionController.swift @@ -28,6 +28,8 @@ import TranscriptedCore @available(macOS 14.0, *) @MainActor final class MeetingSessionController: ObservableObject { + static var runtimeDiagnosticsRecorder: RuntimeDiagnostics? + enum StartTrigger: String { case hotkey = "hotkey" case menu = "menu" @@ -290,6 +292,7 @@ final class MeetingSessionController: ObservableObject { /// the older transcript continues in the background. @discardableResult func startRecording(trigger: StartTrigger = .unknown) async -> Bool { + Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "start_requested") DiagnosticsTrail.record( engine: "meeting", event: "meeting_start_requested", @@ -329,10 +332,12 @@ final class MeetingSessionController: ObservableObject { startDecision.errorMessage ?? "Turn on the required permissions in System Settings before recording a meeting." ) + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "start_blocked_permission") return false } guard await ensureModelsReadyForRecording(trigger: trigger) else { + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "models_unavailable") return false } @@ -359,11 +364,13 @@ final class MeetingSessionController: ObservableObject { properties: failureProperties ) state = .error(failureMessage) + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "start_failed") return false } activeRecordingTrigger = trigger state = .recording + Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "recording") let pipelineSnapshot = capture.pipelineDiagnosticsSnapshot() DiagnosticsTrail.record( engine: "meeting", @@ -495,6 +502,7 @@ final class MeetingSessionController: ObservableObject { guard !isFinishingRecording else { return } isFinishingRecording = true defer { isFinishingRecording = false } + Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "stop_requested") _ = audioInactivityDetector.stopRecording() audioInactivityWarning = nil @@ -524,6 +532,7 @@ final class MeetingSessionController: ObservableObject { let durationMs = Int(finalRecordingDuration * 1000) activeRecordingTrigger = .unknown state = .transcribing + Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "transcribing") DiagnosticsTrail.record( level: finalSystemAudioStatus.isWarning ? .warning : .info, @@ -601,6 +610,15 @@ final class MeetingSessionController: ObservableObject { // from Settings → Meetings, where the pipeline will either succeed // on a now-finalized file or fail cleanly. if stopResult.didTimeOut { + Self.runtimeDiagnosticsRecorder?.recordStall( + kind: "meeting", + stage: "recording_stop_timeout", + durationSeconds: finalRecordingDuration, + extra: [ + "trigger": recordingTrigger.rawValue, + "reason": reason.rawValue + ] + ) failedManager.addFailedTranscription( micAudioURL: micURL, systemAudioURL: files.systemURL, @@ -614,6 +632,7 @@ final class MeetingSessionController: ObservableObject { context: baseDiagnosticsContext(extra: ["reason": reason.rawValue]) ) state = .error("Recording didn't close cleanly. Open Settings → Meetings to retry.") + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "stop_timeout") return } @@ -712,6 +731,7 @@ final class MeetingSessionController: ObservableObject { activeRecordingTrigger = .unknown restoreStateAfterRecordingEndedWithoutNewWork() AppSoundPlayer.shared.play(.dictationCancelled) + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "cancelled") DiagnosticsTrail.record( engine: "meeting", @@ -766,6 +786,7 @@ final class MeetingSessionController: ObservableObject { message: "Imported meeting transcription requested", context: baseDiagnosticsContext(extra: ["trigger": StartTrigger.fileImport.rawValue]) ) + Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "file_import_requested") if case .idle = state { await prepareModels() @@ -799,6 +820,7 @@ final class MeetingSessionController: ObservableObject { context: baseDiagnosticsContext(extra: ["error": error.localizedDescription]) ) state = .error(error.localizedDescription) + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "file_import_failed") return false } @@ -830,6 +852,7 @@ final class MeetingSessionController: ObservableObject { "queue_depth_bucket": AnalyticsReporter.queueDepthBucket(queuedTranscriptionJobs.count), ] ) + Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "transcribing") return true } @@ -863,6 +886,7 @@ final class MeetingSessionController: ObservableObject { message: "Meeting transcription cancelled", context: baseDiagnosticsContext(extra: ["reason": reason.rawValue]) ) + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "transcription_cancelled") } func prepareForTermination() async { @@ -1345,6 +1369,7 @@ final class MeetingSessionController: ObservableObject { uniquingKeysWith: { _, new in new } ) ) + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "transcript_saved") AppSoundPlayer.shared.play(.meetingTranscriptComplete) case .failed(let message): lastTerminalTranscriptionOutcome = .failed(message) @@ -1373,6 +1398,7 @@ final class MeetingSessionController: ObservableObject { uniquingKeysWith: { _, new in new } ) ) + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "transcript_failed") finalizeBackgroundTranscriptionStateIfNeeded() case .gettingReady: if previousStatus.diagnosticName != status.diagnosticName { diff --git a/Sources/Observability/AnalyticsEventPolicy.swift b/Sources/Observability/AnalyticsEventPolicy.swift index 47eaaedb..564118c3 100644 --- a/Sources/Observability/AnalyticsEventPolicy.swift +++ b/Sources/Observability/AnalyticsEventPolicy.swift @@ -53,11 +53,46 @@ struct AnalyticsEventPolicy: Equatable { "was_recording", ] + private static let runtimeDiagnosticProperties: Set = [ + "app_version", + "build_version", + "duration_bucket", + "format_ready", + "heartbeat_age_bucket", + "last_event", + "os_major", + "previous_clean_shutdown", + "reason", + "recovering", + "session_active", + "session_kind", + "session_stage", + "stall_kind", + "stall_stage", + "trigger", + ] + private static let allowedPolicies: [String: AnalyticsEventPolicy] = [ "app_launched": .init( name: "app_launched", allowedProperties: [] ), + "app_unclean_shutdown_detected": .init( + name: "app_unclean_shutdown_detected", + allowedProperties: runtimeDiagnosticProperties + ), + "app_session_stall_detected": .init( + name: "app_session_stall_detected", + allowedProperties: runtimeDiagnosticProperties + ), + "support_diagnostics_copied": .init( + name: "support_diagnostics_copied", + allowedProperties: [] + ), + "support_diagnostic_event_sent": .init( + name: "support_diagnostic_event_sent", + allowedProperties: [] + ), "onboarding_shown": .init( name: "onboarding_shown", allowedProperties: [ diff --git a/Sources/Observability/CLAUDE.md b/Sources/Observability/CLAUDE.md index 82de2dca..3021526e 100644 --- a/Sources/Observability/CLAUDE.md +++ b/Sources/Observability/CLAUDE.md @@ -12,6 +12,8 @@ anonymous analytics, and Sparkle update plumbing. - `JSONLWriter.swift` — shared append-only JSONL writer that reuses file handles and falls back cleanly if log files are rotated or recreated - `LockedFileAppender.swift` — cross-process-safe file append helper that serializes writes and uses `flock` so concurrent JSONL/debug-log writers do not interleave records - `DiagnosticsTrail.swift` — lightweight high-signal diagnostics helper +- `RuntimeDiagnostics.swift` — app runtime heartbeat, dirty-shutdown detection, and active session stage tracking for force quits / silent exits +- `RuntimeDiagnosticsStore.swift` — JSON marker persistence and privacy-safe dirty-shutdown context builder - `CrashReporter.swift` — crash reporting setup - `CrashReportingPreferences.swift` — Settings-backed crash reporting preference - `AnalyticsReporter.swift` — privacy-first anonymous usage analytics to PostHog @@ -36,6 +38,7 @@ anonymous analytics, and Sparkle update plumbing. - `SentryRuntimeConfiguration` rejects non-HTTPS DSNs, so insecure local overrides fail closed instead of downgrading crash transport - PostHog config is read from `Info.plist` (`TranscriptedPostHogAPIKey`, `TranscriptedPostHogHost`) or process environment (`POSTHOG_API_KEY`, `POSTHOG_HOST`), and anonymous analytics must stay event-allowlisted and bucketed rather than sending raw payloads - Non-fatal error forwarding to Sentry is allowlisted. New `.error` events should not automatically assume they are safe to send off-device. +- `RuntimeDiagnostics` writes only coarse runtime state under app-owned state. Keep it free of transcript text, raw audio, file paths, device names, meeting titles, and speaker names. - Update telemetry should keep using `UpdateFailureKind` instead of ad hoc string parsing so dashboards stay stable across Sparkle error wording changes. ## Verification @@ -57,6 +60,7 @@ Relevant direct coverage: - `Tests/SentryPayloadSanitizerTests.swift` - `Tests/SentryRuntimeConfigurationTests.swift` - `Tests/ObservabilityLogWriterTests.swift` +- `Tests/RuntimeDiagnosticsStoreTests.swift` - `Tests/UpdateFailureKindTests.swift` Useful files while testing: diff --git a/Sources/Observability/CrashReporter.swift b/Sources/Observability/CrashReporter.swift index bce8f9ec..0d1ac1ca 100644 --- a/Sources/Observability/CrashReporter.swift +++ b/Sources/Observability/CrashReporter.swift @@ -74,6 +74,22 @@ final class CrashReporter { ) } + @discardableResult + func captureSupportDiagnosticEvent(extra: [String: String]) -> String? { + captureMessageEvent( + level: .warning, + title: "support_diagnostic_event", + message: "Manual support diagnostic event from Transcripted Settings", + tags: [ + "source": "support_diagnostics", + "engine": "support", + "event": "diagnostic_event", + ], + extra: extra, + fingerprint: ["support", "diagnostic_event"] + ) + } + func captureObservabilityEvent( level: EventLevel, engine: String, diff --git a/Sources/Observability/RuntimeDiagnostics.swift b/Sources/Observability/RuntimeDiagnostics.swift new file mode 100644 index 00000000..2d7b8531 --- /dev/null +++ b/Sources/Observability/RuntimeDiagnostics.swift @@ -0,0 +1,143 @@ +import Foundation + +@MainActor +final class RuntimeDiagnostics { + private let markerURL: URL + private var marker: RuntimeDiagnosticsMarker? + private var heartbeatTimer: Timer? + + init(markerURL: URL = RuntimeDiagnosticsStore.defaultMarkerURL()) { + self.markerURL = markerURL + } + + func start() { + guard marker == nil else { return } + + if let previous = RuntimeDiagnosticsStore.load(from: markerURL), + !previous.cleanShutdown { + let context = RuntimeDiagnosticsStore.contextForUncleanShutdown(previous: previous) + EventReporter.shared.capture( + level: .error, + engine: "app", + event: "unclean_shutdown_detected", + message: "Previous app session did not shut down cleanly", + context: context + ) + AnalyticsReporter.track("app_unclean_shutdown_detected", properties: context) + } + + marker = RuntimeDiagnosticsStore.makeLaunchMarker( + appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown", + buildVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown", + osMajor: ProcessInfo.processInfo.operatingSystemVersion.majorVersion + ) + persist(event: "app_launched") + startHeartbeatTimer() + } + + func markCleanShutdown() { + guard var marker else { return } + marker.cleanShutdown = true + marker.updatedAt = Date() + marker.lastEvent = "clean_shutdown" + self.marker = marker + RuntimeDiagnosticsStore.save(marker, to: markerURL) + heartbeatTimer?.invalidate() + heartbeatTimer = nil + } + + func recordSession(kind: String, stage: String, active: Bool = true) { + if marker == nil { + start() + } + guard marker != nil else { return } + updateMarker { marker in + marker.sessionKind = kind + marker.sessionStage = stage + marker.sessionActive = active + marker.lastEvent = "\(kind)_\(stage)" + } + } + + func clearSession(kind: String, outcome: String) { + if marker == nil { + start() + } + guard marker != nil else { return } + updateMarker { marker in + marker.sessionKind = kind + marker.sessionStage = outcome + marker.sessionActive = false + marker.lastEvent = "\(kind)_\(outcome)" + } + } + + func recordStall( + kind: String, + stage: String, + durationSeconds: Double, + extra: [String: String] = [:] + ) { + recordSession(kind: kind, stage: stage, active: true) + var context = currentAnalyticsContext() + context["duration_bucket"] = AnalyticsReporter.durationBucket(seconds: durationSeconds) + context["stall_kind"] = kind + context["stall_stage"] = stage + for (key, value) in extra { + context[key] = value + } + + EventReporter.shared.capture( + level: .error, + engine: "app", + event: "session_stall_detected", + message: "Runtime session appears stalled", + context: context + ) + AnalyticsReporter.track("app_session_stall_detected", properties: context) + } + + func currentAnalyticsContext(now: Date = Date()) -> [String: String] { + guard let marker else { + return [ + "last_event": "unknown", + "session_active": "false", + "session_kind": "none", + "session_stage": "idle", + ] + } + + return [ + "heartbeat_age_bucket": RuntimeDiagnosticsStore.heartbeatAgeBucket(previousUpdate: marker.updatedAt, now: now), + "last_event": marker.lastEvent, + "previous_clean_shutdown": "\(marker.cleanShutdown)", + "session_active": "\(marker.sessionActive)", + "session_kind": marker.sessionKind, + "session_stage": marker.sessionStage, + ] + } + + private func startHeartbeatTimer() { + heartbeatTimer?.invalidate() + heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.persist(event: "heartbeat") + } + } + } + + private func persist(event: String) { + updateMarker { marker in + marker.lastEvent = event + } + } + + private func updateMarker(_ update: (inout RuntimeDiagnosticsMarker) -> Void) { + guard var marker else { return } + update(&marker) + marker.cleanShutdown = false + marker.updatedAt = Date() + self.marker = marker + RuntimeDiagnosticsStore.save(marker, to: markerURL) + } +} diff --git a/Sources/Observability/RuntimeDiagnosticsStore.swift b/Sources/Observability/RuntimeDiagnosticsStore.swift new file mode 100644 index 00000000..4c83d512 --- /dev/null +++ b/Sources/Observability/RuntimeDiagnosticsStore.swift @@ -0,0 +1,95 @@ +import Foundation + +struct RuntimeDiagnosticsMarker: Codable, Equatable { + var launchID: String + var appVersion: String + var buildVersion: String + var osMajor: Int + var cleanShutdown: Bool + var startedAt: Date + var updatedAt: Date + var lastEvent: String + var sessionKind: String + var sessionStage: String + var sessionActive: Bool +} + +enum RuntimeDiagnosticsStore { + static let markerFileName = "runtime-diagnostics.json" + + static func defaultMarkerURL(fileManager: FileManager = .default) -> URL { + fileManager.transcriptedStateDir.appendingPathComponent(markerFileName, isDirectory: false) + } + + static func makeLaunchMarker( + launchID: String = UUID().uuidString, + appVersion: String, + buildVersion: String, + osMajor: Int, + now: Date = Date() + ) -> RuntimeDiagnosticsMarker { + RuntimeDiagnosticsMarker( + launchID: launchID, + appVersion: appVersion, + buildVersion: buildVersion, + osMajor: osMajor, + cleanShutdown: false, + startedAt: now, + updatedAt: now, + lastEvent: "app_launched", + sessionKind: "none", + sessionStage: "idle", + sessionActive: false + ) + } + + static func load(from url: URL) -> RuntimeDiagnosticsMarker? { + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(RuntimeDiagnosticsMarker.self, from: data) + } + + static func save(_ marker: RuntimeDiagnosticsMarker, to url: URL) { + do { + try FileManager.default.createPrivateDirectory(at: url.deletingLastPathComponent()) + let data = try JSONEncoder().encode(marker) + try data.write(to: url, options: .atomic) + FileManager.default.restrictFileToOwnerOnly(at: url) + } catch { + fputs("Runtime diagnostics marker write failed: \(error.localizedDescription)\n", stderr) + } + } + + static func heartbeatAgeBucket(previousUpdate: Date, now: Date = Date()) -> String { + let age = max(0, now.timeIntervalSince(previousUpdate)) + switch age { + case ..<15: + return "lt_15s" + case ..<60: + return "15_59s" + case ..<300: + return "1_4m" + case ..<900: + return "5_14m" + case ..<3600: + return "15_59m" + default: + return "1h_plus" + } + } + + static func contextForUncleanShutdown( + previous marker: RuntimeDiagnosticsMarker, + now: Date = Date() + ) -> [String: String] { + [ + "app_version": marker.appVersion, + "build_version": marker.buildVersion, + "heartbeat_age_bucket": heartbeatAgeBucket(previousUpdate: marker.updatedAt, now: now), + "last_event": marker.lastEvent, + "os_major": "\(marker.osMajor)", + "session_active": "\(marker.sessionActive)", + "session_kind": marker.sessionKind, + "session_stage": marker.sessionStage, + ] + } +} diff --git a/Sources/Observability/SentryEventPolicy.swift b/Sources/Observability/SentryEventPolicy.swift index acf687a2..5e12f2f5 100644 --- a/Sources/Observability/SentryEventPolicy.swift +++ b/Sources/Observability/SentryEventPolicy.swift @@ -10,6 +10,16 @@ struct SentryEventPolicy: Equatable { } private static let allowedPolicies: [String: SentryEventPolicy] = [ + "app.unclean_shutdown_detected": .init( + engine: "app", + event: "unclean_shutdown_detected", + summary: "Previous app session did not shut down cleanly." + ), + "app.session_stall_detected": .init( + engine: "app", + event: "session_stall_detected", + summary: "Transcripted detected a stalled runtime session." + ), "parakeet.model_init_failed": .init( engine: "parakeet", event: "model_init_failed", diff --git a/Sources/TranscriptedApp.swift b/Sources/TranscriptedApp.swift index c55ae7aa..1c43a403 100644 --- a/Sources/TranscriptedApp.swift +++ b/Sources/TranscriptedApp.swift @@ -37,7 +37,15 @@ class TranscriptedAppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegat checkForUpdates: { [weak self] in self?.appState.sparkleUpdater.checkForUpdates() }, sendFeedback: { [weak self] in guard let self else { return } - TranscriptedSupportActions.sendFeedback(logger: self.appState.logger) + TranscriptedSupportActions.sendFeedback(appState: self.appState) + }, + copyDiagnostics: { [weak self] in + guard let self else { return false } + return TranscriptedSupportActions.copyDiagnostics(appState: self.appState) + }, + sendDiagnosticEvent: { [weak self] in + guard let self else { return nil } + return TranscriptedSupportActions.sendDiagnosticEvent(appState: self.appState) } ) private lazy var settingsWindowController = TranscriptedSettingsWindowController( diff --git a/Sources/TranscriptedAppState.swift b/Sources/TranscriptedAppState.swift index de0a90d2..3c5858ea 100644 --- a/Sources/TranscriptedAppState.swift +++ b/Sources/TranscriptedAppState.swift @@ -12,6 +12,7 @@ class TranscriptedAppState: ObservableObject { let sparkleUpdater = SparkleUpdaterController() let contextCapture = ContextCaptureEngine() let sttRouter = STTRouter() + let runtimeDiagnostics = RuntimeDiagnostics() /// Meeting-mode pipeline (Lane B). Lazily instantiated so unit tests that /// don't exercise the meeting feature don't pay the construction cost. @@ -79,6 +80,10 @@ class TranscriptedAppState: ObservableObject { logger.log("APP LAUNCHED | modes: dictation + meetings") AnalyticsReporter.track("app_launched") + runtimeDiagnostics.start() + if #available(macOS 14.0, *) { + MeetingSessionController.runtimeDiagnosticsRecorder = runtimeDiagnostics + } // Wire EventReporter with live engine state for context enrichment EventReporter.shared.setEngineStateSummary { [weak self] in @@ -143,6 +148,10 @@ class TranscriptedAppState: ObservableObject { NotificationCenter.default.removeObserver(observer) promptsObserver = nil } + if #available(macOS 14.0, *) { + MeetingSessionController.runtimeDiagnosticsRecorder = nil + } + runtimeDiagnostics.markCleanShutdown() } private func startRuntimeReadinessIfNeeded() { diff --git a/Sources/UI/CLAUDE.md b/Sources/UI/CLAUDE.md index a3d750a0..db159bff 100644 --- a/Sources/UI/CLAUDE.md +++ b/Sources/UI/CLAUDE.md @@ -74,7 +74,7 @@ The current agent-connect surfaces should keep one simple mental model: - `Settings/SpeakerNamingSheet.swift` — sheet for reviewing speakers in a completed meeting, grouped into local room speakers vs remote participants, with a "Keep as You" escape hatch for local mic splits - `Settings/SpeakerPeopleSettingsSection.swift` — settings section and view model for browsing, naming, merging, previewing, and deleting saved speaker profiles, plus the toggle for identifying multiple local speakers on the mic track - `Settings/TranscriptedOnboardingWindowController.swift` — dedicated first-launch window that hosts onboarding before users drop into the menubar flow -- `Settings/TranscriptedSettingsActions.swift` — struct of callbacks (start dictation, start meeting, import audio, paste, connect agent, check updates, send feedback) injected into the settings view +- `Settings/TranscriptedSettingsActions.swift` — struct of callbacks (start dictation, start meeting, import audio, paste, connect agent, check updates, send feedback, copy/send diagnostics) injected into the settings view - `Settings/TranscriptedSettingsComponents.swift` — shared SwiftUI building blocks (`SettingsPageIntro`, `SettingsSection`) used across settings pages - `Settings/TranscriptedSettingsNavigationModel.swift` — observable navigation state for the current `TranscriptedSettingsPage` selection - `Settings/TranscriptedSettingsPage.swift` — enum of settings pages (home, general, models, shortcuts, meetings, dictations, people, storage, connectAgent, privacy, about) with titles, summaries, and SF Symbol names @@ -91,7 +91,8 @@ The current agent-connect surfaces should keep one simple mental model: - `Shared/MeetingAudioPlayback.swift` — shared play/pause/resume `NSSound`-backed controller for recent-meeting audio previews in Settings - `Shared/RecentCaptureScanners.swift` — `RecentMeetingsScanner` that loads recent meeting transcripts plus retained audio attachments for the Settings meetings page - `Shared/SpeakerClipPlayback.swift` — reusable audio-preview helper for persisted speaker sample clips -- `Shared/TranscriptedSupportActions.swift` — "Send feedback" flow that builds a GitHub-issue URL seeded with sanitized recent log lines +- `Shared/SupportDiagnosticsBundle.swift` — privacy-safe support summary used for copied diagnostics and manual diagnostic events +- `Shared/TranscriptedSupportActions.swift` — support flows for feedback, copied diagnostics, and manually queued diagnostic events Cross-cutting permission checks now live in `Sources/Support/TranscriptedPermissionAccess.swift` so the meeting prompt detector and the settings/onboarding flows share the same diff --git a/Sources/UI/Overlay/DictationSessionController.swift b/Sources/UI/Overlay/DictationSessionController.swift index 16a6f502..a3db7c2f 100644 --- a/Sources/UI/Overlay/DictationSessionController.swift +++ b/Sources/UI/Overlay/DictationSessionController.swift @@ -124,6 +124,7 @@ class DictationSessionController: ObservableObject { sessionStartTime = CFAbsoluteTimeGetCurrent() currentDictationTrigger = trigger lastCompletedText = nil + appState.runtimeDiagnostics.recordSession(kind: "dictation", stage: "start_requested") switch TranscriptedPermissionAccess.microphoneAuthorizationStatus() { case .authorized: @@ -269,6 +270,7 @@ class DictationSessionController: ObservableObject { self.recordingStartRetryTask = nil overlayController.state = .listening self.resizePanelToCompact() + appState.runtimeDiagnostics.recordSession(kind: "dictation", stage: "recording") appState.logger.log("DICTATION | started (parakeet, \(appState.sttRouter.inputDeviceName))") AppSoundPlayer.shared.play(.dictationStart) self.installSessionTimeout() @@ -350,6 +352,7 @@ class DictationSessionController: ObservableObject { if started { overlayController.state = .listening resizePanelToCompact() + appState.runtimeDiagnostics.recordSession(kind: "dictation", stage: "recording_after_wait") let waited = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000) appState.logger.log("DICTATION | started after \(waited)ms wait (parakeet, \(appState.sttRouter.inputDeviceName))") DiagnosticsTrail.record( @@ -417,6 +420,16 @@ class DictationSessionController: ObservableObject { ) ) trackDictationStartFailed("microphone_start_timeout") + appState.runtimeDiagnostics.recordStall( + kind: "dictation", + stage: "microphone_start_timeout", + durationSeconds: TranscriptedConstants.dictationRecoveryBudget, + extra: [ + "format_ready": "\(appState.sttRouter.inputFormatReady)", + "recovering": "\(appState.sttRouter.isRecovering)" + ] + ) + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "microphone_start_timeout") isDictating = false overlayController.showError( microphoneTimeoutMessage( @@ -452,6 +465,7 @@ class DictationSessionController: ObservableObject { ) ) trackDictationStartFailed(dictationStartFailureKind(for: status)) + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "start_failed") if !overlayController.isVisible { overlayController.showPanel(near: sourceApp, anchorRect: sessionAnchorRect) } @@ -557,6 +571,7 @@ class DictationSessionController: ObservableObject { streamingTask?.cancel() streamingTask = Task { + appState.runtimeDiagnostics.recordSession(kind: "dictation", stage: "stop_requested") await appState.sttRouter.stopRecording() // Surface model warmup honestly instead of calling it "Transcribing" @@ -574,10 +589,12 @@ class DictationSessionController: ObservableObject { appState.logger.log("DICTATION | voice model failed to load for transcription") overlayController.showError("Voice model failed to load") isDictating = false + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "model_unavailable") return } } overlayController.state = .drafting + appState.runtimeDiagnostics.recordSession(kind: "dictation", stage: "transcribing") let voiceText = await appState.sttRouter.transcribe() guard !Task.isCancelled else { return } @@ -610,6 +627,7 @@ class DictationSessionController: ObservableObject { AppSoundPlayer.shared.play(.noSpeech) overlayController.showNoSpeechAndDismiss() isDictating = false + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "no_speech") return } @@ -675,6 +693,7 @@ class DictationSessionController: ObservableObject { ] ) ) + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "completed") } } @@ -685,6 +704,7 @@ class DictationSessionController: ObservableObject { AppSoundPlayer.shared.play(.dictationCancelled) overlayController.hideWithCancelAnimation() isDictating = false + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "cancelled") appState.logger.log("DICTATION | cancelled") DiagnosticsTrail.record( logger: appState.logger, @@ -766,6 +786,7 @@ class DictationSessionController: ObservableObject { case .failed(let message): self.startupTask = nil self.isDictating = false + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "model_failed") overlayController.showError( "Dictation couldn't start: \(message)", actionTitle: "Retry Dictation", @@ -788,6 +809,13 @@ class DictationSessionController: ObservableObject { guard !Task.isCancelled else { return } self.startupTask = nil self.isDictating = false + appState.runtimeDiagnostics.recordStall( + kind: "dictation", + stage: "model_load_timeout", + durationSeconds: Double(TranscriptedConstants.modelLoadMaxIterations) + * Double(TranscriptedConstants.modelLoadPollInterval) / 1_000_000_000 + ) + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "model_load_timeout") overlayController.showError( "Dictation is still loading. Please try again in a moment.", actionTitle: "Retry Dictation", @@ -959,6 +987,7 @@ class DictationSessionController: ObservableObject { private func handleDictationInterruption() { cancelActiveTasks(cancelRecording: true) isDictating = false + appState?.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "interrupted") appState?.logger.log("DICTATION | interrupted") DiagnosticsTrail.record( logger: appState?.logger, diff --git a/Sources/UI/Settings/TranscriptedSettingsActions.swift b/Sources/UI/Settings/TranscriptedSettingsActions.swift index fbf09622..5806e245 100644 --- a/Sources/UI/Settings/TranscriptedSettingsActions.swift +++ b/Sources/UI/Settings/TranscriptedSettingsActions.swift @@ -9,4 +9,6 @@ struct TranscriptedSettingsActions { let openConnectAgent: () -> Void let checkForUpdates: () -> Void let sendFeedback: () -> Void + let copyDiagnostics: () -> Bool + let sendDiagnosticEvent: () -> String? } diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index 5c9391d1..a99a62c9 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -48,6 +48,7 @@ struct TranscriptedSettingsView: View { @State private var crashReportingEnabled = CrashReportingPreferences.isEnabled() @State private var anonymousAnalyticsEnabled = AnalyticsPreferences.isEnabled() @State private var sentryTestStatus: String? + @State private var diagnosticsActionStatus: String? @State private var permissionStates = PermissionSnapshot.current() @State private var captureLibraryURL = FileManager.default.transcriptedCaptureLibraryDir @State private var recentMeetings: [RecentMeetingItem] = [] @@ -1282,6 +1283,7 @@ struct TranscriptedSettingsView: View { trackSettingsToggle("crash_reporting", enabled: newValue, page: .privacy) CrashReportingPreferences.setEnabled(newValue) sentryTestStatus = nil + diagnosticsActionStatus = nil } )) .disabled(!CrashReporter.isAvailable) @@ -1297,6 +1299,7 @@ struct TranscriptedSettingsView: View { trackSettingsToggle("anonymous_analytics", enabled: false, page: .privacy) AnalyticsPreferences.setEnabled(false) } + diagnosticsActionStatus = nil } )) .disabled(!AnalyticsReporter.isAvailable) @@ -1315,6 +1318,27 @@ struct TranscriptedSettingsView: View { } } + HStack { + Button("Copy Diagnostics") { + trackSettingsAction("copy_diagnostics", page: .privacy) + diagnosticsActionStatus = actions.copyDiagnostics() + ? "Copied diagnostics." + : "Could not copy diagnostics." + } + + Button("Send Diagnostic Event") { + trackSettingsAction("send_diagnostic_event", page: .privacy) + sendDiagnosticEvent() + } + .disabled(!CrashReporter.isAvailable || !crashReportingEnabled) + + if let diagnosticsActionStatus { + Text(diagnosticsActionStatus) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Text("Never sent: transcript text, audio, names, emails, file paths, raw URLs, or meeting titles.") .font(.caption) .foregroundStyle(.secondary) @@ -1795,6 +1819,25 @@ struct TranscriptedSettingsView: View { sentryTestStatus = "Queued test event \(eventID.prefix(8)). Check Sentry in a few seconds." } + private func sendDiagnosticEvent() { + guard CrashReporter.isAvailable else { + diagnosticsActionStatus = "Sentry is not configured in this build yet." + return + } + + guard crashReportingEnabled else { + diagnosticsActionStatus = "Turn on crash and error reports first." + return + } + + guard let eventID = actions.sendDiagnosticEvent() else { + diagnosticsActionStatus = "Diagnostic event could not be queued." + return + } + + diagnosticsActionStatus = "Queued diagnostic event \(eventID.prefix(8))." + } + private func chooseCaptureLibrary() { let panel = NSOpenPanel() panel.canChooseDirectories = true diff --git a/Sources/UI/Shared/FeedbackIssueBuilder.swift b/Sources/UI/Shared/FeedbackIssueBuilder.swift index 4e4af8c6..3f824b10 100644 --- a/Sources/UI/Shared/FeedbackIssueBuilder.swift +++ b/Sources/UI/Shared/FeedbackIssueBuilder.swift @@ -6,25 +6,34 @@ enum FeedbackIssueBuilder { private static let title = "Transcripted Feedback" private static let maxLogLines = 80 + private static let maxDiagnosticsCharacters = 2_500 private static let omittedLogsNotice = "[Older logs omitted because GitHub rejects very long feedback URLs.]" + private static let omittedDiagnosticsNotice = "[Older diagnostics omitted because GitHub rejects very long feedback URLs.]" private static let noLogsMessage = "No in-app logs attached." - static func issueURL(rawLogLines: [String]?) -> URL? { + static func issueURL(rawLogLines: [String]?, diagnostics: String? = nil) -> URL? { let rawLogs = rawLogLines?.suffix(maxLogLines).joined(separator: "\n") ?? noLogsMessage let sanitizedLogs = AnalyticsPayloadSanitizer.redact(rawLogs) - return issueURL(sanitizedLogs: sanitizedLogs.isEmpty ? noLogsMessage : sanitizedLogs) + let sanitizedDiagnostics = diagnostics.map { fittingDiagnostics(from: AnalyticsPayloadSanitizer.redact($0)) } + return issueURL( + sanitizedLogs: sanitizedLogs.isEmpty ? noLogsMessage : sanitizedLogs, + diagnostics: sanitizedDiagnostics + ) } - static func issueURL(sanitizedLogs: String) -> URL? { - if let url = uncappedIssueURL(sanitizedLogs: sanitizedLogs), + static func issueURL(sanitizedLogs: String, diagnostics: String? = nil) -> URL? { + if let url = uncappedIssueURL(sanitizedLogs: sanitizedLogs, diagnostics: diagnostics), url.absoluteString.count <= maxIssueURLCharacterCount { return url } - return uncappedIssueURL(sanitizedLogs: fittingTrimmedLogs(from: sanitizedLogs)) + return uncappedIssueURL( + sanitizedLogs: fittingTrimmedLogs(from: sanitizedLogs, diagnostics: diagnostics), + diagnostics: diagnostics + ) } - private static func fittingTrimmedLogs(from sanitizedLogs: String) -> String { + private static func fittingTrimmedLogs(from sanitizedLogs: String, diagnostics: String?) -> String { var lowerBound = 0 var upperBound = sanitizedLogs.count var best = omittedLogsNotice @@ -33,7 +42,7 @@ enum FeedbackIssueBuilder { let middle = (lowerBound + upperBound) / 2 let candidate = trimmedLogs(from: sanitizedLogs, maxTailCharacters: middle) - guard let url = uncappedIssueURL(sanitizedLogs: candidate) else { + guard let url = uncappedIssueURL(sanitizedLogs: candidate, diagnostics: diagnostics) else { upperBound = middle - 1 continue } @@ -62,20 +71,39 @@ enum FeedbackIssueBuilder { return "\(omittedLogsNotice)\n\(tail)" } - private static func uncappedIssueURL(sanitizedLogs: String) -> URL? { + private static func fittingDiagnostics(from diagnostics: String) -> String { + guard diagnostics.count > maxDiagnosticsCharacters else { return diagnostics } + var tail = String(diagnostics.suffix(maxDiagnosticsCharacters)) + if let firstNewline = tail.firstIndex(of: "\n") { + tail = String(tail[tail.index(after: firstNewline)...]) + } + return "\(omittedDiagnosticsNotice)\n\(tail)" + } + + private static func uncappedIssueURL(sanitizedLogs: String, diagnostics: String?) -> URL? { var components = URLComponents(string: issueURLString) components?.queryItems = [ URLQueryItem(name: "title", value: title), - URLQueryItem(name: "body", value: body(logs: sanitizedLogs)) + URLQueryItem(name: "body", value: body(logs: sanitizedLogs, diagnostics: diagnostics)) ] return components?.url } - private static func body(logs: String) -> String { - """ + private static func body(logs: String, diagnostics: String?) -> String { + let diagnosticsText: String + if let diagnostics, !diagnostics.isEmpty { + diagnosticsText = diagnostics + } else { + diagnosticsText = "No diagnostics attached." + } + return """ What happened: [describe the issue here] + --- + Diagnostics: + \(diagnosticsText) + --- Logs: \(logs) diff --git a/Sources/UI/Shared/SupportDiagnosticsBundle.swift b/Sources/UI/Shared/SupportDiagnosticsBundle.swift new file mode 100644 index 00000000..7bcac2d0 --- /dev/null +++ b/Sources/UI/Shared/SupportDiagnosticsBundle.swift @@ -0,0 +1,114 @@ +import Foundation + +struct SupportDiagnosticsSnapshot: Equatable { + var appVersion: String + var buildVersion: String + var osVersion: String + var crashReportingAvailable: Bool + var crashReportingEnabled: Bool + var analyticsAvailable: Bool + var analyticsEnabled: Bool + var microphoneStatus: String + var systemAudioRecordingGranted: Bool + var pastebackGranted: Bool + var calendarGranted: Bool + var audioRoute: [String: String] + var runtime: [String: String] + var meetingState: String + var meetingRecording: Bool + var meetingDurationBucket: String + var recentLogLines: [String] +} + +enum SupportDiagnosticsBundle { + static let maxRecentLogLines = 20 + + static func text(snapshot: SupportDiagnosticsSnapshot, now: Date = Date()) -> String { + let recentLogs = snapshot.recentLogLines + .suffix(maxRecentLogLines) + .map(AnalyticsPayloadSanitizer.redact) + .filter { !$0.isEmpty } + + return """ + Transcripted diagnostics + Generated: \(ISO8601DateFormatter().string(from: now)) + + App + Version: \(snapshot.appVersion) + Build: \(snapshot.buildVersion) + macOS: \(snapshot.osVersion) + + Reporting + Crash reporting: \(status(available: snapshot.crashReportingAvailable, enabled: snapshot.crashReportingEnabled)) + Anonymous analytics: \(status(available: snapshot.analyticsAvailable, enabled: snapshot.analyticsEnabled)) + + Permissions + Microphone: \(snapshot.microphoneStatus) + System audio recording: \(bool(snapshot.systemAudioRecordingGranted)) + Paste-back accessibility: \(bool(snapshot.pastebackGranted)) + Calendar: \(bool(snapshot.calendarGranted)) + + Runtime + \(render(snapshot.runtime)) + + Audio Route + \(render(snapshot.audioRoute)) + + Meeting + State: \(snapshot.meetingState) + Recording: \(bool(snapshot.meetingRecording)) + Duration: \(snapshot.meetingDurationBucket) + + Recent Events + \(recentLogs.isEmpty ? "No recent in-app events." : recentLogs.joined(separator: "\n")) + + Privacy + This diagnostic summary is designed to exclude transcript text, raw audio, file paths, device names, meeting titles, speaker names, emails, tokens, and raw URLs. + """ + } + + static func sentryContext(snapshot: SupportDiagnosticsSnapshot) -> [String: String] { + var context: [String: String] = [ + "analytics_available": bool(snapshot.analyticsAvailable), + "analytics_enabled": bool(snapshot.analyticsEnabled), + "app_version": snapshot.appVersion, + "build_version": snapshot.buildVersion, + "calendar_granted": bool(snapshot.calendarGranted), + "crash_reporting_available": bool(snapshot.crashReportingAvailable), + "crash_reporting_enabled": bool(snapshot.crashReportingEnabled), + "meeting_duration_bucket": snapshot.meetingDurationBucket, + "meeting_recording": bool(snapshot.meetingRecording), + "meeting_state": snapshot.meetingState, + "microphone_status": snapshot.microphoneStatus, + "pasteback_granted": bool(snapshot.pastebackGranted), + "system_audio_recording_granted": bool(snapshot.systemAudioRecordingGranted), + ] + + for (key, value) in snapshot.audioRoute { + context["route_\(key)"] = value + } + + for (key, value) in snapshot.runtime { + context["runtime_\(key)"] = value + } + + return context + } + + private static func render(_ values: [String: String]) -> String { + guard !values.isEmpty else { return "Unavailable" } + return values + .sorted { $0.key < $1.key } + .map { "\($0.key): \($0.value)" } + .joined(separator: "\n") + } + + private static func status(available: Bool, enabled: Bool) -> String { + guard available else { return "unavailable" } + return enabled ? "enabled" : "disabled" + } + + private static func bool(_ value: Bool) -> String { + value ? "true" : "false" + } +} diff --git a/Sources/UI/Shared/TranscriptedSupportActions.swift b/Sources/UI/Shared/TranscriptedSupportActions.swift index 2aad692b..9a2a7303 100644 --- a/Sources/UI/Shared/TranscriptedSupportActions.swift +++ b/Sources/UI/Shared/TranscriptedSupportActions.swift @@ -1,18 +1,55 @@ import AppKit +import AVFoundation import Foundation @MainActor enum TranscriptedSupportActions { + static func sendFeedback(appState: TranscriptedAppState) { + guard let url = feedbackIssueURL(appState: appState) else { return } + AppSoundPlayer.shared.play(.feedbackSubmitted, respectingPreferences: false) + NSWorkspace.shared.open(url) + } + static func sendFeedback(logger: AppLogger?) { - guard let url = feedbackIssueURL(logger: logger) else { return } + guard let url = FeedbackIssueBuilder.issueURL(rawLogLines: logger?.entries) else { return } AppSoundPlayer.shared.play(.feedbackSubmitted, respectingPreferences: false) NSWorkspace.shared.open(url) } + static func copyDiagnostics(appState: TranscriptedAppState) -> Bool { + let text = diagnosticsText(appState: appState) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + let copied = pasteboard.setString(text, forType: .string) + if copied { + AnalyticsReporter.track("support_diagnostics_copied") + } + return copied + } + + static func sendDiagnosticEvent(appState: TranscriptedAppState) -> String? { + let snapshot = diagnosticsSnapshot(appState: appState) + let context = SupportDiagnosticsBundle.sentryContext(snapshot: snapshot) + + AnalyticsReporter.track("support_diagnostic_event_sent") + return CrashReporter.shared.captureSupportDiagnosticEvent(extra: context) + } + static func feedbackIssueURL(logger: AppLogger?) -> URL? { FeedbackIssueBuilder.issueURL(rawLogLines: logger?.entries) } + static func feedbackIssueURL(appState: TranscriptedAppState) -> URL? { + FeedbackIssueBuilder.issueURL( + rawLogLines: appState.logger.entries, + diagnostics: diagnosticsText(appState: appState) + ) + } + + static func diagnosticsText(appState: TranscriptedAppState) -> String { + SupportDiagnosticsBundle.text(snapshot: diagnosticsSnapshot(appState: appState)) + } + static var appVersionDescription: String { let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String let buildVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String @@ -28,4 +65,66 @@ enum TranscriptedSupportActions { return "Version unavailable" } } + + private static func diagnosticsSnapshot(appState: TranscriptedAppState) -> SupportDiagnosticsSnapshot { + let meetingState: String + let meetingRecording: Bool + let meetingDurationBucket: String + if #available(macOS 14.0, *) { + meetingState = meetingStateName(appState.meetingSession.state) + meetingRecording = appState.meetingSession.isRecording + meetingDurationBucket = AnalyticsReporter.durationBucket(seconds: appState.meetingSession.recordingDuration) + } else { + meetingState = "unavailable" + meetingRecording = false + meetingDurationBucket = "lt_10s" + } + + return SupportDiagnosticsSnapshot( + appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown", + buildVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown", + osVersion: ProcessInfo.processInfo.operatingSystemVersionString, + crashReportingAvailable: CrashReporter.isAvailable, + crashReportingEnabled: CrashReportingPreferences.isEnabled(), + analyticsAvailable: AnalyticsReporter.isAvailable, + analyticsEnabled: AnalyticsPreferences.isEnabled(), + microphoneStatus: microphoneStatusName(TranscriptedPermissionAccess.microphoneAuthorizationStatus()), + systemAudioRecordingGranted: TranscriptedPermissionAccess.isGranted(.systemAudioRecording), + pastebackGranted: TranscriptedPermissionAccess.isGranted(.accessibility), + calendarGranted: TranscriptedPermissionAccess.isGranted(.calendar), + audioRoute: appState.sttRouter.dictationAudioRouteAnalyticsContext, + runtime: appState.runtimeDiagnostics.currentAnalyticsContext(), + meetingState: meetingState, + meetingRecording: meetingRecording, + meetingDurationBucket: meetingDurationBucket, + recentLogLines: appState.logger.entries + ) + } + + private static func microphoneStatusName(_ status: AVAuthorizationStatus) -> String { + switch status { + case .authorized: + return "authorized" + case .denied: + return "denied" + case .restricted: + return "restricted" + case .notDetermined: + return "not_determined" + @unknown default: + return "unknown" + } + } + + @available(macOS 14.0, *) + private static func meetingStateName(_ state: MeetingSessionController.State) -> String { + switch state { + case .idle: return "idle" + case .loadingModels: return "loading_models" + case .ready: return "ready" + case .recording: return "recording" + case .transcribing: return "transcribing" + case .error: return "error" + } + } } diff --git a/Tests/AnalyticsEventPolicyTests.swift b/Tests/AnalyticsEventPolicyTests.swift index bc9761ab..99adc9bf 100644 --- a/Tests/AnalyticsEventPolicyTests.swift +++ b/Tests/AnalyticsEventPolicyTests.swift @@ -94,6 +94,33 @@ func testAnalyticsEventPolicy() { assertEqual(sanitized["surface"], "settings_about", "update surface should survive sanitization") } + runSuite("AnalyticsEventPolicy allows runtime diagnostic events") { + let unclean = AnalyticsEventPolicy.policy(forEvent: "app_unclean_shutdown_detected") + let stall = AnalyticsEventPolicy.policy(forEvent: "app_session_stall_detected") + let copied = AnalyticsEventPolicy.policy(forEvent: "support_diagnostics_copied") + let sent = AnalyticsEventPolicy.policy(forEvent: "support_diagnostic_event_sent") + + assertEqual(unclean?.allowedProperties.contains("session_stage"), true, "unclean shutdown should preserve last session stage") + assertEqual(unclean?.allowedProperties.contains("heartbeat_age_bucket"), true, "unclean shutdown should preserve heartbeat age bucket") + assertEqual(stall?.allowedProperties.contains("stall_stage"), true, "session stall should preserve stall stage") + assertEqual(stall?.allowedProperties.contains("duration_bucket"), true, "session stall should preserve duration bucket") + assertNotNil(copied, "copy diagnostics event should be allowlisted") + assertNotNil(sent, "send diagnostic event should be allowlisted") + + let sanitized = AnalyticsPayloadSanitizer.sanitizeProperties( + [ + "duration_bucket": "30_119s", + "heartbeat_age_bucket": "1_4m", + "session_kind": "dictation", + "session_stage": "recording", + "stall_stage": "microphone_start_timeout", + ], + allowedKeys: stall?.allowedProperties ?? [] + ) + assertEqual(sanitized["session_stage"], "recording", "session stage should survive sanitization") + assertEqual(sanitized["stall_stage"], "microphone_start_timeout", "stall stage should survive sanitization") + } + runSuite("AnalyticsEventPolicy preserves dictation auto-send attribution") { let dictationCompleted = AnalyticsEventPolicy.policy(forEvent: "dictation_completed") assertEqual(dictationCompleted?.allowedProperties.contains("auto_send"), true, "dictation completion should allow the existing auto_send property") diff --git a/Tests/FastTests.manifest b/Tests/FastTests.manifest index 44d9bb1f..23504a9a 100644 --- a/Tests/FastTests.manifest +++ b/Tests/FastTests.manifest @@ -17,6 +17,8 @@ AnalyticsReporterTests.swift:testAnalyticsReporter MeetingFailureKindTests.swift:testMeetingFailureKind ObservabilityPreferencesTests.swift:testObservabilityPreferences ObservabilityLogWriterTests.swift:testObservabilityLogWriter +RuntimeDiagnosticsStoreTests.swift:testRuntimeDiagnosticsStore +SupportDiagnosticsBundleTests.swift:testSupportDiagnosticsBundle MeetingTranscriptStylerTests.swift:testMeetingTranscriptStyler MeetingAudioArchiveResolverTests.swift:testMeetingAudioArchiveResolver MeetingPromptDetectorTests.swift:testMeetingPromptDetector diff --git a/Tests/FeedbackIssueBuilderTests.swift b/Tests/FeedbackIssueBuilderTests.swift index 0f2ffda3..93b62c0b 100644 --- a/Tests/FeedbackIssueBuilderTests.swift +++ b/Tests/FeedbackIssueBuilderTests.swift @@ -13,6 +13,7 @@ func testFeedbackIssueBuilder() { let body = feedbackBody(from: url) assertTrue(body.contains("What happened:"), "issue body should include the feedback prompt") + assertTrue(body.contains("Diagnostics:"), "issue body should include a diagnostics section") assertTrue(body.contains("APP LAUNCHED"), "short logs should remain in the issue body") assertFalse(body.contains("/Users/redbars/"), "paths should be redacted before the URL is built") assertFalse(body.contains("person@example.com"), "emails should be redacted before the URL is built") @@ -42,6 +43,18 @@ func testFeedbackIssueBuilder() { assertFalse(body.contains("marker_0"), "logs older than the latest 80 entries should not be included") assertTrue(body.contains("marker_99"), "latest log entry should be included") } + + runSuite("FeedbackIssueBuilder attaches sanitized diagnostics") { + let url = FeedbackIssueBuilder.issueURL( + rawLogLines: ["[14:00:00.000] APP LAUNCHED"], + diagnostics: "Route bluetooth_input_to_built_in_output from /Users/redbars/private.txt for person@example.com" + ) + let body = feedbackBody(from: url) + + assertTrue(body.contains("bluetooth_input_to_built_in_output"), "safe diagnostic route shape should be included") + assertFalse(body.contains("/Users/redbars"), "diagnostic paths should be redacted") + assertFalse(body.contains("person@example.com"), "diagnostic emails should be redacted") + } } private func feedbackBody(from url: URL?) -> String { diff --git a/Tests/RuntimeDiagnosticsStoreTests.swift b/Tests/RuntimeDiagnosticsStoreTests.swift new file mode 100644 index 00000000..9de2a9c1 --- /dev/null +++ b/Tests/RuntimeDiagnosticsStoreTests.swift @@ -0,0 +1,52 @@ +import Foundation + +func testRuntimeDiagnosticsStore() { + runSuite("RuntimeDiagnosticsStore builds unclean shutdown context without raw dates") { + let marker = RuntimeDiagnosticsMarker( + launchID: "launch-1", + appVersion: "1.2.3", + buildVersion: "456", + osMajor: 26, + cleanShutdown: false, + startedAt: Date(timeIntervalSince1970: 1_000), + updatedAt: Date(timeIntervalSince1970: 1_100), + lastEvent: "dictation_recording", + sessionKind: "dictation", + sessionStage: "recording", + sessionActive: true + ) + + let context = RuntimeDiagnosticsStore.contextForUncleanShutdown( + previous: marker, + now: Date(timeIntervalSince1970: 1_220) + ) + + assertEqual(context["app_version"], "1.2.3", "context should keep app version") + assertEqual(context["build_version"], "456", "context should keep build version") + assertEqual(context["heartbeat_age_bucket"], "1_4m", "context should bucket heartbeat age") + assertEqual(context["last_event"], "dictation_recording", "context should keep last runtime event") + assertEqual(context["session_active"], "true", "context should keep whether a session was active") + assertEqual(context["session_kind"], "dictation", "context should keep coarse session kind") + assertEqual(context["session_stage"], "recording", "context should keep coarse session stage") + } + + runSuite("RuntimeDiagnosticsStore round-trips marker files") { + let url = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("RuntimeDiagnosticsStoreTests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent(RuntimeDiagnosticsStore.markerFileName) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let marker = RuntimeDiagnosticsStore.makeLaunchMarker( + launchID: "roundtrip", + appVersion: "1.0", + buildVersion: "7", + osMajor: 26, + now: Date(timeIntervalSince1970: 100) + ) + + RuntimeDiagnosticsStore.save(marker, to: url) + let loaded = RuntimeDiagnosticsStore.load(from: url) + + assertEqual(loaded, marker, "runtime diagnostics marker should round-trip through JSON") + } +} diff --git a/Tests/SentryEventPolicyTests.swift b/Tests/SentryEventPolicyTests.swift index 04e7fd0b..610720ee 100644 --- a/Tests/SentryEventPolicyTests.swift +++ b/Tests/SentryEventPolicyTests.swift @@ -6,6 +6,14 @@ func testSentryEventPolicy() { forEngine: "parakeet", event: "transcription_failed" ) + let uncleanShutdown = SentryEventPolicy.policy( + forEngine: "app", + event: "unclean_shutdown_detected" + ) + let sessionStall = SentryEventPolicy.policy( + forEngine: "app", + event: "session_stall_detected" + ) let hotkeyFailure = SentryEventPolicy.policy( forEngine: "capture", event: "hotkey_register_failed" @@ -56,6 +64,8 @@ func testSentryEventPolicy() { ) assertEqual(transcriptionFailure?.summary, "Speech transcription failed.", "transcription failure should use the normalized summary") + assertEqual(uncleanShutdown?.summary, "Previous app session did not shut down cleanly.", "unclean shutdown reports should be visible in Sentry") + assertEqual(sessionStall?.summary, "Transcripted detected a stalled runtime session.", "session stalls should be visible in Sentry") assertEqual(hotkeyFailure?.summary, "Transcripted could not register a keyboard shortcut.", "capture failure should stay allowlisted") assertEqual(audioStartFailure?.summary, "Speech audio engine failed to start.", "audio-start failures should stay allowlisted with a privacy-safe summary") assertEqual(microphoneStartTimeout?.summary, "Dictation microphone start timed out.", "microphone start timeouts should be visible in Sentry without raw device names") diff --git a/Tests/SupportDiagnosticsBundleTests.swift b/Tests/SupportDiagnosticsBundleTests.swift new file mode 100644 index 00000000..481b77b8 --- /dev/null +++ b/Tests/SupportDiagnosticsBundleTests.swift @@ -0,0 +1,76 @@ +import Foundation + +func testSupportDiagnosticsBundle() { + runSuite("SupportDiagnosticsBundle text excludes obvious sensitive values") { + let snapshot = SupportDiagnosticsSnapshot( + appVersion: "1.2.3", + buildVersion: "456", + osVersion: "Version 26.0", + crashReportingAvailable: true, + crashReportingEnabled: true, + analyticsAvailable: true, + analyticsEnabled: false, + microphoneStatus: "authorized", + systemAudioRecordingGranted: true, + pastebackGranted: true, + calendarGranted: false, + audioRoute: [ + "input_device_class": "bluetooth", + "input_rate_hz": "24000", + "route_shape": "bluetooth_input_to_built_in_output", + ], + runtime: [ + "session_kind": "dictation", + "session_stage": "recording", + "session_active": "true", + ], + meetingState: "ready", + meetingRecording: false, + meetingDurationBucket: "lt_10s", + recentLogLines: [ + "Opened /Users/redbars/Library/Application Support/Transcripted/logs/app.jsonl", + "Email person@example.com should not leak", + ] + ) + + let text = SupportDiagnosticsBundle.text( + snapshot: snapshot, + now: Date(timeIntervalSince1970: 100) + ) + + assertTrue(text.contains("Version: 1.2.3"), "diagnostics should include app version") + assertTrue(text.contains("input_device_class: bluetooth"), "diagnostics should include coarse route facts") + assertTrue(text.contains("session_stage: recording"), "diagnostics should include runtime session stage") + assertFalse(text.contains("/Users/redbars"), "diagnostics should redact home paths") + assertFalse(text.contains("person@example.com"), "diagnostics should redact emails") + assertFalse(text.contains("Application Support/Transcripted"), "diagnostics should redact app support paths") + } + + runSuite("SupportDiagnosticsBundle Sentry context avoids sanitizer-dropped audio keys") { + let snapshot = SupportDiagnosticsSnapshot( + appVersion: "1.2.3", + buildVersion: "456", + osVersion: "Version 26.0", + crashReportingAvailable: true, + crashReportingEnabled: true, + analyticsAvailable: true, + analyticsEnabled: true, + microphoneStatus: "authorized", + systemAudioRecordingGranted: true, + pastebackGranted: true, + calendarGranted: true, + audioRoute: ["input_device_class": "bluetooth"], + runtime: ["session_stage": "recording"], + meetingState: "recording", + meetingRecording: true, + meetingDurationBucket: "2_9m", + recentLogLines: [] + ) + + let context = SupportDiagnosticsBundle.sentryContext(snapshot: snapshot) + let sanitized = SentryPayloadSanitizer.sanitizeContext(context) + + assertEqual(sanitized["route_input_device_class"], "bluetooth", "route context should survive Sentry key sanitization") + assertNil(sanitized["audio_input_device_class"], "support diagnostics should not use audio-prefixed Sentry keys") + } +} diff --git a/docs/storage-paths.md b/docs/storage-paths.md index 00e904e4..cfc1065e 100644 --- a/docs/storage-paths.md +++ b/docs/storage-paths.md @@ -40,6 +40,7 @@ App-owned meeting state is stored separately under: - speaker DB: `~/Library/Application Support/Transcripted/state/speakers.sqlite` - stats DB: `~/Library/Application Support/Transcripted/state/stats.sqlite` - failed queue: `~/Library/Application Support/Transcripted/state/failed_transcriptions.json` +- runtime diagnostics marker: `~/Library/Application Support/Transcripted/state/runtime-diagnostics.json` Claude Desktop integration installs the bundled read-only MCP helper under: diff --git a/scripts/entrypoints/run-tests.sh b/scripts/entrypoints/run-tests.sh index 495f3524..3fbfaada 100755 --- a/scripts/entrypoints/run-tests.sh +++ b/scripts/entrypoints/run-tests.sh @@ -159,6 +159,7 @@ APP_SOURCES=( "Sources/Observability/AnalyticsPayloadSanitizer.swift" "Sources/Observability/AnalyticsPreferences.swift" "Sources/Observability/CrashReportingPreferences.swift" + "Sources/Observability/RuntimeDiagnosticsStore.swift" "Sources/Observability/UpdateFailureKind.swift" "Sources/Observability/SentryRuntimeConfiguration.swift" "Sources/Observability/SentryEventPolicy.swift" @@ -171,6 +172,7 @@ APP_SOURCES=( "Sources/TranscriptedCore/Speaker/SpeakerNamingPolicy.swift" "Sources/UI/Shared/AgentConnectionGuide.swift" "Sources/UI/Shared/FeedbackIssueBuilder.swift" + "Sources/UI/Shared/SupportDiagnosticsBundle.swift" "Sources/UI/Shared/FirstRunExperience.swift" "Sources/UI/Shared/AppSoundPlayer.swift" "Sources/UI/Settings/TranscriptedSettingsPage.swift" From cc8181d2bace22a9040ff572e97564f55b3d6fe2 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Thu, 30 Apr 2026 06:23:46 -0500 Subject: [PATCH 03/22] Route feedback to support email --- Sources/UI/CLAUDE.md | 2 +- Sources/UI/Shared/FeedbackIssueBuilder.swift | 37 ++++++++-------- .../Shared/TranscriptedSupportActions.swift | 12 +++--- Tests/FeedbackIssueBuilderTests.swift | 42 ++++++++++++------- 4 files changed, 54 insertions(+), 39 deletions(-) diff --git a/Sources/UI/CLAUDE.md b/Sources/UI/CLAUDE.md index db159bff..8c321786 100644 --- a/Sources/UI/CLAUDE.md +++ b/Sources/UI/CLAUDE.md @@ -85,7 +85,7 @@ The current agent-connect surfaces should keep one simple mental model: - `Shared/AgentConnectionGuide.swift` — shared smart-prompt, MCP setup, and folder fallback copy for the agent-connect flow - `Shared/AppSoundPlayer.swift` — UI sound preferences and playback helpers -- `Shared/FeedbackIssueBuilder.swift` — builds sanitized feedback issue payloads and support links from current app state +- `Shared/FeedbackIssueBuilder.swift` — builds sanitized support email payloads and links from current app state - `Shared/FirstRunExperience.swift` — shared first-run menu and onboarding state helpers for permission, local-model, dictation, and meeting CTA copy - `Shared/MeetingAudioArchiveResolver.swift` — resolves retained meeting-audio attachments that belong to a saved transcript for review playback - `Shared/MeetingAudioPlayback.swift` — shared play/pause/resume `NSSound`-backed controller for recent-meeting audio previews in Settings diff --git a/Sources/UI/Shared/FeedbackIssueBuilder.swift b/Sources/UI/Shared/FeedbackIssueBuilder.swift index 3f824b10..02e91391 100644 --- a/Sources/UI/Shared/FeedbackIssueBuilder.swift +++ b/Sources/UI/Shared/FeedbackIssueBuilder.swift @@ -1,33 +1,34 @@ import Foundation enum FeedbackIssueBuilder { - static let issueURLString = "https://github.com/r3dbars/transcripted/issues/new" - static let maxIssueURLCharacterCount = 6_000 + static let supportEmailAddress = "help@transcripted.app" + static let emailURLString = "mailto:\(supportEmailAddress)" + static let maxEmailURLCharacterCount = 6_000 private static let title = "Transcripted Feedback" private static let maxLogLines = 80 private static let maxDiagnosticsCharacters = 2_500 - private static let omittedLogsNotice = "[Older logs omitted because GitHub rejects very long feedback URLs.]" - private static let omittedDiagnosticsNotice = "[Older diagnostics omitted because GitHub rejects very long feedback URLs.]" + private static let omittedLogsNotice = "[Older logs omitted because the feedback email got too long.]" + private static let omittedDiagnosticsNotice = "[Older diagnostics omitted because the feedback email got too long.]" private static let noLogsMessage = "No in-app logs attached." - static func issueURL(rawLogLines: [String]?, diagnostics: String? = nil) -> URL? { + static func emailURL(rawLogLines: [String]?, diagnostics: String? = nil) -> URL? { let rawLogs = rawLogLines?.suffix(maxLogLines).joined(separator: "\n") ?? noLogsMessage let sanitizedLogs = AnalyticsPayloadSanitizer.redact(rawLogs) let sanitizedDiagnostics = diagnostics.map { fittingDiagnostics(from: AnalyticsPayloadSanitizer.redact($0)) } - return issueURL( + return emailURL( sanitizedLogs: sanitizedLogs.isEmpty ? noLogsMessage : sanitizedLogs, diagnostics: sanitizedDiagnostics ) } - static func issueURL(sanitizedLogs: String, diagnostics: String? = nil) -> URL? { - if let url = uncappedIssueURL(sanitizedLogs: sanitizedLogs, diagnostics: diagnostics), - url.absoluteString.count <= maxIssueURLCharacterCount { + static func emailURL(sanitizedLogs: String, diagnostics: String? = nil) -> URL? { + if let url = uncappedEmailURL(sanitizedLogs: sanitizedLogs, diagnostics: diagnostics), + url.absoluteString.count <= maxEmailURLCharacterCount { return url } - return uncappedIssueURL( + return uncappedEmailURL( sanitizedLogs: fittingTrimmedLogs(from: sanitizedLogs, diagnostics: diagnostics), diagnostics: diagnostics ) @@ -42,12 +43,12 @@ enum FeedbackIssueBuilder { let middle = (lowerBound + upperBound) / 2 let candidate = trimmedLogs(from: sanitizedLogs, maxTailCharacters: middle) - guard let url = uncappedIssueURL(sanitizedLogs: candidate, diagnostics: diagnostics) else { + guard let url = uncappedEmailURL(sanitizedLogs: candidate, diagnostics: diagnostics) else { upperBound = middle - 1 continue } - if url.absoluteString.count <= maxIssueURLCharacterCount { + if url.absoluteString.count <= maxEmailURLCharacterCount { best = candidate lowerBound = middle + 1 } else { @@ -80,13 +81,15 @@ enum FeedbackIssueBuilder { return "\(omittedDiagnosticsNotice)\n\(tail)" } - private static func uncappedIssueURL(sanitizedLogs: String, diagnostics: String?) -> URL? { - var components = URLComponents(string: issueURLString) - components?.queryItems = [ - URLQueryItem(name: "title", value: title), + private static func uncappedEmailURL(sanitizedLogs: String, diagnostics: String?) -> URL? { + var components = URLComponents() + components.scheme = "mailto" + components.path = supportEmailAddress + components.queryItems = [ + URLQueryItem(name: "subject", value: title), URLQueryItem(name: "body", value: body(logs: sanitizedLogs, diagnostics: diagnostics)) ] - return components?.url + return components.url } private static func body(logs: String, diagnostics: String?) -> String { diff --git a/Sources/UI/Shared/TranscriptedSupportActions.swift b/Sources/UI/Shared/TranscriptedSupportActions.swift index 9a2a7303..a16443ef 100644 --- a/Sources/UI/Shared/TranscriptedSupportActions.swift +++ b/Sources/UI/Shared/TranscriptedSupportActions.swift @@ -5,13 +5,13 @@ import Foundation @MainActor enum TranscriptedSupportActions { static func sendFeedback(appState: TranscriptedAppState) { - guard let url = feedbackIssueURL(appState: appState) else { return } + guard let url = feedbackEmailURL(appState: appState) else { return } AppSoundPlayer.shared.play(.feedbackSubmitted, respectingPreferences: false) NSWorkspace.shared.open(url) } static func sendFeedback(logger: AppLogger?) { - guard let url = FeedbackIssueBuilder.issueURL(rawLogLines: logger?.entries) else { return } + guard let url = FeedbackIssueBuilder.emailURL(rawLogLines: logger?.entries) else { return } AppSoundPlayer.shared.play(.feedbackSubmitted, respectingPreferences: false) NSWorkspace.shared.open(url) } @@ -35,12 +35,12 @@ enum TranscriptedSupportActions { return CrashReporter.shared.captureSupportDiagnosticEvent(extra: context) } - static func feedbackIssueURL(logger: AppLogger?) -> URL? { - FeedbackIssueBuilder.issueURL(rawLogLines: logger?.entries) + static func feedbackEmailURL(logger: AppLogger?) -> URL? { + FeedbackIssueBuilder.emailURL(rawLogLines: logger?.entries) } - static func feedbackIssueURL(appState: TranscriptedAppState) -> URL? { - FeedbackIssueBuilder.issueURL( + static func feedbackEmailURL(appState: TranscriptedAppState) -> URL? { + FeedbackIssueBuilder.emailURL( rawLogLines: appState.logger.entries, diagnostics: diagnosticsText(appState: appState) ) diff --git a/Tests/FeedbackIssueBuilderTests.swift b/Tests/FeedbackIssueBuilderTests.swift index 93b62c0b..7c59d223 100644 --- a/Tests/FeedbackIssueBuilderTests.swift +++ b/Tests/FeedbackIssueBuilderTests.swift @@ -1,43 +1,45 @@ import Foundation func testFeedbackIssueBuilder() { - runSuite("FeedbackIssueBuilder builds a normal GitHub issue URL") { - let url = FeedbackIssueBuilder.issueURL(rawLogLines: [ + runSuite("FeedbackIssueBuilder builds a normal support email URL") { + let url = FeedbackIssueBuilder.emailURL(rawLogLines: [ "[12:00:00.000] APP LAUNCHED | modes: dictation + meetings", "[12:01:00.000] ERROR | wrote /Users/redbars/private.txt for person@example.com via https://example.com/log" ]) - assertNotNil(url, "feedback issue URL should be created") - assertTrue(url?.absoluteString.hasPrefix(FeedbackIssueBuilder.issueURLString) == true, "URL should target GitHub issues/new") - assertTrue((url?.absoluteString.count ?? 0) <= FeedbackIssueBuilder.maxIssueURLCharacterCount, "normal URL should stay under the cap") + assertNotNil(url, "feedback email URL should be created") + assertTrue(url?.absoluteString.hasPrefix(FeedbackIssueBuilder.emailURLString) == true, "URL should target the support email address") + assertTrue((url?.absoluteString.count ?? 0) <= FeedbackIssueBuilder.maxEmailURLCharacterCount, "normal URL should stay under the cap") let body = feedbackBody(from: url) - assertTrue(body.contains("What happened:"), "issue body should include the feedback prompt") - assertTrue(body.contains("Diagnostics:"), "issue body should include a diagnostics section") - assertTrue(body.contains("APP LAUNCHED"), "short logs should remain in the issue body") + let subject = feedbackSubject(from: url) + assertEqual(subject, "Transcripted Feedback", "email subject should be set") + assertTrue(body.contains("What happened:"), "email body should include the feedback prompt") + assertTrue(body.contains("Diagnostics:"), "email body should include a diagnostics section") + assertTrue(body.contains("APP LAUNCHED"), "short logs should remain in the email body") assertFalse(body.contains("/Users/redbars/"), "paths should be redacted before the URL is built") assertFalse(body.contains("person@example.com"), "emails should be redacted before the URL is built") assertFalse(body.contains("https://example.com/log"), "URLs should be redacted before the URL is built") } - runSuite("FeedbackIssueBuilder trims huge logs before GitHub rejects the URL") { + runSuite("FeedbackIssueBuilder trims huge logs before the feedback email gets too long") { let longLines = (0..<140).map { index in "[15:11:\(String(format: "%02d", index)).000] DIAG | capture.right_option_pressed | marker_\(index) \(String(repeating: "extra diagnostic context ", count: 8))" } - let url = FeedbackIssueBuilder.issueURL(rawLogLines: longLines) + let url = FeedbackIssueBuilder.emailURL(rawLogLines: longLines) - assertNotNil(url, "feedback issue URL should still be created for huge logs") - assertTrue((url?.absoluteString.count ?? 0) <= FeedbackIssueBuilder.maxIssueURLCharacterCount, "huge log URL should be capped") + assertNotNil(url, "feedback email URL should still be created for huge logs") + assertTrue((url?.absoluteString.count ?? 0) <= FeedbackIssueBuilder.maxEmailURLCharacterCount, "huge log URL should be capped") let body = feedbackBody(from: url) - assertTrue(body.contains("Older logs omitted"), "trimmed issue body should explain why older logs are missing") + assertTrue(body.contains("Older logs omitted"), "trimmed email body should explain why older logs are missing") assertTrue(body.contains("marker_139"), "newest useful log lines should be kept") assertFalse(body.contains("marker_0"), "oldest logs should be omitted") } runSuite("FeedbackIssueBuilder keeps only the latest in-app log window") { let lines = (0..<100).map { "[14:00:\($0)] marker_\($0)" } - let url = FeedbackIssueBuilder.issueURL(rawLogLines: lines) + let url = FeedbackIssueBuilder.emailURL(rawLogLines: lines) let body = feedbackBody(from: url) assertFalse(body.contains("marker_0"), "logs older than the latest 80 entries should not be included") @@ -45,7 +47,7 @@ func testFeedbackIssueBuilder() { } runSuite("FeedbackIssueBuilder attaches sanitized diagnostics") { - let url = FeedbackIssueBuilder.issueURL( + let url = FeedbackIssueBuilder.emailURL( rawLogLines: ["[14:00:00.000] APP LAUNCHED"], diagnostics: "Route bluetooth_input_to_built_in_output from /Users/redbars/private.txt for person@example.com" ) @@ -66,3 +68,13 @@ private func feedbackBody(from url: URL?) -> String { return body } + +private func feedbackSubject(from url: URL?) -> String { + guard let url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let subject = components.queryItems?.first(where: { $0.name == "subject" })?.value else { + return "" + } + + return subject +} From b5e65a4f00911004e33ee342f0755e59114e12f3 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Thu, 30 Apr 2026 09:40:31 -0500 Subject: [PATCH 04/22] Add settings support page --- Sources/UI/CLAUDE.md | 2 +- .../SettingsRecentCaptureRefreshPolicy.swift | 2 +- .../Settings/TranscriptedSettingsPage.swift | 7 ++- .../Settings/TranscriptedSettingsView.swift | 63 +++++++++++++++++-- ...tingsRecentCaptureRefreshPolicyTests.swift | 2 +- 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/Sources/UI/CLAUDE.md b/Sources/UI/CLAUDE.md index 8c321786..ae4a7890 100644 --- a/Sources/UI/CLAUDE.md +++ b/Sources/UI/CLAUDE.md @@ -77,7 +77,7 @@ The current agent-connect surfaces should keep one simple mental model: - `Settings/TranscriptedSettingsActions.swift` — struct of callbacks (start dictation, start meeting, import audio, paste, connect agent, check updates, send feedback, copy/send diagnostics) injected into the settings view - `Settings/TranscriptedSettingsComponents.swift` — shared SwiftUI building blocks (`SettingsPageIntro`, `SettingsSection`) used across settings pages - `Settings/TranscriptedSettingsNavigationModel.swift` — observable navigation state for the current `TranscriptedSettingsPage` selection -- `Settings/TranscriptedSettingsPage.swift` — enum of settings pages (home, general, models, shortcuts, meetings, dictations, people, storage, connectAgent, privacy, about) with titles, summaries, and SF Symbol names +- `Settings/TranscriptedSettingsPage.swift` — enum of settings pages (home, general, models, shortcuts, meetings, dictations, people, storage, connectAgent, privacy, support, about) with titles, summaries, and SF Symbol names - `Settings/TranscriptedSettingsView.swift` — main settings view - `Settings/TranscriptedSettingsWindowController.swift` — NSWindowController for settings diff --git a/Sources/UI/Settings/SettingsRecentCaptureRefreshPolicy.swift b/Sources/UI/Settings/SettingsRecentCaptureRefreshPolicy.swift index 69834454..7d0607ea 100644 --- a/Sources/UI/Settings/SettingsRecentCaptureRefreshPolicy.swift +++ b/Sources/UI/Settings/SettingsRecentCaptureRefreshPolicy.swift @@ -13,7 +13,7 @@ enum SettingsRecentCaptureRefreshPolicy { return .homeDashboard case .meetings, .dictations: return .recentLists - case .general, .models, .shortcuts, .people, .storage, .connectAgent, .privacy, .about: + case .general, .models, .shortcuts, .people, .storage, .connectAgent, .privacy, .support, .about: return .none } } diff --git a/Sources/UI/Settings/TranscriptedSettingsPage.swift b/Sources/UI/Settings/TranscriptedSettingsPage.swift index aa51749e..86d0bf3d 100644 --- a/Sources/UI/Settings/TranscriptedSettingsPage.swift +++ b/Sources/UI/Settings/TranscriptedSettingsPage.swift @@ -11,6 +11,7 @@ enum TranscriptedSettingsPage: String, CaseIterable, Identifiable { case storage case connectAgent case privacy + case support case about var id: String { rawValue } @@ -36,6 +37,7 @@ enum TranscriptedSettingsPage: String, CaseIterable, Identifiable { case .storage: return "Storage" case .connectAgent: return "Agent" case .privacy: return "Privacy" + case .support: return "Support" case .about: return "About" } } @@ -62,8 +64,10 @@ enum TranscriptedSettingsPage: String, CaseIterable, Identifiable { return "One prompt, plus direct paths." case .privacy: return "Permissions and optional reporting." + case .support: + return "Feedback and diagnostics." case .about: - return "Version, updates, and support." + return "Version and updates." } } @@ -79,6 +83,7 @@ enum TranscriptedSettingsPage: String, CaseIterable, Identifiable { case .storage: return "externaldrive.fill" case .connectAgent: return "sparkles" case .privacy: return "lock.shield.fill" + case .support: return "questionmark.bubble.fill" case .about: return "info.circle.fill" } } diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index a99a62c9..de46d5e3 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -13,7 +13,7 @@ private struct SettingsSidebarSection: Identifiable { SettingsSidebarSection(id: "home", title: nil, pages: [.home]), SettingsSidebarSection(id: "recording", title: "Recording", pages: [.meetings, .dictations, .people, .shortcuts]), SettingsSidebarSection(id: "setup", title: "Setup", pages: [.general, .models, .storage, .connectAgent]), - SettingsSidebarSection(id: "trust", title: "Trust", pages: [.privacy, .about]) + SettingsSidebarSection(id: "trust", title: "Trust", pages: [.privacy, .support, .about]) ] } @@ -239,6 +239,8 @@ struct TranscriptedSettingsView: View { connectAgentPage case .privacy: privacyPage + case .support: + supportPage case .about: aboutPage } @@ -1358,7 +1360,7 @@ struct TranscriptedSettingsView: View { VStack(alignment: .leading, spacing: 24) { SettingsPageIntro( title: "About", - summary: "Version, updates, and support." + summary: "Version and updates." ) SettingsSection( @@ -1409,12 +1411,63 @@ struct TranscriptedSettingsView: View { sparkleUpdater.performUserUpdateAction(surface: "settings_about") } .disabled(!aboutUpdateButtonEnabled) + } + } + } + } + + private var supportPage: some View { + VStack(alignment: .leading, spacing: 24) { + SettingsPageIntro( + title: "Support", + summary: "Feedback and diagnostics." + ) + + SettingsSection( + title: "Contact", + detail: "Opens a prefilled email to help@transcripted.app." + ) { + SettingsStatusCard( + title: "Email support", + status: "help@transcripted.app", + detail: "Includes scrubbed app context so problems are easier to fix.", + tone: .ready + ) + + Button("Send Feedback") { + trackSettingsAction("submit_feedback", page: .support) + actions.sendFeedback() + } + } + + SettingsSection( + title: "Diagnostics", + detail: "Privacy-safe context for debugging device and app issues." + ) { + HStack { + Button("Copy Diagnostics") { + trackSettingsAction("copy_diagnostics", page: .support) + diagnosticsActionStatus = actions.copyDiagnostics() + ? "Copied diagnostics." + : "Could not copy diagnostics." + } + + Button("Send Diagnostic Event") { + trackSettingsAction("send_diagnostic_event", page: .support) + sendDiagnosticEvent() + } + .disabled(!CrashReporter.isAvailable || !crashReportingEnabled) - Button("Submit Feedback") { - trackSettingsAction("submit_feedback", page: .about) - actions.sendFeedback() + if let diagnosticsActionStatus { + Text(diagnosticsActionStatus) + .font(.caption) + .foregroundStyle(.secondary) } } + + Text("Never sent: transcript text, audio, names, emails, file paths, raw URLs, or meeting titles.") + .font(.caption) + .foregroundStyle(.secondary) } } } diff --git a/Tests/SettingsRecentCaptureRefreshPolicyTests.swift b/Tests/SettingsRecentCaptureRefreshPolicyTests.swift index 8f41d383..b305ca69 100644 --- a/Tests/SettingsRecentCaptureRefreshPolicyTests.swift +++ b/Tests/SettingsRecentCaptureRefreshPolicyTests.swift @@ -23,7 +23,7 @@ func testSettingsRecentCaptureRefreshPolicy() { } runSuite("SettingsRecentCaptureRefreshPolicy.mode — skips recent capture work on non-list pages") { - for page in [TranscriptedSettingsPage.general, .models, .shortcuts, .people, .storage, .connectAgent, .privacy, .about] { + for page in [TranscriptedSettingsPage.general, .models, .shortcuts, .people, .storage, .connectAgent, .privacy, .support, .about] { assertEqual( SettingsRecentCaptureRefreshPolicy.mode(for: page), .none, From 037f7d7ef0af0d24fb7da2b3529de09cdc116f00 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Thu, 30 Apr 2026 09:50:14 -0500 Subject: [PATCH 05/22] Refine support feedback flow --- .../UI/MenuBar/MenuBarPanelController.swift | 1 + Sources/UI/MenuBar/MenuBarSettingsView.swift | 15 ++--- .../MenuBar/MenuBarUtilityActionsView.swift | 12 ++-- .../Settings/TranscriptedSettingsView.swift | 63 +++++++------------ 4 files changed, 37 insertions(+), 54 deletions(-) diff --git a/Sources/UI/MenuBar/MenuBarPanelController.swift b/Sources/UI/MenuBar/MenuBarPanelController.swift index 36f19f83..ed2766d2 100644 --- a/Sources/UI/MenuBar/MenuBarPanelController.swift +++ b/Sources/UI/MenuBar/MenuBarPanelController.swift @@ -45,6 +45,7 @@ final class MenuBarPanelController: NSViewController { content.utilityActionsView.onOpenSettings = { [weak self] in self?.openSettingsFromMenu(.home) } content.utilityActionsView.onCheckForUpdates = { [weak self] in self?.performUpdateActionFromMenu() } content.utilityActionsView.onOpenConnectAgent = { [weak self] in self?.openSettingsFromMenu(.connectAgent) } + content.utilityActionsView.onOpenSupport = { [weak self] in self?.openSettingsFromMenu(.support) } content.onUpdateAction = { [weak self] in self?.performUpdateActionFromMenu() } view = content contentView = content diff --git a/Sources/UI/MenuBar/MenuBarSettingsView.swift b/Sources/UI/MenuBar/MenuBarSettingsView.swift index 2fc1c043..4d00bb72 100644 --- a/Sources/UI/MenuBar/MenuBarSettingsView.swift +++ b/Sources/UI/MenuBar/MenuBarSettingsView.swift @@ -24,8 +24,8 @@ final class MenuBarSettingsView: NSView { ) private let feedbackButton = MenuIconButton( symbolName: "bubble.left", - accessibilityLabel: "Send feedback", - toolTip: "Send feedback" + accessibilityLabel: "Submit feedback for support", + toolTip: "Submit feedback for support" ) private let quitButton = MenuIconButton( symbolName: "power", @@ -37,6 +37,7 @@ final class MenuBarSettingsView: NSView { var onOpenSettings: (() -> Void)? var onCheckForUpdates: (() -> Void)? var onOpenAgentConnect: (() -> Void)? + var onOpenSupport: (() -> Void)? override init(frame: NSRect) { super.init(frame: frame) @@ -60,7 +61,7 @@ final class MenuBarSettingsView: NSView { updatesButton.target = self updatesButton.action = #selector(checkForUpdates) feedbackButton.target = self - feedbackButton.action = #selector(sendFeedback) + feedbackButton.action = #selector(openSupport) quitButton.target = self quitButton.action = #selector(quitApp) } @@ -94,10 +95,6 @@ final class MenuBarSettingsView: NSView { connectAgentButton.frame = NSRect(x: 0, y: buttonY, width: connectWidth, height: buttonSize) } - @objc private func sendFeedback() { - TranscriptedSupportActions.sendFeedback(logger: appState?.logger) - } - @objc private func quitApp() { NSApplication.shared.terminate(nil) } @@ -114,6 +111,10 @@ final class MenuBarSettingsView: NSView { onOpenAgentConnect?() } + @objc private func openSupport() { + onOpenSupport?() + } + func dismissTransientUI() {} var intrinsicHeight: CGFloat { MenuTokens.secondaryButtonSize + 8 } diff --git a/Sources/UI/MenuBar/MenuBarUtilityActionsView.swift b/Sources/UI/MenuBar/MenuBarUtilityActionsView.swift index 7671c06f..e4d51666 100644 --- a/Sources/UI/MenuBar/MenuBarUtilityActionsView.swift +++ b/Sources/UI/MenuBar/MenuBarUtilityActionsView.swift @@ -8,6 +8,7 @@ final class MenuBarUtilityActionsView: NSView { var onOpenConnectAgent: (() -> Void)? var onCheckForUpdates: (() -> Void)? var onOpenSettings: (() -> Void)? + var onOpenSupport: (() -> Void)? private let connectAgentRow = MenuBarActionRowView() private let feedbackRow = MenuBarActionRowView() @@ -28,8 +29,7 @@ final class MenuBarUtilityActionsView: NSView { private func setupViews() { connectAgentRow.onPress = { [weak self] in self?.onOpenConnectAgent?() } feedbackRow.onPress = { [weak self] in - self?.trackMenuAction("submit_feedback") - self?.sendFeedback() + self?.onOpenSupport?() } updatesRow.onPress = { [weak self] in self?.onCheckForUpdates?() } settingsRow.onPress = { [weak self] in self?.onOpenSettings?() } @@ -59,8 +59,8 @@ final class MenuBarUtilityActionsView: NSView { feedbackRow.update( symbolName: "bubble.left", - title: "Submit Feedback", - detail: "", + title: "Submit feedback for support", + detail: "Opens the Support tab", tone: .standard, size: .utility ) @@ -112,10 +112,6 @@ final class MenuBarUtilityActionsView: NSView { } } - private func sendFeedback() { - TranscriptedSupportActions.sendFeedback(logger: appState?.logger) - } - private func trackMenuAction(_ actionID: String) { AnalyticsReporter.track( "menu_bar_action_clicked", diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index de46d5e3..213f7152 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -1320,27 +1320,6 @@ struct TranscriptedSettingsView: View { } } - HStack { - Button("Copy Diagnostics") { - trackSettingsAction("copy_diagnostics", page: .privacy) - diagnosticsActionStatus = actions.copyDiagnostics() - ? "Copied diagnostics." - : "Could not copy diagnostics." - } - - Button("Send Diagnostic Event") { - trackSettingsAction("send_diagnostic_event", page: .privacy) - sendDiagnosticEvent() - } - .disabled(!CrashReporter.isAvailable || !crashReportingEnabled) - - if let diagnosticsActionStatus { - Text(diagnosticsActionStatus) - .font(.caption) - .foregroundStyle(.secondary) - } - } - Text("Never sent: transcript text, audio, names, emails, file paths, raw URLs, or meeting titles.") .font(.caption) .foregroundStyle(.secondary) @@ -1420,43 +1399,45 @@ struct TranscriptedSettingsView: View { VStack(alignment: .leading, spacing: 24) { SettingsPageIntro( title: "Support", - summary: "Feedback and diagnostics." + summary: "Send feedback or support by email." ) SettingsSection( - title: "Contact", - detail: "Opens a prefilled email to help@transcripted.app." + title: "Send feedback or support by email", + detail: "Starts an email draft to help@transcripted.app." ) { SettingsStatusCard( title: "Email support", status: "help@transcripted.app", - detail: "Includes scrubbed app context so problems are easier to fix.", + detail: "Opens a prefilled email with scrubbed app context.", tone: .ready ) - Button("Send Feedback") { + Button("Email support") { trackSettingsAction("submit_feedback", page: .support) actions.sendFeedback() } } SettingsSection( - title: "Diagnostics", - detail: "Privacy-safe context for debugging device and app issues." + title: "Diagnostics for support", + detail: "Use these when the issue is hard to explain." ) { - HStack { - Button("Copy Diagnostics") { - trackSettingsAction("copy_diagnostics", page: .support) - diagnosticsActionStatus = actions.copyDiagnostics() - ? "Copied diagnostics." - : "Could not copy diagnostics." - } + VStack(alignment: .leading, spacing: 10) { + HStack { + Button("Copy diagnostics to attach to email") { + trackSettingsAction("copy_diagnostics", page: .support) + diagnosticsActionStatus = actions.copyDiagnostics() + ? "Copied diagnostics. Attach them to your support email." + : "Could not copy diagnostics." + } - Button("Send Diagnostic Event") { - trackSettingsAction("send_diagnostic_event", page: .support) - sendDiagnosticEvent() + Button("One-click send diagnostic event") { + trackSettingsAction("send_diagnostic_event", page: .support) + sendDiagnosticEvent() + } + .disabled(!CrashReporter.isAvailable || !crashReportingEnabled) } - .disabled(!CrashReporter.isAvailable || !crashReportingEnabled) if let diagnosticsActionStatus { Text(diagnosticsActionStatus) @@ -1465,6 +1446,10 @@ struct TranscriptedSettingsView: View { } } + Text("The diagnostic event sends privacy-safe issue context to the creator of Transcripted so the problem is easier to fix.") + .font(.caption) + .foregroundStyle(.secondary) + Text("Never sent: transcript text, audio, names, emails, file paths, raw URLs, or meeting titles.") .font(.caption) .foregroundStyle(.secondary) From e9964967cbb833aa9161207190d70eaa4c222e0d Mon Sep 17 00:00:00 2001 From: r3dbars Date: Thu, 30 Apr 2026 10:10:14 -0500 Subject: [PATCH 06/22] Simplify support settings UI --- .../Settings/TranscriptedSettingsView.swift | 202 ++++++++++++++---- 1 file changed, 164 insertions(+), 38 deletions(-) diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index 213f7152..157071f4 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -1396,64 +1396,190 @@ struct TranscriptedSettingsView: View { } private var supportPage: some View { - VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 20) { SettingsPageIntro( title: "Support", - summary: "Send feedback or support by email." + summary: "Need help, found a bug, or want to send feedback? Email is the best way to reach the team building Transcripted." ) - SettingsSection( - title: "Send feedback or support by email", - detail: "Starts an email draft to help@transcripted.app." + SupportActionCard( + symbolName: "envelope.fill", + title: "Email support", + detail: "Send feedback, ask for help, or tell us what felt broken. This opens a prefilled email to help@transcripted.app.", + buttonTitle: "Email support", + buttonSymbolName: "paperplane.fill", + tone: .primary, + status: nil, + isEnabled: true ) { - SettingsStatusCard( - title: "Email support", - status: "help@transcripted.app", - detail: "Opens a prefilled email with scrubbed app context.", - tone: .ready - ) - - Button("Email support") { - trackSettingsAction("submit_feedback", page: .support) - actions.sendFeedback() - } + trackSettingsAction("submit_feedback", page: .support) + actions.sendFeedback() + } + + SupportActionCard( + symbolName: "waveform.path.ecg", + title: "Send diagnostics", + detail: "Had an error or something felt broken? Send a privacy-safe diagnostic event so we can investigate and try to fix it.", + buttonTitle: "One-click send diagnostics", + buttonSymbolName: "bolt.fill", + tone: .secondary, + status: diagnosticsActionStatus, + isEnabled: CrashReporter.isAvailable && crashReportingEnabled + ) { + trackSettingsAction("send_diagnostic_event", page: .support) + sendDiagnosticEvent() } - SettingsSection( - title: "Diagnostics for support", - detail: "Use these when the issue is hard to explain." - ) { - VStack(alignment: .leading, spacing: 10) { - HStack { - Button("Copy diagnostics to attach to email") { - trackSettingsAction("copy_diagnostics", page: .support) - diagnosticsActionStatus = actions.copyDiagnostics() - ? "Copied diagnostics. Attach them to your support email." - : "Could not copy diagnostics." - } + SupportPrivacyNote() + } + } - Button("One-click send diagnostic event") { - trackSettingsAction("send_diagnostic_event", page: .support) - sendDiagnosticEvent() - } - .disabled(!CrashReporter.isAvailable || !crashReportingEnabled) + private struct SupportActionCard: View { + enum Tone { + case primary + case secondary + } + + let symbolName: String + let title: String + let detail: String + let buttonTitle: String + let buttonSymbolName: String + let tone: Tone + let status: String? + let isEnabled: Bool + let action: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: symbolName) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(iconForeground) + .frame(width: 34, height: 34) + .background(iconBackground, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.primary) + + Text(detail) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) + } - if let diagnosticsActionStatus { - Text(diagnosticsActionStatus) - .font(.caption) + Button(action: action) { + Label(buttonTitle, systemImage: buttonSymbolName) + .font(.callout.weight(.semibold)) + .labelStyle(.titleAndIcon) + .lineLimit(1) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background(buttonBackground, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .foregroundStyle(buttonForeground) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.55) + + if let status, !status.isEmpty { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color(nsColor: .systemGreen)) + + Text(status) + .font(.caption.weight(.medium)) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } } + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(cardBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(cardStroke, lineWidth: 1) + ) + } - Text("The diagnostic event sends privacy-safe issue context to the creator of Transcripted so the problem is easier to fix.") - .font(.caption) + private var iconForeground: Color { + switch tone { + case .primary: + return Color(nsColor: .systemGreen) + case .secondary: + return Color.accentColor + } + } + + private var iconBackground: Color { + switch tone { + case .primary: + return Color(nsColor: .systemGreen).opacity(0.16) + case .secondary: + return Color.accentColor.opacity(0.14) + } + } + + private var cardBackground: Color { + switch tone { + case .primary: + return Color(nsColor: .controlBackgroundColor).opacity(0.9) + case .secondary: + return Color(nsColor: .controlBackgroundColor).opacity(0.72) + } + } + + private var cardStroke: Color { + switch tone { + case .primary: + return Color(nsColor: .systemGreen).opacity(0.25) + case .secondary: + return Color.primary.opacity(0.08) + } + } + + private var buttonBackground: Color { + switch tone { + case .primary: + return Color(nsColor: .systemGreen) + case .secondary: + return Color.secondary.opacity(0.16) + } + } + + private var buttonForeground: Color { + switch tone { + case .primary: + return .white + case .secondary: + return .primary + } + } + } + + private struct SupportPrivacyNote: View { + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.secondary) + .frame(width: 20) Text("Never sent: transcript text, audio, names, emails, file paths, raw URLs, or meeting titles.") .font(.caption) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } + .padding(.top, 2) } } From 102c1f43b40900dd71b642b2ad6778a85c4681e6 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Thu, 30 Apr 2026 11:52:09 -0500 Subject: [PATCH 07/22] Simplify feedback menu item --- Sources/UI/MenuBar/MenuBarSettingsView.swift | 4 ++-- Sources/UI/MenuBar/MenuBarUtilityActionsView.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/UI/MenuBar/MenuBarSettingsView.swift b/Sources/UI/MenuBar/MenuBarSettingsView.swift index 4d00bb72..b64bd93e 100644 --- a/Sources/UI/MenuBar/MenuBarSettingsView.swift +++ b/Sources/UI/MenuBar/MenuBarSettingsView.swift @@ -24,8 +24,8 @@ final class MenuBarSettingsView: NSView { ) private let feedbackButton = MenuIconButton( symbolName: "bubble.left", - accessibilityLabel: "Submit feedback for support", - toolTip: "Submit feedback for support" + accessibilityLabel: "Submit feedback", + toolTip: "Submit feedback" ) private let quitButton = MenuIconButton( symbolName: "power", diff --git a/Sources/UI/MenuBar/MenuBarUtilityActionsView.swift b/Sources/UI/MenuBar/MenuBarUtilityActionsView.swift index e4d51666..1b4e872f 100644 --- a/Sources/UI/MenuBar/MenuBarUtilityActionsView.swift +++ b/Sources/UI/MenuBar/MenuBarUtilityActionsView.swift @@ -59,8 +59,8 @@ final class MenuBarUtilityActionsView: NSView { feedbackRow.update( symbolName: "bubble.left", - title: "Submit feedback for support", - detail: "Opens the Support tab", + title: "Submit feedback", + detail: "", tone: .standard, size: .utility ) From 39e6ca949a5dca38afeb66c0bd65419ca783b1d6 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Thu, 30 Apr 2026 20:22:15 -0500 Subject: [PATCH 08/22] Refine home hero spotlight --- Sources/UI/Settings/HomeView.swift | 308 ++++++++++++------ .../Settings/TranscriptedSettingsView.swift | 10 +- 2 files changed, 208 insertions(+), 110 deletions(-) diff --git a/Sources/UI/Settings/HomeView.swift b/Sources/UI/Settings/HomeView.swift index ff171c28..ef1b0b6e 100644 --- a/Sources/UI/Settings/HomeView.swift +++ b/Sources/UI/Settings/HomeView.swift @@ -174,6 +174,57 @@ enum HomeActivityTab: String, CaseIterable, Identifiable { } } +enum HomeHeroMode: String, CaseIterable, Identifiable { + case dictation + case meeting + + var id: String { rawValue } + + var switchTitle: String { + switch self { + case .dictation: return "Dictation" + case .meeting: return "Meetings" + } + } + + var title: String { + switch self { + case .dictation: return "Capture spoken work anywhere" + case .meeting: return "Record the conversation once" + } + } + + var subtitle: String { + switch self { + case .dictation: + return "Press your shortcut and speak. Transcripted pastes clean text into whatever app you're using." + case .meeting: + return "Capture local mic and system audio, then turn the call into searchable notes you can review later." + } + } + + var actionTitle: String { + switch self { + case .dictation: return "Start dictation" + case .meeting: return "Record meeting" + } + } + + var learnTitle: String { + switch self { + case .dictation: return "Dictation works anywhere you write" + case .meeting: return "Meetings become local notes" + } + } + + var symbolName: String { + switch self { + case .dictation: return "mic.fill" + case .meeting: return "waveform" + } + } +} + // MARK: - Stats summary struct HomeStatItem: Identifiable { @@ -204,66 +255,37 @@ struct HomeWelcomeHeader: View { // MARK: - Hero card struct HomeHeroCard: View { - let primaryTitle: String - let primarySubtitle: String - let primaryAction: () -> Void - let secondaryTitle: String - let secondarySubtitle: String - let secondaryAction: () -> Void + @Binding var selectedMode: HomeHeroMode + let onStartDictation: () -> Void + let onStartMeeting: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Capture spoken work") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(Color.primary) - + VStack(alignment: .leading, spacing: 18) { ViewThatFits(in: .horizontal) { - HStack(alignment: .top, spacing: 12) { - HomeActionChoiceCard( - title: primaryTitle, - subtitle: primarySubtitle, - symbolName: "mic.fill", - isPrimary: true, - action: primaryAction - ) + HStack(alignment: .center, spacing: 22) { + heroCopy + .frame(maxWidth: .infinity, alignment: .leading) - HomeActionChoiceCard( - title: secondaryTitle, - subtitle: secondarySubtitle, - symbolName: "waveform", - isPrimary: false, - action: secondaryAction - ) + HomeAppIconCloud() + .frame(width: 330, height: 190) } - VStack(spacing: 12) { - HomeActionChoiceCard( - title: primaryTitle, - subtitle: primarySubtitle, - symbolName: "mic.fill", - isPrimary: true, - action: primaryAction - ) - - HomeActionChoiceCard( - title: secondaryTitle, - subtitle: secondarySubtitle, - symbolName: "waveform", - isPrimary: false, - action: secondaryAction - ) + VStack(alignment: .leading, spacing: 18) { + heroCopy + HomeAppIconCloud() + .frame(height: 150) } } } - .padding(18) + .padding(22) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 18, style: .continuous) .fill( LinearGradient( colors: [ - Color(nsColor: .controlBackgroundColor).opacity(0.94), - Color.accentColor.opacity(0.11) + Color(nsColor: .controlBackgroundColor).opacity(0.98), + Color.accentColor.opacity(0.08) ], startPoint: .topLeading, endPoint: .bottomTrailing @@ -272,91 +294,169 @@ struct HomeHeroCard: View { ) .overlay( RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.accentColor.opacity(0.22), lineWidth: 1) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) ) } -} -private struct HomeActionChoiceCard: View { - let title: String - let subtitle: String - let symbolName: String - let isPrimary: Bool - let action: () -> Void + private var heroCopy: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 9) { + Text(selectedMode.title) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(Color.primary) + .fixedSize(horizontal: false, vertical: true) - var body: some View { - Button(action: action) { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .center, spacing: 10) { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(iconBackground) - Image(systemName: symbolName) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(iconForeground) - } - .frame(width: 34, height: 34) + Text(selectedMode.subtitle) + .font(.system(size: 14)) + .foregroundStyle(.secondary) + .lineSpacing(2) + .fixedSize(horizontal: false, vertical: true) + } - Text(title) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Color.primary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 10) { + ForEach(HomeHeroMode.allCases) { mode in + HomeHeroModeButton( + mode: mode, + isSelected: selectedMode == mode, + action: { + withAnimation(.easeInOut(duration: 0.16)) { + selectedMode = mode + } + } + ) } + } - Text(subtitle) - .font(.callout) - .foregroundStyle(.secondary) - .lineSpacing(1) - .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 12) { + Button(action: selectedAction) { + Label(selectedMode.actionTitle, systemImage: selectedMode.symbolName) + .font(.system(size: 14, weight: .semibold)) + .padding(.horizontal, 15) + .padding(.vertical, 9) + } + .buttonStyle(.plain) + .foregroundStyle(Color.white) + .background( + Capsule(style: .continuous) + .fill(Color.accentColor) + ) + .help(selectedMode.actionTitle) HStack(spacing: 6) { - Text(title) + Text(selectedMode.learnTitle) Image(systemName: "arrow.right") .font(.system(size: 11, weight: .semibold)) } - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(isPrimary ? Color.white : Color.accentColor) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background( - Capsule(style: .continuous) - .fill(isPrimary ? Color.accentColor : Color.accentColor.opacity(0.13)) - ) - .padding(.top, 2) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var selectedAction: () -> Void { + switch selectedMode { + case .dictation: return onStartDictation + case .meeting: return onStartMeeting + } + } +} + +private struct HomeHeroModeButton: View { + let mode: HomeHeroMode + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: mode.symbolName) + .font(.system(size: 13, weight: .semibold)) + Text(mode.switchTitle) + .font(.system(size: 14, weight: .semibold)) } - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(16) + .foregroundStyle(isSelected ? Color.white : Color.primary) + .padding(.horizontal, 14) + .padding(.vertical, 9) .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(cardBackground) + Capsule(style: .continuous) + .fill(isSelected ? Color.accentColor : Color(nsColor: .controlBackgroundColor).opacity(0.86)) ) .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(borderColor, lineWidth: isPrimary ? 1.2 : 1) + Capsule(style: .continuous) + .stroke(isSelected ? Color.clear : Color.primary.opacity(0.12), lineWidth: 1) ) - .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } .buttonStyle(.plain) + .help("Show \(mode.switchTitle.lowercased())") } +} - private var cardBackground: Color { - if isPrimary { - return Color.accentColor.opacity(0.13) +private struct HomeAppIconCloud: View { + private let icons: [HomeAppIconBubble] = [ + HomeAppIconBubble(symbolName: "message.fill", label: nil, color: .green, x: 0.12, y: 0.72, size: 43), + HomeAppIconBubble(symbolName: "envelope.fill", label: nil, color: .red, x: 0.34, y: 0.64, size: 46), + HomeAppIconBubble(symbolName: "note.text", label: nil, color: .yellow, x: 0.58, y: 0.46, size: 43, usesDarkGlyph: true), + HomeAppIconBubble(symbolName: "calendar", label: nil, color: .orange, x: 0.24, y: 0.24, size: 48), + HomeAppIconBubble(symbolName: "terminal.fill", label: nil, color: .black, x: 0.72, y: 0.18, size: 46), + HomeAppIconBubble(symbolName: "sparkles", label: nil, color: .purple, x: 0.48, y: 0.23, size: 42), + HomeAppIconBubble(symbolName: nil, label: "in", color: .blue, x: 0.83, y: 0.68, size: 44), + HomeAppIconBubble(symbolName: "bubble.left.and.bubble.right.fill", label: nil, color: .cyan, x: 0.94, y: 0.38, size: 40) + ] + + var body: some View { + GeometryReader { proxy in + ZStack { + ForEach(icons) { icon in + HomeAppIconBubbleView(icon: icon) + .position( + x: proxy.size.width * icon.x, + y: proxy.size.height * icon.y + ) + } + } } - return Color(nsColor: .controlBackgroundColor).opacity(0.72) + .accessibilityHidden(true) } +} - private var borderColor: Color { - isPrimary ? Color.accentColor.opacity(0.34) : Color.accentColor.opacity(0.2) - } +private struct HomeAppIconBubble: Identifiable { + let id = UUID() + let symbolName: String? + let label: String? + let color: Color + let x: CGFloat + let y: CGFloat + let size: CGFloat + var usesDarkGlyph = false +} - private var iconBackground: Color { - isPrimary ? Color.accentColor.opacity(0.2) : Color(nsColor: .textColor).opacity(0.08) - } +private struct HomeAppIconBubbleView: View { + let icon: HomeAppIconBubble + + var body: some View { + ZStack { + Circle() + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.92)) + .shadow(color: Color.black.opacity(0.08), radius: 8, y: 4) + Circle() + .stroke(Color.primary.opacity(0.08), lineWidth: 1) - private var iconForeground: Color { - isPrimary ? Color.accentColor : Color.secondary + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(icon.color.opacity(icon.color == .black ? 0.88 : 0.82)) + .frame(width: icon.size * 0.52, height: icon.size * 0.52) + + if let symbolName = icon.symbolName { + Image(systemName: symbolName) + .font(.system(size: icon.size * 0.27, weight: .semibold)) + .foregroundStyle(icon.usesDarkGlyph ? Color.black.opacity(0.7) : Color.white) + } else if let label = icon.label { + Text(label) + .font(.system(size: icon.size * 0.27, weight: .bold)) + .foregroundStyle(Color.white) + } + } + .frame(width: icon.size, height: icon.size) } } diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index f4bfaf62..6469f924 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -60,6 +60,7 @@ struct TranscriptedSettingsView: View { @State private var meetingVoiceProcessingEnabled = MicrophoneProcessingPreferences.isVoiceProcessingEnabled() @StateObject private var homeViewModel = HomeViewModel() @State private var homeActivityTab: HomeActivityTab = .dictations + @State private var homeHeroMode: HomeHeroMode = .dictation @State private var homeCopiedRowID: String? @State private var homeDeleteConfirmation: HomeDeleteConfirmation? @State private var homeDeleteFailure: HomeDeleteFailure? @@ -261,15 +262,12 @@ struct TranscriptedSettingsView: View { } HomeHeroCard( - primaryTitle: "Start Dictation", - primarySubtitle: "Press your dictation shortcut and speak. Transcripted pastes the text into any app you're using. Capture what you say anywhere you write.", - primaryAction: { + selectedMode: $homeHeroMode, + onStartDictation: { trackSettingsAction("start_dictation", page: .home) actions.startDictation() }, - secondaryTitle: "Record a Meeting", - secondarySubtitle: "Record a meeting to capture the conversation, transcribe it, and save notes you can review or reuse later.", - secondaryAction: { + onStartMeeting: { trackSettingsAction("start_meeting", page: .home) actions.startMeeting() } From c44bc2aa0d59f5b77a430f5edf81fc65d1165733 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Thu, 30 Apr 2026 20:31:50 -0500 Subject: [PATCH 09/22] Polish home hero design --- Sources/UI/Settings/HomeView.swift | 228 +++++++++++++---------------- 1 file changed, 100 insertions(+), 128 deletions(-) diff --git a/Sources/UI/Settings/HomeView.swift b/Sources/UI/Settings/HomeView.swift index ef1b0b6e..a5d3a313 100644 --- a/Sources/UI/Settings/HomeView.swift +++ b/Sources/UI/Settings/HomeView.swift @@ -189,17 +189,17 @@ enum HomeHeroMode: String, CaseIterable, Identifiable { var title: String { switch self { - case .dictation: return "Capture spoken work anywhere" - case .meeting: return "Record the conversation once" + case .dictation: return "Speak anywhere. It types where you were writing." + case .meeting: return "Record the call. Keep the notes." } } var subtitle: String { switch self { case .dictation: - return "Press your shortcut and speak. Transcripted pastes clean text into whatever app you're using." + return "Use your shortcut, say the thought, and Transcripted pastes cleaned text back into the app you were using." case .meeting: - return "Capture local mic and system audio, then turn the call into searchable notes you can review later." + return "Capture local mic and system audio, then turn the conversation into searchable local Markdown." } } @@ -212,8 +212,8 @@ enum HomeHeroMode: String, CaseIterable, Identifiable { var learnTitle: String { switch self { - case .dictation: return "Dictation works anywhere you write" - case .meeting: return "Meetings become local notes" + case .dictation: return "Works in any app with a text cursor." + case .meeting: return "Saved locally for review and agent context." } } @@ -223,6 +223,30 @@ enum HomeHeroMode: String, CaseIterable, Identifiable { case .meeting: return "waveform" } } + + var steps: [HomeHeroStep] { + switch self { + case .dictation: + return [ + HomeHeroStep(number: "1", title: "Press your shortcut", detail: "Start from the app where you are writing."), + HomeHeroStep(number: "2", title: "Say the thought", detail: "Transcripted listens locally and cleans up the text."), + HomeHeroStep(number: "3", title: "Keep moving", detail: "The finished text lands back at your cursor.") + ] + case .meeting: + return [ + HomeHeroStep(number: "1", title: "Start recording", detail: "Capture your mic plus system audio."), + HomeHeroStep(number: "2", title: "Let it transcribe", detail: "Transcripted turns the call into readable notes."), + HomeHeroStep(number: "3", title: "Use it later", detail: "Open the Markdown or point an agent at it.") + ] + } + } +} + +struct HomeHeroStep: Identifiable { + let id = UUID() + let number: String + let title: String + let detail: String } // MARK: - Stats summary @@ -260,37 +284,46 @@ struct HomeHeroCard: View { let onStartMeeting: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 20) { + HStack(alignment: .center) { + Label("Capture", systemImage: selectedMode.symbolName) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.6) + + Spacer() + + Picker("", selection: $selectedMode) { + ForEach(HomeHeroMode.allCases) { mode in + Text(mode.switchTitle).tag(mode) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 210) + } + ViewThatFits(in: .horizontal) { - HStack(alignment: .center, spacing: 22) { + HStack(alignment: .top, spacing: 24) { heroCopy .frame(maxWidth: .infinity, alignment: .leading) - HomeAppIconCloud() - .frame(width: 330, height: 190) + HomeHeroWorkflowPanel(mode: selectedMode) + .frame(width: 290) } VStack(alignment: .leading, spacing: 18) { heroCopy - HomeAppIconCloud() - .frame(height: 150) + HomeHeroWorkflowPanel(mode: selectedMode) } } } - .padding(22) + .padding(24) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill( - LinearGradient( - colors: [ - Color(nsColor: .controlBackgroundColor).opacity(0.98), - Color.accentColor.opacity(0.08) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.82)) ) .overlay( RoundedRectangle(cornerRadius: 18, style: .continuous) @@ -302,44 +335,24 @@ struct HomeHeroCard: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 9) { Text(selectedMode.title) - .font(.system(size: 24, weight: .semibold)) + .font(.system(size: 26, weight: .semibold)) .foregroundStyle(Color.primary) .fixedSize(horizontal: false, vertical: true) Text(selectedMode.subtitle) - .font(.system(size: 14)) + .font(.system(size: 15)) .foregroundStyle(.secondary) .lineSpacing(2) .fixedSize(horizontal: false, vertical: true) } - HStack(spacing: 10) { - ForEach(HomeHeroMode.allCases) { mode in - HomeHeroModeButton( - mode: mode, - isSelected: selectedMode == mode, - action: { - withAnimation(.easeInOut(duration: 0.16)) { - selectedMode = mode - } - } - ) - } - } - HStack(spacing: 12) { Button(action: selectedAction) { Label(selectedMode.actionTitle, systemImage: selectedMode.symbolName) - .font(.system(size: 14, weight: .semibold)) - .padding(.horizontal, 15) - .padding(.vertical, 9) + .font(.system(size: 13, weight: .semibold)) } - .buttonStyle(.plain) - .foregroundStyle(Color.white) - .background( - Capsule(style: .continuous) - .fill(Color.accentColor) - ) + .buttonStyle(.borderedProminent) + .controlSize(.regular) .help(selectedMode.actionTitle) HStack(spacing: 6) { @@ -362,101 +375,60 @@ struct HomeHeroCard: View { } } -private struct HomeHeroModeButton: View { +private struct HomeHeroWorkflowPanel: View { let mode: HomeHeroMode - let isSelected: Bool - let action: () -> Void var body: some View { - Button(action: action) { - HStack(spacing: 8) { - Image(systemName: mode.symbolName) - .font(.system(size: 13, weight: .semibold)) - Text(mode.switchTitle) - .font(.system(size: 14, weight: .semibold)) - } - .foregroundStyle(isSelected ? Color.white : Color.primary) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .background( - Capsule(style: .continuous) - .fill(isSelected ? Color.accentColor : Color(nsColor: .controlBackgroundColor).opacity(0.86)) - ) - .overlay( - Capsule(style: .continuous) - .stroke(isSelected ? Color.clear : Color.primary.opacity(0.12), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .help("Show \(mode.switchTitle.lowercased())") - } -} - -private struct HomeAppIconCloud: View { - private let icons: [HomeAppIconBubble] = [ - HomeAppIconBubble(symbolName: "message.fill", label: nil, color: .green, x: 0.12, y: 0.72, size: 43), - HomeAppIconBubble(symbolName: "envelope.fill", label: nil, color: .red, x: 0.34, y: 0.64, size: 46), - HomeAppIconBubble(symbolName: "note.text", label: nil, color: .yellow, x: 0.58, y: 0.46, size: 43, usesDarkGlyph: true), - HomeAppIconBubble(symbolName: "calendar", label: nil, color: .orange, x: 0.24, y: 0.24, size: 48), - HomeAppIconBubble(symbolName: "terminal.fill", label: nil, color: .black, x: 0.72, y: 0.18, size: 46), - HomeAppIconBubble(symbolName: "sparkles", label: nil, color: .purple, x: 0.48, y: 0.23, size: 42), - HomeAppIconBubble(symbolName: nil, label: "in", color: .blue, x: 0.83, y: 0.68, size: 44), - HomeAppIconBubble(symbolName: "bubble.left.and.bubble.right.fill", label: nil, color: .cyan, x: 0.94, y: 0.38, size: 40) - ] + VStack(alignment: .leading, spacing: 12) { + Text("How it works") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.6) - var body: some View { - GeometryReader { proxy in - ZStack { - ForEach(icons) { icon in - HomeAppIconBubbleView(icon: icon) - .position( - x: proxy.size.width * icon.x, - y: proxy.size.height * icon.y - ) + VStack(alignment: .leading, spacing: 10) { + ForEach(mode.steps) { step in + HomeHeroStepRow(step: step) } } } - .accessibilityHidden(true) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.primary.opacity(0.035)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.primary.opacity(0.07), lineWidth: 1) + ) } } -private struct HomeAppIconBubble: Identifiable { - let id = UUID() - let symbolName: String? - let label: String? - let color: Color - let x: CGFloat - let y: CGFloat - let size: CGFloat - var usesDarkGlyph = false -} - -private struct HomeAppIconBubbleView: View { - let icon: HomeAppIconBubble +private struct HomeHeroStepRow: View { + let step: HomeHeroStep var body: some View { - ZStack { - Circle() - .fill(Color(nsColor: .controlBackgroundColor).opacity(0.92)) - .shadow(color: Color.black.opacity(0.08), radius: 8, y: 4) - Circle() - .stroke(Color.primary.opacity(0.08), lineWidth: 1) + HStack(alignment: .top, spacing: 10) { + Text(step.number) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 22, height: 22) + .background( + Circle() + .fill(Color.accentColor.opacity(0.12)) + ) - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(icon.color.opacity(icon.color == .black ? 0.88 : 0.82)) - .frame(width: icon.size * 0.52, height: icon.size * 0.52) - - if let symbolName = icon.symbolName { - Image(systemName: symbolName) - .font(.system(size: icon.size * 0.27, weight: .semibold)) - .foregroundStyle(icon.usesDarkGlyph ? Color.black.opacity(0.7) : Color.white) - } else if let label = icon.label { - Text(label) - .font(.system(size: icon.size * 0.27, weight: .bold)) - .foregroundStyle(Color.white) + VStack(alignment: .leading, spacing: 2) { + Text(step.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.primary) + Text(step.detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } } - .frame(width: icon.size, height: icon.size) } } From 7c6e05a971877812d3b7c4f8ec1342c170bc61e7 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Thu, 30 Apr 2026 20:37:18 -0500 Subject: [PATCH 10/22] Sync home activity with hero mode --- Sources/UI/Settings/HomeView.swift | 106 +----------------- .../Settings/TranscriptedSettingsView.swift | 14 ++- 2 files changed, 17 insertions(+), 103 deletions(-) diff --git a/Sources/UI/Settings/HomeView.swift b/Sources/UI/Settings/HomeView.swift index a5d3a313..a6e5ffd2 100644 --- a/Sources/UI/Settings/HomeView.swift +++ b/Sources/UI/Settings/HomeView.swift @@ -224,31 +224,14 @@ enum HomeHeroMode: String, CaseIterable, Identifiable { } } - var steps: [HomeHeroStep] { + var activityTab: HomeActivityTab { switch self { - case .dictation: - return [ - HomeHeroStep(number: "1", title: "Press your shortcut", detail: "Start from the app where you are writing."), - HomeHeroStep(number: "2", title: "Say the thought", detail: "Transcripted listens locally and cleans up the text."), - HomeHeroStep(number: "3", title: "Keep moving", detail: "The finished text lands back at your cursor.") - ] - case .meeting: - return [ - HomeHeroStep(number: "1", title: "Start recording", detail: "Capture your mic plus system audio."), - HomeHeroStep(number: "2", title: "Let it transcribe", detail: "Transcripted turns the call into readable notes."), - HomeHeroStep(number: "3", title: "Use it later", detail: "Open the Markdown or point an agent at it.") - ] + case .dictation: return .dictations + case .meeting: return .meetings } } } -struct HomeHeroStep: Identifiable { - let id = UUID() - let number: String - let title: String - let detail: String -} - // MARK: - Stats summary struct HomeStatItem: Identifiable { @@ -304,20 +287,7 @@ struct HomeHeroCard: View { .frame(width: 210) } - ViewThatFits(in: .horizontal) { - HStack(alignment: .top, spacing: 24) { - heroCopy - .frame(maxWidth: .infinity, alignment: .leading) - - HomeHeroWorkflowPanel(mode: selectedMode) - .frame(width: 290) - } - - VStack(alignment: .leading, spacing: 18) { - heroCopy - HomeHeroWorkflowPanel(mode: selectedMode) - } - } + heroCopy } .padding(24) .frame(maxWidth: .infinity, alignment: .leading) @@ -375,63 +345,6 @@ struct HomeHeroCard: View { } } -private struct HomeHeroWorkflowPanel: View { - let mode: HomeHeroMode - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("How it works") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - .tracking(0.6) - - VStack(alignment: .leading, spacing: 10) { - ForEach(mode.steps) { step in - HomeHeroStepRow(step: step) - } - } - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(Color.primary.opacity(0.035)) - ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Color.primary.opacity(0.07), lineWidth: 1) - ) - } -} - -private struct HomeHeroStepRow: View { - let step: HomeHeroStep - - var body: some View { - HStack(alignment: .top, spacing: 10) { - Text(step.number) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(Color.accentColor) - .frame(width: 22, height: 22) - .background( - Circle() - .fill(Color.accentColor.opacity(0.12)) - ) - - VStack(alignment: .leading, spacing: 2) { - Text(step.title) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(Color.primary) - Text(step.detail) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } -} - // MARK: - Stats rail struct HomeStatsRail: View { @@ -845,7 +758,7 @@ struct HomeDayGroupedList: View { // MARK: - Activity tabs container struct HomeActivityTabsCard: View { - @Binding var selectedTab: HomeActivityTab + let selectedTab: HomeActivityTab let dictationSections: [HomeDaySection] let meetingSections: [HomeDaySection] let isLoading: Bool @@ -876,15 +789,6 @@ struct HomeActivityTabsCard: View { } Spacer(minLength: 12) - - Picker("", selection: $selectedTab) { - ForEach(HomeActivityTab.allCases) { tab in - Text(tab.label).tag(tab) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .frame(width: 190, alignment: .trailing) } if isLoading { diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index 6469f924..f5e62311 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -262,7 +262,7 @@ struct TranscriptedSettingsView: View { } HomeHeroCard( - selectedMode: $homeHeroMode, + selectedMode: homeHeroModeSelection, onStartDictation: { trackSettingsAction("start_dictation", page: .home) actions.startDictation() @@ -312,7 +312,7 @@ struct TranscriptedSettingsView: View { } HomeActivityTabsCard( - selectedTab: $homeActivityTab, + selectedTab: homeActivityTab, dictationSections: homeViewModel.dictationDaySections, meetingSections: homeViewModel.meetingDaySections, isLoading: homeViewModel.isLoading, @@ -381,6 +381,16 @@ struct TranscriptedSettingsView: View { } } + private var homeHeroModeSelection: Binding { + Binding( + get: { homeHeroMode }, + set: { newMode in + homeHeroMode = newMode + homeActivityTab = newMode.activityTab + } + ) + } + private func handleCopyDictation(_ entry: SavedDictationEntry) { trackSettingsAction("copy_dictation", page: .home) let pasteboard = NSPasteboard.general From 081b4dfb5bae25b403ea3e1870fe88866edfa9d3 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 1 May 2026 05:46:53 -0500 Subject: [PATCH 11/22] Simplify home page copy --- Sources/UI/Settings/HomeView.swift | 58 +++++-------------- .../Settings/TranscriptedSettingsView.swift | 6 +- 2 files changed, 18 insertions(+), 46 deletions(-) diff --git a/Sources/UI/Settings/HomeView.swift b/Sources/UI/Settings/HomeView.swift index a6e5ffd2..dd20d238 100644 --- a/Sources/UI/Settings/HomeView.swift +++ b/Sources/UI/Settings/HomeView.swift @@ -189,17 +189,17 @@ enum HomeHeroMode: String, CaseIterable, Identifiable { var title: String { switch self { - case .dictation: return "Speak anywhere. It types where you were writing." - case .meeting: return "Record the call. Keep the notes." + case .dictation: return "Dictate anywhere" + case .meeting: return "Record meetings" } } var subtitle: String { switch self { case .dictation: - return "Use your shortcut, say the thought, and Transcripted pastes cleaned text back into the app you were using." + return "Speak once. Clean text lands back at your cursor." case .meeting: - return "Capture local mic and system audio, then turn the conversation into searchable local Markdown." + return "Capture the call. Save searchable local notes." } } @@ -212,8 +212,8 @@ enum HomeHeroMode: String, CaseIterable, Identifiable { var learnTitle: String { switch self { - case .dictation: return "Works in any app with a text cursor." - case .meeting: return "Saved locally for review and agent context." + case .dictation: return "Works anywhere you write." + case .meeting: return "Saved as local Markdown." } } @@ -267,14 +267,8 @@ struct HomeHeroCard: View { let onStartMeeting: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 18) { HStack(alignment: .center) { - Label("Capture", systemImage: selectedMode.symbolName) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - .tracking(0.6) - Spacer() Picker("", selection: $selectedMode) { @@ -302,7 +296,7 @@ struct HomeHeroCard: View { } private var heroCopy: some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 15) { VStack(alignment: .leading, spacing: 9) { Text(selectedMode.title) .font(.system(size: 26, weight: .semibold)) @@ -312,11 +306,11 @@ struct HomeHeroCard: View { Text(selectedMode.subtitle) .font(.system(size: 15)) .foregroundStyle(.secondary) - .lineSpacing(2) + .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } - HStack(spacing: 12) { + HStack(spacing: 10) { Button(action: selectedAction) { Label(selectedMode.actionTitle, systemImage: selectedMode.symbolName) .font(.system(size: 13, weight: .semibold)) @@ -332,6 +326,7 @@ struct HomeHeroCard: View { } .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) + .lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -395,9 +390,6 @@ struct HomeStatsRail: View { Text("\(streak) day streak") .font(.subheadline.weight(.semibold)) } - Text(streak == 1 ? "Keep it going tomorrow." : "Nice run.") - .font(.caption) - .foregroundStyle(.secondary) } } } @@ -656,15 +648,8 @@ struct HomeDictationRow: View { Text(preview) .font(.system(size: 13)) .foregroundStyle(Color.primary) - .lineLimit(3) + .lineLimit(1) .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - - if !entry.sourceAppName.isEmpty, entry.sourceAppName != "Unknown" { - Text(entry.sourceAppName) - .font(.caption2) - .foregroundStyle(.tertiary) - } } } } @@ -702,8 +687,7 @@ struct HomeMeetingRow: View { Text(item.title) .font(.system(size: 13, weight: .medium)) .foregroundStyle(Color.primary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) + .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) } } @@ -781,11 +765,8 @@ struct HomeActivityTabsCard: View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .center, spacing: 16) { VStack(alignment: .leading, spacing: 3) { - Text("Recent activity") + Text(selectedTab.label) .font(.system(size: 15, weight: .semibold)) - Text(activitySubtitle) - .font(.caption) - .foregroundStyle(.secondary) } Spacer(minLength: 12) @@ -795,7 +776,7 @@ struct HomeActivityTabsCard: View { HStack { ProgressView() .controlSize(.small) - Text("Loading recent activity") + Text("Loading") .font(.callout) .foregroundStyle(.secondary) Spacer() @@ -882,15 +863,6 @@ struct HomeActivityTabsCard: View { } .frame(maxWidth: .infinity, alignment: .leading) } - - private var activitySubtitle: String { - switch selectedTab { - case .dictations: - return canLoadMoreDictations ? "Showing the latest 10 dictations" : "Latest dictations" - case .meetings: - return canLoadMoreMeetings ? "Showing the latest 10 meetings" : "Latest meetings" - } - } } struct HomeLoadMoreButton: View { diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index f5e62311..74338795 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -525,7 +525,7 @@ struct TranscriptedSettingsView: View { let meetings = statsService.todayRecordings let dictationLabel = dictations == 1 ? "dictation" : "dictations" let meetingLabel = meetings == 1 ? "meeting" : "meetings" - return "\(formattedInteger(dictations)) \(dictationLabel) today, \(formattedInteger(meetings)) \(meetingLabel) today." + return "\(formattedInteger(dictations)) \(dictationLabel) · \(formattedInteger(meetings)) \(meetingLabel) today" } private var homeStatItems: [HomeStatItem] { @@ -538,7 +538,7 @@ struct TranscriptedSettingsView: View { HomeStatItem( symbolName: "keyboard", value: formattedTypingTimeSaved(forDictatedWords: homeViewModel.totalDictationWordCount), - label: "typing saved" + label: "saved" ), HomeStatItem( symbolName: "person.2.wave.2.fill", @@ -548,7 +548,7 @@ struct TranscriptedSettingsView: View { HomeStatItem( symbolName: "clock.fill", value: statsService.formattedTotalHours, - label: "meeting hours" + label: "hours" ) ] } From e8d74fadb69982cd7626460f8bbd7661ce1f0afa Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 1 May 2026 06:00:40 -0500 Subject: [PATCH 12/22] Attach home mode tabs to hero --- Sources/UI/Settings/HomeView.swift | 138 ++++++++++++++++++++++++----- 1 file changed, 115 insertions(+), 23 deletions(-) diff --git a/Sources/UI/Settings/HomeView.swift b/Sources/UI/Settings/HomeView.swift index dd20d238..57f52c5d 100644 --- a/Sources/UI/Settings/HomeView.swift +++ b/Sources/UI/Settings/HomeView.swift @@ -267,32 +267,27 @@ struct HomeHeroCard: View { let onStartMeeting: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 18) { - HStack(alignment: .center) { - Spacer() - - Picker("", selection: $selectedMode) { - ForEach(HomeHeroMode.allCases) { mode in - Text(mode.switchTitle).tag(mode) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .frame(width: 210) + VStack(alignment: .leading, spacing: 0) { + HomeHeroModeTabs(selectedMode: $selectedMode) + .padding(.leading, 20) + .padding(.bottom, -1) + .zIndex(1) + + VStack(alignment: .leading, spacing: 0) { + heroCopy } - - heroCopy + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(cardFill) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) } - .padding(24) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor).opacity(0.82)) - ) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.primary.opacity(0.08), lineWidth: 1) - ) } private var heroCopy: some View { @@ -332,6 +327,10 @@ struct HomeHeroCard: View { .frame(maxWidth: .infinity, alignment: .leading) } + private var cardFill: Color { + Color(nsColor: .controlBackgroundColor).opacity(0.82) + } + private var selectedAction: () -> Void { switch selectedMode { case .dictation: return onStartDictation @@ -340,6 +339,99 @@ struct HomeHeroCard: View { } } +private struct HomeHeroModeTabs: View { + @Binding var selectedMode: HomeHeroMode + + var body: some View { + HStack(alignment: .bottom, spacing: 4) { + ForEach(HomeHeroMode.allCases) { mode in + HomeHeroModeTab( + mode: mode, + isSelected: selectedMode == mode, + action: { + withAnimation(.easeInOut(duration: 0.16)) { + selectedMode = mode + } + } + ) + } + } + } +} + +private struct HomeHeroModeTab: View { + let mode: HomeHeroMode + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 7) { + Image(systemName: mode.symbolName) + .font(.system(size: 12, weight: .semibold)) + Text(mode.switchTitle) + .font(.system(size: 13, weight: .semibold)) + } + .foregroundStyle(isSelected ? Color.primary : Color.secondary) + .padding(.horizontal, 15) + .padding(.top, isSelected ? 11 : 9) + .padding(.bottom, isSelected ? 10 : 8) + .background( + HomeHeroTabShape(cornerRadius: 12) + .fill(tabFill) + ) + .overlay( + HomeHeroTabShape(cornerRadius: 12) + .stroke(tabStroke, lineWidth: 1) + ) + .overlay(alignment: .bottom) { + if isSelected { + Rectangle() + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.82)) + .frame(height: 1.5) + .padding(.horizontal, 1) + } + } + } + .buttonStyle(.plain) + .help("Show \(mode.switchTitle.lowercased())") + } + + private var tabFill: Color { + if isSelected { + return Color(nsColor: .controlBackgroundColor).opacity(0.82) + } + return Color.primary.opacity(0.035) + } + + private var tabStroke: Color { + isSelected ? Color.primary.opacity(0.08) : Color.primary.opacity(0.05) + } +} + +private struct HomeHeroTabShape: Shape { + let cornerRadius: CGFloat + + func path(in rect: CGRect) -> Path { + let radius = min(cornerRadius, rect.width / 2, rect.height) + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + radius)) + path.addQuadCurve( + to: CGPoint(x: rect.minX + radius, y: rect.minY), + control: CGPoint(x: rect.minX, y: rect.minY) + ) + path.addLine(to: CGPoint(x: rect.maxX - radius, y: rect.minY)) + path.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.minY + radius), + control: CGPoint(x: rect.maxX, y: rect.minY) + ) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.closeSubpath() + return path + } +} + // MARK: - Stats rail struct HomeStatsRail: View { From e34d11cf22a5eec767eed022f0d2a5eb8d804e1d Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 1 May 2026 06:21:21 -0500 Subject: [PATCH 13/22] Unify home tabs and activity --- Sources/UI/Settings/HomeView.swift | 57 +++++----- .../Settings/TranscriptedSettingsView.swift | 100 +++++++++--------- 2 files changed, 81 insertions(+), 76 deletions(-) diff --git a/Sources/UI/Settings/HomeView.swift b/Sources/UI/Settings/HomeView.swift index 57f52c5d..af4226fa 100644 --- a/Sources/UI/Settings/HomeView.swift +++ b/Sources/UI/Settings/HomeView.swift @@ -175,11 +175,13 @@ enum HomeActivityTab: String, CaseIterable, Identifiable { } enum HomeHeroMode: String, CaseIterable, Identifiable { - case dictation case meeting + case dictation var id: String { rawValue } + static let tabOrder: [HomeHeroMode] = [.meeting, .dictation] + var switchTitle: String { switch self { case .dictation: return "Dictation" @@ -261,22 +263,42 @@ struct HomeWelcomeHeader: View { // MARK: - Hero card -struct HomeHeroCard: View { +struct HomeHeroCard: View { @Binding var selectedMode: HomeHeroMode let onStartDictation: () -> Void let onStartMeeting: () -> Void + private let activityContent: () -> ActivityContent + + init( + selectedMode: Binding, + onStartDictation: @escaping () -> Void, + onStartMeeting: @escaping () -> Void, + @ViewBuilder activityContent: @escaping () -> ActivityContent + ) { + _selectedMode = selectedMode + self.onStartDictation = onStartDictation + self.onStartMeeting = onStartMeeting + self.activityContent = activityContent + } var body: some View { VStack(alignment: .leading, spacing: 0) { HomeHeroModeTabs(selectedMode: $selectedMode) - .padding(.leading, 20) + .padding(.leading, 36) .padding(.bottom, -1) .zIndex(1) - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 22) { heroCopy + + Divider() + .opacity(0.55) + + activityContent() } - .padding(24) + .padding(.top, 26) + .padding(.horizontal, 28) + .padding(.bottom, 24) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 18, style: .continuous) @@ -344,7 +366,7 @@ private struct HomeHeroModeTabs: View { var body: some View { HStack(alignment: .bottom, spacing: 4) { - ForEach(HomeHeroMode.allCases) { mode in + ForEach(HomeHeroMode.tabOrder) { mode in HomeHeroModeTab( mode: mode, isSelected: selectedMode == mode, @@ -373,9 +395,9 @@ private struct HomeHeroModeTab: View { .font(.system(size: 13, weight: .semibold)) } .foregroundStyle(isSelected ? Color.primary : Color.secondary) - .padding(.horizontal, 15) - .padding(.top, isSelected ? 11 : 9) - .padding(.bottom, isSelected ? 10 : 8) + .padding(.horizontal, 18) + .padding(.top, isSelected ? 12 : 10) + .padding(.bottom, isSelected ? 11 : 9) .background( HomeHeroTabShape(cornerRadius: 12) .fill(tabFill) @@ -915,16 +937,7 @@ struct HomeActivityTabsCard: View { } } } - .padding(18) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor).opacity(0.78)) - ) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.primary.opacity(0.08), lineWidth: 1) - ) } @ViewBuilder @@ -944,14 +957,6 @@ struct HomeActivityTabsCard: View { getID: getID, row: row ) - - if canLoadMore { - HomeLoadMoreButton( - title: loadMoreTitle, - isLoading: isLoadingMore, - action: loadMoreAction - ) - } } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index 74338795..98f94652 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -59,8 +59,8 @@ struct TranscriptedSettingsView: View { @State private var copiedAgentMeetingID: String? @State private var meetingVoiceProcessingEnabled = MicrophoneProcessingPreferences.isVoiceProcessingEnabled() @StateObject private var homeViewModel = HomeViewModel() - @State private var homeActivityTab: HomeActivityTab = .dictations - @State private var homeHeroMode: HomeHeroMode = .dictation + @State private var homeActivityTab: HomeActivityTab = .meetings + @State private var homeHeroMode: HomeHeroMode = .meeting @State private var homeCopiedRowID: String? @State private var homeDeleteConfirmation: HomeDeleteConfirmation? @State private var homeDeleteFailure: HomeDeleteFailure? @@ -271,7 +271,54 @@ struct TranscriptedSettingsView: View { trackSettingsAction("start_meeting", page: .home) actions.startMeeting() } - ) + ) { + HomeActivityTabsCard( + selectedTab: homeActivityTab, + dictationSections: homeViewModel.dictationDaySections, + meetingSections: homeViewModel.meetingDaySections, + isLoading: homeViewModel.isLoading, + isLoadingMore: homeViewModel.isLoadingMore, + canLoadMoreDictations: homeViewModel.canLoadMoreDictations, + canLoadMoreMeetings: homeViewModel.canLoadMoreMeetings, + copiedRowID: homeCopiedRowID, + onOpenDictation: { entry in + trackSettingsAction("open_recent_dictation", page: .home) + NSWorkspace.shared.open(entry.url) + }, + onCopyDictation: { entry in + handleCopyDictation(entry) + }, + onFlagDictation: { _ in + trackSettingsAction("flag_dictation", page: .home) + actions.sendFeedback() + }, + dictationMenuItems: { entry in + dictationRowMenuItems(for: entry) + }, + onOpenMeeting: { item in + trackSettingsAction("open_recent_meeting", page: .home) + NSWorkspace.shared.open(item.transcriptURL) + }, + onCopyMeeting: { item in + handleCopyMeeting(item) + }, + onFlagMeeting: { _ in + trackSettingsAction("flag_meeting", page: .home) + actions.sendFeedback() + }, + meetingMenuItems: { item in + meetingRowMenuItems(for: item) + }, + onLoadMoreDictations: { + trackSettingsAction("load_more_dictations", page: .home) + homeViewModel.loadMoreDictations() + }, + onLoadMoreMeetings: { + trackSettingsAction("load_more_meetings", page: .home) + homeViewModel.loadMoreMeetings() + } + ) + } if let activity = homeTranscriptionActivity { SettingsActivityCard( @@ -310,53 +357,6 @@ struct TranscriptedSettingsView: View { .frame(width: 200, alignment: .topLeading) } } - - HomeActivityTabsCard( - selectedTab: homeActivityTab, - dictationSections: homeViewModel.dictationDaySections, - meetingSections: homeViewModel.meetingDaySections, - isLoading: homeViewModel.isLoading, - isLoadingMore: homeViewModel.isLoadingMore, - canLoadMoreDictations: homeViewModel.canLoadMoreDictations, - canLoadMoreMeetings: homeViewModel.canLoadMoreMeetings, - copiedRowID: homeCopiedRowID, - onOpenDictation: { entry in - trackSettingsAction("open_recent_dictation", page: .home) - NSWorkspace.shared.open(entry.url) - }, - onCopyDictation: { entry in - handleCopyDictation(entry) - }, - onFlagDictation: { _ in - trackSettingsAction("flag_dictation", page: .home) - actions.sendFeedback() - }, - dictationMenuItems: { entry in - dictationRowMenuItems(for: entry) - }, - onOpenMeeting: { item in - trackSettingsAction("open_recent_meeting", page: .home) - NSWorkspace.shared.open(item.transcriptURL) - }, - onCopyMeeting: { item in - handleCopyMeeting(item) - }, - onFlagMeeting: { _ in - trackSettingsAction("flag_meeting", page: .home) - actions.sendFeedback() - }, - meetingMenuItems: { item in - meetingRowMenuItems(for: item) - }, - onLoadMoreDictations: { - trackSettingsAction("load_more_dictations", page: .home) - homeViewModel.loadMoreDictations() - }, - onLoadMoreMeetings: { - trackSettingsAction("load_more_meetings", page: .home) - homeViewModel.loadMoreMeetings() - } - ) } .animation(.snappy(duration: 0.22), value: homeTranscriptionActivity) .onChange(of: homeActivityTab) { _, newValue in From b2b89cf25f8b234d30f82a514eb0e89e6acdd828 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 1 May 2026 06:31:49 -0500 Subject: [PATCH 14/22] Make home folder span full width --- Sources/UI/Settings/HomeView.swift | 111 ++++------ .../Settings/TranscriptedSettingsView.swift | 191 ++++++++---------- 2 files changed, 127 insertions(+), 175 deletions(-) diff --git a/Sources/UI/Settings/HomeView.swift b/Sources/UI/Settings/HomeView.swift index af4226fa..108af75c 100644 --- a/Sources/UI/Settings/HomeView.swift +++ b/Sources/UI/Settings/HomeView.swift @@ -456,62 +456,69 @@ private struct HomeHeroTabShape: Shape { // MARK: - Stats rail -struct HomeStatsRail: View { - let header: String +struct HomeStatsTopCard: View { let stats: [HomeStatItem] let streak: Int? + private let columns = [ + GridItem(.flexible(minimum: 86), spacing: 12), + GridItem(.flexible(minimum: 86), spacing: 12) + ] + var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text(header) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - .tracking(0.6) + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Text("Overall") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.6) + + Spacer(minLength: 10) + + if let streak, streak > 0 { + HStack(spacing: 5) { + Image(systemName: "flame.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.orange) + Text("\(streak)d") + .font(.caption.weight(.semibold)) + } + .foregroundStyle(Color.primary) + } + } - VStack(alignment: .leading, spacing: 12) { + LazyVGrid(columns: columns, alignment: .leading, spacing: 10) { ForEach(stats) { stat in - HStack(alignment: .top, spacing: 10) { + HStack(spacing: 8) { ZStack { Circle() .fill(Color.primary.opacity(0.06)) Image(systemName: stat.symbolName) - .font(.system(size: 11, weight: .semibold)) + .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.secondary) } - .frame(width: 26, height: 26) - .padding(.top, 1) + .frame(width: 24, height: 24) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 1) { Text(stat.value) - .font(.system(size: 20, weight: .semibold)) + .font(.system(size: 16, weight: .semibold)) .foregroundStyle(Color.primary) + .lineLimit(1) Text(stat.label) - .font(.caption) + .font(.caption2) .foregroundStyle(.secondary) + .lineLimit(1) } } } } - - if let streak, streak > 0 { - Divider() - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Image(systemName: "flame.fill") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.orange) - Text("\(streak) day streak") - .font(.subheadline.weight(.semibold)) - } - } - } } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .frame(width: 286, alignment: .leading) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor).opacity(0.78)) + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.72)) ) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) @@ -520,46 +527,6 @@ struct HomeStatsRail: View { } } -// MARK: - Inline stats strip (narrow layout fallback) - -struct HomeStatsStrip: View { - let stats: [HomeStatItem] - let streak: Int? - - var body: some View { - HStack(spacing: 22) { - ForEach(stats) { stat in - VStack(alignment: .leading, spacing: 2) { - Label(stat.value, systemImage: stat.symbolName) - .font(.system(size: 18, weight: .semibold)) - Text(stat.label) - .font(.caption) - .foregroundStyle(.secondary) - } - } - if let streak, streak > 0 { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Image(systemName: "flame.fill") - .foregroundStyle(.orange) - Text("\(streak)") - .font(.system(size: 18, weight: .semibold)) - } - Text("day streak") - .font(.caption) - .foregroundStyle(.secondary) - } - } - Spacer() - } - .padding(14) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor).opacity(0.6)) - ) - } -} - // MARK: - Row action buttons struct HomeRowMenuItem: Identifiable { diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index 98f94652..fe95a09e 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -247,115 +247,104 @@ struct TranscriptedSettingsView: View { private var homePage: some View { let stats = homeStatItems let needsAttention = homeNeedsAttentionIssues - let useWideLayout = homeShouldUseWideLayout return VStack(alignment: .leading, spacing: 20) { HStack(alignment: .top, spacing: 20) { - VStack(alignment: .leading, spacing: 18) { - HomeWelcomeHeader( - name: homeViewModel.welcomeName, - summary: homeWelcomeSummary - ) + HomeWelcomeHeader( + name: homeViewModel.welcomeName, + summary: homeWelcomeSummary + ) + .frame(maxWidth: .infinity, alignment: .leading) - if !useWideLayout { - HomeStatsStrip(stats: stats, streak: homeStreak) - } + HomeStatsTopCard(stats: stats, streak: homeStreak) + } - HomeHeroCard( - selectedMode: homeHeroModeSelection, - onStartDictation: { - trackSettingsAction("start_dictation", page: .home) - actions.startDictation() - }, - onStartMeeting: { - trackSettingsAction("start_meeting", page: .home) - actions.startMeeting() - } - ) { - HomeActivityTabsCard( - selectedTab: homeActivityTab, - dictationSections: homeViewModel.dictationDaySections, - meetingSections: homeViewModel.meetingDaySections, - isLoading: homeViewModel.isLoading, - isLoadingMore: homeViewModel.isLoadingMore, - canLoadMoreDictations: homeViewModel.canLoadMoreDictations, - canLoadMoreMeetings: homeViewModel.canLoadMoreMeetings, - copiedRowID: homeCopiedRowID, - onOpenDictation: { entry in - trackSettingsAction("open_recent_dictation", page: .home) - NSWorkspace.shared.open(entry.url) - }, - onCopyDictation: { entry in - handleCopyDictation(entry) - }, - onFlagDictation: { _ in - trackSettingsAction("flag_dictation", page: .home) - actions.sendFeedback() - }, - dictationMenuItems: { entry in - dictationRowMenuItems(for: entry) - }, - onOpenMeeting: { item in - trackSettingsAction("open_recent_meeting", page: .home) - NSWorkspace.shared.open(item.transcriptURL) - }, - onCopyMeeting: { item in - handleCopyMeeting(item) - }, - onFlagMeeting: { _ in - trackSettingsAction("flag_meeting", page: .home) - actions.sendFeedback() - }, - meetingMenuItems: { item in - meetingRowMenuItems(for: item) - }, - onLoadMoreDictations: { - trackSettingsAction("load_more_dictations", page: .home) - homeViewModel.loadMoreDictations() - }, - onLoadMoreMeetings: { - trackSettingsAction("load_more_meetings", page: .home) - homeViewModel.loadMoreMeetings() - } - ) + HomeHeroCard( + selectedMode: homeHeroModeSelection, + onStartDictation: { + trackSettingsAction("start_dictation", page: .home) + actions.startDictation() + }, + onStartMeeting: { + trackSettingsAction("start_meeting", page: .home) + actions.startMeeting() + } + ) { + HomeActivityTabsCard( + selectedTab: homeActivityTab, + dictationSections: homeViewModel.dictationDaySections, + meetingSections: homeViewModel.meetingDaySections, + isLoading: homeViewModel.isLoading, + isLoadingMore: homeViewModel.isLoadingMore, + canLoadMoreDictations: homeViewModel.canLoadMoreDictations, + canLoadMoreMeetings: homeViewModel.canLoadMoreMeetings, + copiedRowID: homeCopiedRowID, + onOpenDictation: { entry in + trackSettingsAction("open_recent_dictation", page: .home) + NSWorkspace.shared.open(entry.url) + }, + onCopyDictation: { entry in + handleCopyDictation(entry) + }, + onFlagDictation: { _ in + trackSettingsAction("flag_dictation", page: .home) + actions.sendFeedback() + }, + dictationMenuItems: { entry in + dictationRowMenuItems(for: entry) + }, + onOpenMeeting: { item in + trackSettingsAction("open_recent_meeting", page: .home) + NSWorkspace.shared.open(item.transcriptURL) + }, + onCopyMeeting: { item in + handleCopyMeeting(item) + }, + onFlagMeeting: { _ in + trackSettingsAction("flag_meeting", page: .home) + actions.sendFeedback() + }, + meetingMenuItems: { item in + meetingRowMenuItems(for: item) + }, + onLoadMoreDictations: { + trackSettingsAction("load_more_dictations", page: .home) + homeViewModel.loadMoreDictations() + }, + onLoadMoreMeetings: { + trackSettingsAction("load_more_meetings", page: .home) + homeViewModel.loadMoreMeetings() } + ) + } - if let activity = homeTranscriptionActivity { - SettingsActivityCard( - symbolName: activity.symbolName, - title: activity.title, - status: activity.status, - detail: activity.detail, - tone: activity.tone, - progress: activity.progress, - actionTitle: activity.transcriptURL == nil ? nil : "Open Transcript", - action: activity.transcriptURL.map { transcriptURL in - { - trackSettingsAction("open_current_activity", page: .home) - NSWorkspace.shared.open(transcriptURL) - } - } - ) - .transition(.move(edge: .top).combined(with: .opacity)) + if let activity = homeTranscriptionActivity { + SettingsActivityCard( + symbolName: activity.symbolName, + title: activity.title, + status: activity.status, + detail: activity.detail, + tone: activity.tone, + progress: activity.progress, + actionTitle: activity.transcriptURL == nil ? nil : "Open Transcript", + action: activity.transcriptURL.map { transcriptURL in + { + trackSettingsAction("open_current_activity", page: .home) + NSWorkspace.shared.open(transcriptURL) + } } + ) + .transition(.move(edge: .top).combined(with: .opacity)) + } - if !needsAttention.isEmpty { - HomeNeedsAttentionCard( - issues: needsAttention, - onOpenPrivacy: { - trackSettingsAction("open_needs_attention", page: .home) - navigation.selectedPage = .privacy - } - ) + if !needsAttention.isEmpty { + HomeNeedsAttentionCard( + issues: needsAttention, + onOpenPrivacy: { + trackSettingsAction("open_needs_attention", page: .home) + navigation.selectedPage = .privacy } - - } - .frame(maxWidth: .infinity, alignment: .leading) - - if useWideLayout { - HomeStatsRail(header: "Overall", stats: stats, streak: homeStreak) - .frame(width: 200, alignment: .topLeading) - } + ) } } .animation(.snappy(duration: 0.22), value: homeTranscriptionActivity) @@ -516,10 +505,6 @@ struct TranscriptedSettingsView: View { ) } - private var homeShouldUseWideLayout: Bool { - true - } - private var homeWelcomeSummary: String { let dictations = homeViewModel.todayDictationCount let meetings = statsService.todayRecordings From 77849b5bb855c0e9e7b165a8957c1b53b08de0f0 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 1 May 2026 08:20:57 -0500 Subject: [PATCH 15/22] Add contextual home feedback flow --- Sources/UI/Settings/HomeView.swift | 267 +++++++++++++++++- .../Settings/TranscriptedSettingsView.swift | 103 ++++++- Sources/UI/Shared/FeedbackIssueBuilder.swift | 81 +++++- Tests/FeedbackIssueBuilderTests.swift | 27 ++ 4 files changed, 469 insertions(+), 9 deletions(-) diff --git a/Sources/UI/Settings/HomeView.swift b/Sources/UI/Settings/HomeView.swift index 108af75c..caf266c4 100644 --- a/Sources/UI/Settings/HomeView.swift +++ b/Sources/UI/Settings/HomeView.swift @@ -243,6 +243,95 @@ struct HomeStatItem: Identifiable { let label: String } +enum HomeFeedbackIssueKind: String, CaseIterable, Identifiable { + case wrongWords + case missingText + case badFormatting + case wrongSpeakers + case audioProblem + case other + + var id: String { rawValue } + + var label: String { + switch self { + case .wrongWords: return "Wrong words" + case .missingText: return "Missing text" + case .badFormatting: return "Bad formatting" + case .wrongSpeakers: return "Speaker issue" + case .audioProblem: return "Audio issue" + case .other: return "Other" + } + } +} + +struct HomeFeedbackTarget: Identifiable { + let id: String + let sourceKind: String + let title: String + let createdAt: Date + let referenceID: String + let suggestedIssue: HomeFeedbackIssueKind + + static func dictation(_ entry: SavedDictationEntry) -> HomeFeedbackTarget { + HomeFeedbackTarget( + id: "dictation-\(stableReferenceID(for: entry.id))", + sourceKind: "dictation", + title: "Dictation at \(HomeActivityRowFormatting.timeFormatter.string(from: entry.createdAt))", + createdAt: entry.createdAt, + referenceID: stableReferenceID(for: entry.id), + suggestedIssue: .wrongWords + ) + } + + static func meeting(_ item: RecentMeetingItem) -> HomeFeedbackTarget { + HomeFeedbackTarget( + id: "meeting-\(stableReferenceID(for: item.id))", + sourceKind: "meeting", + title: "Meeting at \(HomeActivityRowFormatting.timeFormatter.string(from: item.date))", + createdAt: item.date, + referenceID: stableReferenceID(for: item.id), + suggestedIssue: .wrongSpeakers + ) + } + + private static func stableReferenceID(for value: String) -> String { + var hash: UInt64 = 1_469_598_103_934_665_603 + for byte in value.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return String(hash, radix: 16) + } +} + +struct HomeFeedbackSubmission { + let target: HomeFeedbackTarget + let issueKind: HomeFeedbackIssueKind + let notes: String + let includeDiagnostics: Bool +} + +struct HomeMeetingPreview: Identifiable { + let id: String + let title: String + let date: Date + let transcriptURL: URL + let markdown: String + let readError: String? + let feedbackTarget: HomeFeedbackTarget + + init(item: RecentMeetingItem, markdown: String, readError: String? = nil) { + id = item.id + title = item.title + date = item.date + transcriptURL = item.transcriptURL + self.markdown = markdown + self.readError = readError + feedbackTarget = HomeFeedbackTarget.meeting(item) + } +} + // MARK: - Welcome header struct HomeWelcomeHeader: View { @@ -560,7 +649,7 @@ struct HomeRowActionButtons: View { iconButton( systemName: "flag", - help: "Send feedback", + help: "Report issue", action: onFlag ) @@ -929,6 +1018,182 @@ struct HomeActivityTabsCard: View { } } +// MARK: - Row feedback + +struct HomeFeedbackSheet: View { + let target: HomeFeedbackTarget + let onCancel: () -> Void + let onSubmit: (HomeFeedbackSubmission) -> Void + + @State private var issueKind: HomeFeedbackIssueKind + @State private var notes = "" + @State private var includeDiagnostics = true + + init( + target: HomeFeedbackTarget, + onCancel: @escaping () -> Void, + onSubmit: @escaping (HomeFeedbackSubmission) -> Void + ) { + self.target = target + self.onCancel = onCancel + self.onSubmit = onSubmit + _issueKind = State(initialValue: target.suggestedIssue) + } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text("Report an issue") + .font(.system(size: 22, weight: .semibold)) + Text(target.title) + .font(.callout) + .foregroundStyle(.secondary) + } + + Picker("Issue", selection: $issueKind) { + ForEach(HomeFeedbackIssueKind.allCases) { kind in + Text(kind.label).tag(kind) + } + } + .pickerStyle(.segmented) + + VStack(alignment: .leading, spacing: 8) { + Text("What happened?") + .font(.subheadline.weight(.semibold)) + TextEditor(text: $notes) + .font(.body) + .frame(minHeight: 120) + .scrollContentBackground(.hidden) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.primary.opacity(0.035)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + } + + Toggle("Include safe diagnostics", isOn: $includeDiagnostics) + + Text("Transcripted attaches the capture type, time, app version, a private reference ID, and recent scrubbed logs. It does not attach transcript text, audio, file paths, meeting titles, emails, or raw URLs.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack { + Button("Cancel", action: onCancel) + Spacer() + Button("Review report") { + onSubmit(HomeFeedbackSubmission( + target: target, + issueKind: issueKind, + notes: notes, + includeDiagnostics: includeDiagnostics + )) + } + .buttonStyle(.borderedProminent) + } + } + .padding(24) + .frame(width: 520) + } +} + +// MARK: - Meeting preview + +struct HomeMeetingPreviewSheet: View { + let preview: HomeMeetingPreview + let onOpenMarkdown: () -> Void + let onCopyForAgent: () -> Void + let onReportIssue: () -> Void + let onDone: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 5) { + Text(preview.title) + .font(.system(size: 22, weight: .semibold)) + .lineLimit(2) + Text(HomeMeetingPreviewSheet.dateFormatter.string(from: preview.date)) + .font(.callout) + .foregroundStyle(.secondary) + } + + Spacer() + + Button("Done", action: onDone) + .keyboardShortcut(.defaultAction) + } + + Group { + if let readError = preview.readError { + VStack(alignment: .leading, spacing: 8) { + Label("Could not preview this meeting", systemImage: "exclamationmark.triangle") + .font(.headline) + Text(readError) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 280, alignment: .topLeading) + } else { + ScrollView { + Text(renderedMarkdown) + .font(.system(size: 13)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + } + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.primary.opacity(0.025)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + } + } + + HStack { + Button { + onOpenMarkdown() + } label: { + Label("Open Markdown", systemImage: "doc.text") + } + + Button { + onCopyForAgent() + } label: { + Label("Copy for agent", systemImage: "square.on.square") + } + + Button { + onReportIssue() + } label: { + Label("Report issue", systemImage: "flag") + } + + Spacer() + } + } + .padding(24) + .frame(width: 680, height: 620) + } + + private var renderedMarkdown: AttributedString { + (try? AttributedString(markdown: preview.markdown)) ?? AttributedString(preview.markdown) + } + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = .current + f.dateStyle = .medium + f.timeStyle = .short + return f + }() +} + struct HomeLoadMoreButton: View { let title: String let isLoading: Bool diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index fe95a09e..b91a237c 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -26,6 +26,7 @@ struct TranscriptedSettingsView: View { @ObservedObject private var statsService: StatsService = .shared private let actions: TranscriptedSettingsActions + private let appLogger: AppLogger private let sidebarSections = SettingsSidebarSection.defaultSections @State private var dictationTriggerSystemWarning = PhysicalDictationTriggerPreferences.functionKeyConflictWarning( @@ -64,6 +65,8 @@ struct TranscriptedSettingsView: View { @State private var homeCopiedRowID: String? @State private var homeDeleteConfirmation: HomeDeleteConfirmation? @State private var homeDeleteFailure: HomeDeleteFailure? + @State private var homeFeedbackTarget: HomeFeedbackTarget? + @State private var homeMeetingPreview: HomeMeetingPreview? init( appState: TranscriptedAppState, @@ -74,6 +77,7 @@ struct TranscriptedSettingsView: View { self.navigation = navigation self.speakerPeopleModel = speakerPeopleModel self.actions = actions + self.appLogger = appState.logger _sttRouter = ObservedObject(wrappedValue: appState.sttRouter) _meetingSession = ObservedObject(wrappedValue: appState.meetingSession) _sparkleUpdater = ObservedObject(wrappedValue: appState.sparkleUpdater) @@ -286,23 +290,22 @@ struct TranscriptedSettingsView: View { onCopyDictation: { entry in handleCopyDictation(entry) }, - onFlagDictation: { _ in + onFlagDictation: { entry in trackSettingsAction("flag_dictation", page: .home) - actions.sendFeedback() + homeFeedbackTarget = HomeFeedbackTarget.dictation(entry) }, dictationMenuItems: { entry in dictationRowMenuItems(for: entry) }, onOpenMeeting: { item in - trackSettingsAction("open_recent_meeting", page: .home) - NSWorkspace.shared.open(item.transcriptURL) + presentHomeMeetingPreview(item) }, onCopyMeeting: { item in handleCopyMeeting(item) }, - onFlagMeeting: { _ in + onFlagMeeting: { item in trackSettingsAction("flag_meeting", page: .home) - actions.sendFeedback() + homeFeedbackTarget = HomeFeedbackTarget.meeting(item) }, meetingMenuItems: { item in meetingRowMenuItems(for: item) @@ -348,6 +351,36 @@ struct TranscriptedSettingsView: View { } } .animation(.snappy(duration: 0.22), value: homeTranscriptionActivity) + .sheet(item: $homeFeedbackTarget) { target in + HomeFeedbackSheet( + target: target, + onCancel: { + homeFeedbackTarget = nil + }, + onSubmit: { submission in + submitHomeFeedback(submission) + } + ) + } + .sheet(item: $homeMeetingPreview) { preview in + HomeMeetingPreviewSheet( + preview: preview, + onOpenMarkdown: { + trackSettingsAction("open_recent_meeting_markdown", page: .home) + NSWorkspace.shared.open(preview.transcriptURL) + }, + onCopyForAgent: { + handleCopyMeetingPreview(preview) + }, + onReportIssue: { + homeMeetingPreview = nil + homeFeedbackTarget = preview.feedbackTarget + }, + onDone: { + homeMeetingPreview = nil + } + ) + } .onChange(of: homeActivityTab) { _, newValue in trackSettingsAction("home_tab_\(newValue.rawValue)", page: .home) } @@ -407,6 +440,64 @@ struct TranscriptedSettingsView: View { flashCopied(rowID: item.id) } + private func handleCopyMeetingPreview(_ preview: HomeMeetingPreview) { + trackSettingsAction("copy_meeting_preview", page: .home) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(preview.markdown, forType: .string) + } + + private func presentHomeMeetingPreview(_ item: RecentMeetingItem) { + trackSettingsAction("preview_recent_meeting", page: .home) + do { + let markdown = try String(contentsOf: item.transcriptURL, encoding: .utf8) + homeMeetingPreview = HomeMeetingPreview(item: item, markdown: markdown) + } catch { + homeMeetingPreview = HomeMeetingPreview( + item: item, + markdown: "", + readError: error.localizedDescription + ) + } + } + + private func submitHomeFeedback(_ submission: HomeFeedbackSubmission) { + trackSettingsAction("submit_home_feedback", page: .home) + EventReporter.shared.capture( + level: .info, + engine: "feedback", + event: "capture_feedback_prepared", + message: "User prepared capture feedback", + context: [ + "source_kind": submission.target.sourceKind, + "issue_kind": submission.issueKind.rawValue, + "include_diagnostics": submission.includeDiagnostics ? "true" : "false", + ] + ) + + let report = FeedbackReport( + sourceKind: submission.target.sourceKind, + referenceID: submission.target.referenceID, + occurredAt: submission.target.createdAt, + issueKind: submission.issueKind.label, + userNotes: submission.notes, + appVersion: TranscriptedSupportActions.appVersionDescription, + includeDiagnostics: submission.includeDiagnostics + ) + + guard let url = FeedbackIssueBuilder.issueURL( + report: report, + rawLogLines: submission.includeDiagnostics ? appLogger.entries : nil + ) else { + NSSound.beep() + return + } + + AppSoundPlayer.shared.play(.feedbackSubmitted, respectingPreferences: false) + homeFeedbackTarget = nil + NSWorkspace.shared.open(url) + } + private func flashCopied(rowID: String) { homeCopiedRowID = rowID Task { @MainActor in diff --git a/Sources/UI/Shared/FeedbackIssueBuilder.swift b/Sources/UI/Shared/FeedbackIssueBuilder.swift index 4e4af8c6..c9ad06b5 100644 --- a/Sources/UI/Shared/FeedbackIssueBuilder.swift +++ b/Sources/UI/Shared/FeedbackIssueBuilder.swift @@ -1,11 +1,22 @@ import Foundation +struct FeedbackReport { + let sourceKind: String + let referenceID: String + let occurredAt: Date + let issueKind: String + let userNotes: String + let appVersion: String + let includeDiagnostics: Bool +} + enum FeedbackIssueBuilder { static let issueURLString = "https://github.com/r3dbars/transcripted/issues/new" static let maxIssueURLCharacterCount = 6_000 private static let title = "Transcripted Feedback" private static let maxLogLines = 80 + private static let maxUserNotesCharacters = 1_500 private static let omittedLogsNotice = "[Older logs omitted because GitHub rejects very long feedback URLs.]" private static let noLogsMessage = "No in-app logs attached." @@ -15,6 +26,14 @@ enum FeedbackIssueBuilder { return issueURL(sanitizedLogs: sanitizedLogs.isEmpty ? noLogsMessage : sanitizedLogs) } + static func issueURL(report: FeedbackReport, rawLogLines: [String]?) -> URL? { + let rawLogs = report.includeDiagnostics + ? rawLogLines?.suffix(maxLogLines).joined(separator: "\n") ?? noLogsMessage + : "User chose not to attach diagnostics." + let sanitizedLogs = AnalyticsPayloadSanitizer.redact(rawLogs) + return issueURL(report: report, sanitizedLogs: sanitizedLogs.isEmpty ? noLogsMessage : sanitizedLogs) + } + static func issueURL(sanitizedLogs: String) -> URL? { if let url = uncappedIssueURL(sanitizedLogs: sanitizedLogs), url.absoluteString.count <= maxIssueURLCharacterCount { @@ -24,7 +43,27 @@ enum FeedbackIssueBuilder { return uncappedIssueURL(sanitizedLogs: fittingTrimmedLogs(from: sanitizedLogs)) } + private static func issueURL(report: FeedbackReport, sanitizedLogs: String) -> URL? { + let reportTitle = "Transcripted \(report.sourceKind.capitalized) Feedback" + let notes = sanitizedNotes(report.userNotes) + let body = contextualBody(report: report, notes: notes, logs: sanitizedLogs) + if let url = uncappedIssueURL(title: reportTitle, body: body), + url.absoluteString.count <= maxIssueURLCharacterCount { + return url + } + + let trimmedLogs = fittingTrimmedLogs(from: sanitizedLogs, title: reportTitle, body: body) + return uncappedIssueURL( + title: reportTitle, + body: contextualBody(report: report, notes: notes, logs: trimmedLogs) + ) + } + private static func fittingTrimmedLogs(from sanitizedLogs: String) -> String { + fittingTrimmedLogs(from: sanitizedLogs, title: title, body: body(logs: sanitizedLogs)) + } + + private static func fittingTrimmedLogs(from sanitizedLogs: String, title: String, body: String) -> String { var lowerBound = 0 var upperBound = sanitizedLogs.count var best = omittedLogsNotice @@ -33,7 +72,8 @@ enum FeedbackIssueBuilder { let middle = (lowerBound + upperBound) / 2 let candidate = trimmedLogs(from: sanitizedLogs, maxTailCharacters: middle) - guard let url = uncappedIssueURL(sanitizedLogs: candidate) else { + let candidateBody = body.replacingOccurrences(of: sanitizedLogs, with: candidate) + guard let url = uncappedIssueURL(title: title, body: candidateBody) else { upperBound = middle - 1 continue } @@ -63,14 +103,26 @@ enum FeedbackIssueBuilder { } private static func uncappedIssueURL(sanitizedLogs: String) -> URL? { + uncappedIssueURL(title: title, body: body(logs: sanitizedLogs)) + } + + private static func uncappedIssueURL(title: String, body: String) -> URL? { var components = URLComponents(string: issueURLString) components?.queryItems = [ URLQueryItem(name: "title", value: title), - URLQueryItem(name: "body", value: body(logs: sanitizedLogs)) + URLQueryItem(name: "body", value: body) ] return components?.url } + private static func sanitizedNotes(_ notes: String) -> String { + let trimmed = notes.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "[No notes provided.]" } + let sanitized = AnalyticsPayloadSanitizer.redact(trimmed) + guard sanitized.count > maxUserNotesCharacters else { return sanitized } + return "\(sanitized.prefix(maxUserNotesCharacters))\n[Feedback text truncated for URL length.]" + } + private static func body(logs: String) -> String { """ What happened: @@ -81,4 +133,29 @@ enum FeedbackIssueBuilder { \(logs) """ } + + private static func contextualBody(report: FeedbackReport, notes: String, logs: String) -> String { + """ + What went wrong: + \(notes) + + --- + Capture: + Type: \(report.sourceKind) + Issue: \(report.issueKind) + Reference: \(report.referenceID) + Created: \(feedbackDateFormatter.string(from: report.occurredAt)) + App: \(report.appVersion) + + --- + Diagnostics: + \(logs) + """ + } + + private static let feedbackDateFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() } diff --git a/Tests/FeedbackIssueBuilderTests.swift b/Tests/FeedbackIssueBuilderTests.swift index 0f2ffda3..9844883a 100644 --- a/Tests/FeedbackIssueBuilderTests.swift +++ b/Tests/FeedbackIssueBuilderTests.swift @@ -1,5 +1,7 @@ import Foundation +import Foundation + func testFeedbackIssueBuilder() { runSuite("FeedbackIssueBuilder builds a normal GitHub issue URL") { let url = FeedbackIssueBuilder.issueURL(rawLogLines: [ @@ -42,6 +44,31 @@ func testFeedbackIssueBuilder() { assertFalse(body.contains("marker_0"), "logs older than the latest 80 entries should not be included") assertTrue(body.contains("marker_99"), "latest log entry should be included") } + + runSuite("FeedbackIssueBuilder builds contextual capture feedback") { + let report = FeedbackReport( + sourceKind: "dictation", + referenceID: "abc123", + occurredAt: Date(timeIntervalSince1970: 1_714_000_000), + issueKind: "Wrong words", + userNotes: "It included /Users/redbars/private.txt and person@example.com", + appVersion: "Version 1.2.3", + includeDiagnostics: true + ) + let url = FeedbackIssueBuilder.issueURL(report: report, rawLogLines: [ + "[12:00:00.000] APP LAUNCHED", + "[12:01:00.000] ERROR | https://example.com/log" + ]) + let body = feedbackBody(from: url) + + assertTrue(body.contains("Capture:"), "contextual issue should include capture metadata") + assertTrue(body.contains("Type: dictation"), "source kind should be included") + assertTrue(body.contains("Issue: Wrong words"), "issue kind should be included") + assertTrue(body.contains("Reference: abc123"), "safe reference should be included") + assertFalse(body.contains("/Users/redbars/"), "user notes should be scrubbed") + assertFalse(body.contains("person@example.com"), "emails in user notes should be scrubbed") + assertFalse(body.contains("https://example.com/log"), "diagnostic URLs should be scrubbed") + } } private func feedbackBody(from url: URL?) -> String { From 6729665f33b00b812c58cc35c5084088c29b76da Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 1 May 2026 19:32:45 -0500 Subject: [PATCH 16/22] Tighten home page vertical spacing --- Sources/UI/Settings/TranscriptedSettingsView.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index b91a237c..3bf9a2fc 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -123,10 +123,13 @@ struct TranscriptedSettingsView: View { VStack(alignment: .leading, spacing: 28) { pageBody } - .padding(28) + .padding(.horizontal, 28) + .padding(.top, settingsContentTopPadding) + .padding(.bottom, 28) .frame(maxWidth: 860, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } .frame(minWidth: 880, minHeight: 640) @@ -252,7 +255,7 @@ struct TranscriptedSettingsView: View { let stats = homeStatItems let needsAttention = homeNeedsAttentionIssues - return VStack(alignment: .leading, spacing: 20) { + return VStack(alignment: .leading, spacing: 14) { HStack(alignment: .top, spacing: 20) { HomeWelcomeHeader( name: homeViewModel.welcomeName, @@ -403,6 +406,10 @@ struct TranscriptedSettingsView: View { } } + private var settingsContentTopPadding: CGFloat { + navigation.selectedPage == .home ? -34 : 14 + } + private var homeHeroModeSelection: Binding { Binding( get: { homeHeroMode }, From 5f0bf426ff1e55b8371e9c85cfa92a913935cec8 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 1 May 2026 19:40:12 -0500 Subject: [PATCH 17/22] Blend home tabs into folder --- Sources/UI/Settings/HomeView.swift | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Sources/UI/Settings/HomeView.swift b/Sources/UI/Settings/HomeView.swift index caf266c4..b0ca18c7 100644 --- a/Sources/UI/Settings/HomeView.swift +++ b/Sources/UI/Settings/HomeView.swift @@ -374,7 +374,7 @@ struct HomeHeroCard: View { VStack(alignment: .leading, spacing: 0) { HomeHeroModeTabs(selectedMode: $selectedMode) .padding(.leading, 36) - .padding(.bottom, -1) + .padding(.bottom, -5) .zIndex(1) VStack(alignment: .leading, spacing: 22) { @@ -498,25 +498,30 @@ private struct HomeHeroModeTab: View { .overlay(alignment: .bottom) { if isSelected { Rectangle() - .fill(Color(nsColor: .controlBackgroundColor).opacity(0.82)) - .frame(height: 1.5) - .padding(.horizontal, 1) + .fill(surfaceFill) + .frame(height: 6) + .offset(y: 2) } } } .buttonStyle(.plain) .help("Show \(mode.switchTitle.lowercased())") + .zIndex(isSelected ? 1 : 0) } private var tabFill: Color { if isSelected { - return Color(nsColor: .controlBackgroundColor).opacity(0.82) + return surfaceFill } - return Color.primary.opacity(0.035) + return Color.primary.opacity(0.026) } private var tabStroke: Color { - isSelected ? Color.primary.opacity(0.08) : Color.primary.opacity(0.05) + isSelected ? Color.primary.opacity(0.08) : Color.primary.opacity(0.045) + } + + private var surfaceFill: Color { + Color(nsColor: .controlBackgroundColor).opacity(0.82) } } From 2b26765da6f1090b57fe83856efbfa57f54cfd02 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 1 May 2026 20:00:57 -0500 Subject: [PATCH 18/22] Polish home folder tabs --- Sources/UI/Settings/HomeView.swift | 34 ++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/Sources/UI/Settings/HomeView.swift b/Sources/UI/Settings/HomeView.swift index b0ca18c7..c1d26055 100644 --- a/Sources/UI/Settings/HomeView.swift +++ b/Sources/UI/Settings/HomeView.swift @@ -374,7 +374,7 @@ struct HomeHeroCard: View { VStack(alignment: .leading, spacing: 0) { HomeHeroModeTabs(selectedMode: $selectedMode) .padding(.leading, 36) - .padding(.bottom, -5) + .padding(.bottom, -12) .zIndex(1) VStack(alignment: .leading, spacing: 22) { @@ -385,7 +385,7 @@ struct HomeHeroCard: View { activityContent() } - .padding(.top, 26) + .padding(.top, 34) .padding(.horizontal, 28) .padding(.bottom, 24) .frame(maxWidth: .infinity, alignment: .leading) @@ -492,15 +492,15 @@ private struct HomeHeroModeTab: View { .fill(tabFill) ) .overlay( - HomeHeroTabShape(cornerRadius: 12) + HomeHeroTabBorderShape(cornerRadius: 12) .stroke(tabStroke, lineWidth: 1) ) .overlay(alignment: .bottom) { if isSelected { Rectangle() .fill(surfaceFill) - .frame(height: 6) - .offset(y: 2) + .frame(height: 14) + .offset(y: 8) } } } @@ -517,7 +517,7 @@ private struct HomeHeroModeTab: View { } private var tabStroke: Color { - isSelected ? Color.primary.opacity(0.08) : Color.primary.opacity(0.045) + isSelected ? Color.primary.opacity(0.08) : Color.primary.opacity(0.035) } private var surfaceFill: Color { @@ -548,6 +548,28 @@ private struct HomeHeroTabShape: Shape { } } +private struct HomeHeroTabBorderShape: Shape { + let cornerRadius: CGFloat + + func path(in rect: CGRect) -> Path { + let radius = min(cornerRadius, rect.width / 2, rect.height) + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + radius)) + path.addQuadCurve( + to: CGPoint(x: rect.minX + radius, y: rect.minY), + control: CGPoint(x: rect.minX, y: rect.minY) + ) + path.addLine(to: CGPoint(x: rect.maxX - radius, y: rect.minY)) + path.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.minY + radius), + control: CGPoint(x: rect.maxX, y: rect.minY) + ) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + return path + } +} + // MARK: - Stats rail struct HomeStatsTopCard: View { From 07716842ce416bda53480fe72dcf0bda2eaf7612 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 1 May 2026 20:15:05 -0500 Subject: [PATCH 19/22] Make home tabs seamless --- Sources/UI/Settings/HomeView.swift | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Sources/UI/Settings/HomeView.swift b/Sources/UI/Settings/HomeView.swift index c1d26055..cd615670 100644 --- a/Sources/UI/Settings/HomeView.swift +++ b/Sources/UI/Settings/HomeView.swift @@ -454,7 +454,7 @@ private struct HomeHeroModeTabs: View { @Binding var selectedMode: HomeHeroMode var body: some View { - HStack(alignment: .bottom, spacing: 4) { + HStack(alignment: .bottom, spacing: 0) { ForEach(HomeHeroMode.tabOrder) { mode in HomeHeroModeTab( mode: mode, @@ -467,6 +467,17 @@ private struct HomeHeroModeTabs: View { ) } } + .background(alignment: .bottom) { + Rectangle() + .fill(surfaceFill) + .frame(height: 18) + .offset(y: 10) + .padding(.horizontal, -1) + } + } + + private var surfaceFill: Color { + Color(nsColor: .controlBackgroundColor).opacity(0.82) } } @@ -513,11 +524,11 @@ private struct HomeHeroModeTab: View { if isSelected { return surfaceFill } - return Color.primary.opacity(0.026) + return surfaceFill.opacity(0.62) } private var tabStroke: Color { - isSelected ? Color.primary.opacity(0.08) : Color.primary.opacity(0.035) + isSelected ? Color.primary.opacity(0.08) : Color.primary.opacity(0.03) } private var surfaceFill: Color { From cad7a90edef9b83ad128c2972ecf6be7a91598b8 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 1 May 2026 20:31:38 -0500 Subject: [PATCH 20/22] Tighten meetings view and preview --- Sources/UI/Settings/HomeView.swift | 147 +++++++++++++++++++++++++---- 1 file changed, 127 insertions(+), 20 deletions(-) diff --git a/Sources/UI/Settings/HomeView.swift b/Sources/UI/Settings/HomeView.swift index cd615670..b9ef3aec 100644 --- a/Sources/UI/Settings/HomeView.swift +++ b/Sources/UI/Settings/HomeView.swift @@ -377,7 +377,7 @@ struct HomeHeroCard: View { .padding(.bottom, -12) .zIndex(1) - VStack(alignment: .leading, spacing: 22) { + VStack(alignment: .leading, spacing: 18) { heroCopy Divider() @@ -385,9 +385,9 @@ struct HomeHeroCard: View { activityContent() } - .padding(.top, 34) + .padding(.top, 30) .padding(.horizontal, 28) - .padding(.bottom, 24) + .padding(.bottom, 22) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 18, style: .continuous) @@ -402,10 +402,10 @@ struct HomeHeroCard: View { } private var heroCopy: some View { - VStack(alignment: .leading, spacing: 15) { + VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 9) { Text(selectedMode.title) - .font(.system(size: 26, weight: .semibold)) + .font(.system(size: 24, weight: .semibold)) .foregroundStyle(Color.primary) .fixedSize(horizontal: false, vertical: true) @@ -795,6 +795,7 @@ private struct HomeActivityRowShell: View { let onCopy: () -> Void let onFlag: () -> Void let menuItems: [HomeRowMenuItem] + var compact: Bool = false @ViewBuilder let content: () -> Content @State private var isHovering = false @@ -826,7 +827,7 @@ private struct HomeActivityRowShell: View { .animation(.easeOut(duration: 0.12), value: isHovering) } .padding(.horizontal, 8) - .padding(.vertical, 9) + .padding(.vertical, compact ? 5 : 9) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(isHovering ? Color.primary.opacity(0.035) : Color.clear) @@ -884,16 +885,17 @@ struct HomeMeetingRow: View { onOpen: onOpen, onCopy: onCopy, onFlag: onFlag, - menuItems: menuItems + menuItems: menuItems, + compact: true ) { HStack(alignment: .top, spacing: 10) { Image(systemName: "person.2.wave.2.fill") - .font(.system(size: 13, weight: .semibold)) + .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.secondary) .padding(.top, 2) Text(item.title) - .font(.system(size: 13, weight: .medium)) + .font(.system(size: 12.5, weight: .medium)) .foregroundStyle(Color.primary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) @@ -908,6 +910,8 @@ struct HomeDayGroupedList: View { let sections: [HomeDaySection] let emptyMessage: String let getID: (Item) -> AnyHashable + var sectionSpacing: CGFloat = 12 + var headerSpacing: CGFloat = 2 @ViewBuilder let row: (Item) -> Row var body: some View { @@ -921,9 +925,9 @@ struct HomeDayGroupedList: View { .padding(.vertical, 28) .padding(.horizontal, 4) } else { - LazyVStack(alignment: .leading, spacing: 18) { + LazyVStack(alignment: .leading, spacing: sectionSpacing) { ForEach(sections) { section in - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: headerSpacing) { Text(section.label) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) @@ -970,7 +974,7 @@ struct HomeActivityTabsCard: View { let onLoadMoreMeetings: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 10) { HStack(alignment: .center, spacing: 16) { VStack(alignment: .leading, spacing: 3) { Text(selectedTab.label) @@ -1044,11 +1048,13 @@ struct HomeActivityTabsCard: View { getID: @escaping (Item) -> AnyHashable, @ViewBuilder row: @escaping (Item) -> Content ) -> some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { HomeDayGroupedList( sections: sections, emptyMessage: emptyMessage, getID: getID, + sectionSpacing: 10, + headerSpacing: 1, row: row ) } @@ -1176,11 +1182,30 @@ struct HomeMeetingPreviewSheet: View { .frame(maxWidth: .infinity, minHeight: 280, alignment: .topLeading) } else { ScrollView { - Text(renderedMarkdown) - .font(.system(size: 13)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(18) + VStack(alignment: .leading, spacing: 14) { + Text("Transcript") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.6) + + if transcriptLines.isEmpty { + Text(cleanedMarkdown) + .font(.system(size: 13)) + .foregroundStyle(Color.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(Array(transcriptLines.enumerated()), id: \.offset) { _, line in + HomeMeetingTranscriptLineView(line: line) + } + } + .textSelection(.enabled) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) } .background( RoundedRectangle(cornerRadius: 12, style: .continuous) @@ -1219,8 +1244,57 @@ struct HomeMeetingPreviewSheet: View { .frame(width: 680, height: 620) } - private var renderedMarkdown: AttributedString { - (try? AttributedString(markdown: preview.markdown)) ?? AttributedString(preview.markdown) + private var cleanedMarkdown: String { + Self.readableMarkdown(from: preview.markdown) + } + + private var transcriptLines: [HomeMeetingTranscriptLine] { + cleanedMarkdown + .split(separator: "\n", omittingEmptySubsequences: false) + .compactMap { Self.parseTranscriptLine(String($0)) } + } + + private static func readableMarkdown(from markdown: String) -> String { + var lines = markdown.components(separatedBy: .newlines) + + if lines.first?.trimmingCharacters(in: .whitespacesAndNewlines) == "---", + let endIndex = lines.dropFirst().firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines) == "---" }) { + lines.removeSubrange(...endIndex) + } + + if let transcriptIndex = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines) == "## Full Transcript" }) { + lines = Array(lines.dropFirst(transcriptIndex + 1)) + } + + lines = lines.filter { line in + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed != "---" && !trimmed.hasPrefix("*Generated by Transcripted") + } + + return lines + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func parseTranscriptLine(_ rawLine: String) -> HomeMeetingTranscriptLine? { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard line.hasPrefix("["), + let timeEnd = line.firstIndex(of: "]") else { return nil } + + let time = String(line[line.index(after: line.startIndex).. Date: Fri, 1 May 2026 20:58:05 -0500 Subject: [PATCH 21/22] Harden home meeting preview performance --- .../HomeMeetingPreviewFormatter.swift | 153 ++++++++++++++++++ Sources/UI/Settings/HomeView.swift | 86 +++------- .../Settings/TranscriptedSettingsView.swift | 39 +++-- Tests/FastTests.manifest | 1 + Tests/HomeMeetingPreviewFormatterTests.swift | 71 ++++++++ scripts/entrypoints/run-tests.sh | 1 + 6 files changed, 280 insertions(+), 71 deletions(-) create mode 100644 Sources/UI/Settings/HomeMeetingPreviewFormatter.swift create mode 100644 Tests/HomeMeetingPreviewFormatterTests.swift diff --git a/Sources/UI/Settings/HomeMeetingPreviewFormatter.swift b/Sources/UI/Settings/HomeMeetingPreviewFormatter.swift new file mode 100644 index 00000000..a7ea806c --- /dev/null +++ b/Sources/UI/Settings/HomeMeetingPreviewFormatter.swift @@ -0,0 +1,153 @@ +import Foundation + +struct HomeMeetingPreviewContent { + let fallbackText: String + let transcriptLines: [HomeMeetingTranscriptLine] + + static func make(from markdown: String) -> HomeMeetingPreviewContent { + let readableLines = readableMarkdownLines(from: markdown) + return HomeMeetingPreviewContent( + fallbackText: readableLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines), + transcriptLines: parseTranscriptLines(readableLines) + ) + } + + private static func readableMarkdownLines(from markdown: String) -> [String] { + var lines = markdown.components(separatedBy: .newlines) + + if lines.first?.trimmingCharacters(in: .whitespacesAndNewlines) == "---", + let endIndex = lines.dropFirst().firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines) == "---" }) { + lines.removeSubrange(...endIndex) + } + + if let transcriptIndex = lines.firstIndex(where: { line in + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed == "## Full Transcript" || trimmed == "## Transcript" + }) { + lines = Array(lines.dropFirst(transcriptIndex + 1)) + } + + return lines.filter { line in + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed != "---" && !trimmed.hasPrefix("*Generated by Transcripted") + } + } + + private static func parseTranscriptLines(_ lines: [String]) -> [HomeMeetingTranscriptLine] { + var parsed: [HomeMeetingTranscriptLine] = [] + var pending: PendingTranscriptLine? + + func flushPending() { + guard let current = pending else { return } + let text = current.textParts + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { + parsed.append(HomeMeetingTranscriptLine( + time: current.time, + speaker: current.speaker, + text: text + )) + } + pending = nil + } + + for rawLine in lines { + let trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + if let marker = parseTranscriptMarker(trimmed) { + flushPending() + pending = PendingTranscriptLine( + time: marker.time, + speaker: marker.speaker, + textParts: marker.remainder.isEmpty ? [] : [marker.remainder] + ) + continue + } + + if pending != nil, !isSectionNoise(trimmed) { + pending?.textParts.append(trimmed) + } + } + + flushPending() + return parsed + } + + private static func parseTranscriptMarker(_ line: String) -> TranscriptMarker? { + if line.hasPrefix("**") { + return parseBoldTimestampMarker(line) + } + if line.hasPrefix("[") { + return parseBracketTimestampMarker(line) + } + return nil + } + + private static func parseBoldTimestampMarker(_ line: String) -> TranscriptMarker? { + let timeStart = line.index(line.startIndex, offsetBy: 2) + guard let timeEnd = line[timeStart...].range(of: "**")?.lowerBound else { return nil } + let time = String(line[timeStart.. TranscriptMarker? { + guard let timeEnd = line.firstIndex(of: "]") else { return nil } + let time = String(line[line.index(after: line.startIndex).. TranscriptMarker { + var speaker = "Speaker" + var text = remainder + + if text.hasPrefix("["), + let speakerEnd = text.firstIndex(of: "]") { + speaker = cleanSpeaker(String(text[text.index(after: text.startIndex).. String { + raw + .replacingOccurrences(of: "[[", with: "") + .replacingOccurrences(of: "]]", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func looksLikeTimestamp(_ value: String) -> Bool { + let parts = value.split(separator: ":") + guard parts.count == 2 || parts.count == 3 else { return false } + return parts.allSatisfy { !$0.isEmpty && $0.allSatisfy(\.isNumber) } + } + + private static func isSectionNoise(_ line: String) -> Bool { + line.hasPrefix("#") || line.hasPrefix("Recorded ") + } +} + +struct HomeMeetingTranscriptLine: Equatable { + let time: String + let speaker: String + let text: String +} + +private struct PendingTranscriptLine { + let time: String + let speaker: String + var textParts: [String] +} + +private struct TranscriptMarker { + let time: String + let speaker: String + let remainder: String +} diff --git a/Sources/UI/Settings/HomeView.swift b/Sources/UI/Settings/HomeView.swift index b9ef3aec..21970b25 100644 --- a/Sources/UI/Settings/HomeView.swift +++ b/Sources/UI/Settings/HomeView.swift @@ -332,6 +332,11 @@ struct HomeMeetingPreview: Identifiable { } } +enum HomeMeetingMarkdownReadResult { + case success(String) + case failure(String) +} + // MARK: - Welcome header struct HomeWelcomeHeader: View { @@ -1152,6 +1157,22 @@ struct HomeMeetingPreviewSheet: View { let onCopyForAgent: () -> Void let onReportIssue: () -> Void let onDone: () -> Void + private let readableContent: HomeMeetingPreviewContent + + init( + preview: HomeMeetingPreview, + onOpenMarkdown: @escaping () -> Void, + onCopyForAgent: @escaping () -> Void, + onReportIssue: @escaping () -> Void, + onDone: @escaping () -> Void + ) { + self.preview = preview + self.onOpenMarkdown = onOpenMarkdown + self.onCopyForAgent = onCopyForAgent + self.onReportIssue = onReportIssue + self.onDone = onDone + self.readableContent = HomeMeetingPreviewContent.make(from: preview.markdown) + } var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -1189,15 +1210,15 @@ struct HomeMeetingPreviewSheet: View { .textCase(.uppercase) .tracking(0.6) - if transcriptLines.isEmpty { - Text(cleanedMarkdown) + if readableContent.transcriptLines.isEmpty { + Text(readableContent.fallbackText) .font(.system(size: 13)) .foregroundStyle(Color.primary) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } else { LazyVStack(alignment: .leading, spacing: 10) { - ForEach(Array(transcriptLines.enumerated()), id: \.offset) { _, line in + ForEach(Array(readableContent.transcriptLines.enumerated()), id: \.offset) { _, line in HomeMeetingTranscriptLineView(line: line) } } @@ -1244,59 +1265,6 @@ struct HomeMeetingPreviewSheet: View { .frame(width: 680, height: 620) } - private var cleanedMarkdown: String { - Self.readableMarkdown(from: preview.markdown) - } - - private var transcriptLines: [HomeMeetingTranscriptLine] { - cleanedMarkdown - .split(separator: "\n", omittingEmptySubsequences: false) - .compactMap { Self.parseTranscriptLine(String($0)) } - } - - private static func readableMarkdown(from markdown: String) -> String { - var lines = markdown.components(separatedBy: .newlines) - - if lines.first?.trimmingCharacters(in: .whitespacesAndNewlines) == "---", - let endIndex = lines.dropFirst().firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines) == "---" }) { - lines.removeSubrange(...endIndex) - } - - if let transcriptIndex = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines) == "## Full Transcript" }) { - lines = Array(lines.dropFirst(transcriptIndex + 1)) - } - - lines = lines.filter { line in - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed != "---" && !trimmed.hasPrefix("*Generated by Transcripted") - } - - return lines - .joined(separator: "\n") - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - private static func parseTranscriptLine(_ rawLine: String) -> HomeMeetingTranscriptLine? { - let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) - guard line.hasPrefix("["), - let timeEnd = line.firstIndex(of: "]") else { return nil } - - let time = String(line[line.index(after: line.startIndex)..? init( appState: TranscriptedAppState, @@ -168,6 +169,8 @@ struct TranscriptedSettingsView: View { .onDisappear { recentCaptureRefreshTask?.cancel() recentCaptureRefreshTask = nil + homeMeetingPreviewLoadTask?.cancel() + homeMeetingPreviewLoadTask = nil homeViewModel.cancel() } } @@ -456,18 +459,36 @@ struct TranscriptedSettingsView: View { private func presentHomeMeetingPreview(_ item: RecentMeetingItem) { trackSettingsAction("preview_recent_meeting", page: .home) - do { - let markdown = try String(contentsOf: item.transcriptURL, encoding: .utf8) - homeMeetingPreview = HomeMeetingPreview(item: item, markdown: markdown) - } catch { - homeMeetingPreview = HomeMeetingPreview( - item: item, - markdown: "", - readError: error.localizedDescription - ) + homeMeetingPreviewLoadTask?.cancel() + homeMeetingPreviewLoadTask = Task { @MainActor in + let readResult = await Self.readMeetingMarkdown(at: item.transcriptURL) + guard !Task.isCancelled else { return } + + switch readResult { + case .success(let markdown): + homeMeetingPreview = HomeMeetingPreview(item: item, markdown: markdown) + case .failure(let message): + homeMeetingPreview = HomeMeetingPreview( + item: item, + markdown: "", + readError: message + ) + } + + homeMeetingPreviewLoadTask = nil } } + private static func readMeetingMarkdown(at url: URL) async -> HomeMeetingMarkdownReadResult { + await Task.detached(priority: .userInitiated) { + do { + return .success(try String(contentsOf: url, encoding: .utf8)) + } catch { + return .failure(error.localizedDescription) + } + }.value + } + private func submitHomeFeedback(_ submission: HomeFeedbackSubmission) { trackSettingsAction("submit_home_feedback", page: .home) EventReporter.shared.capture( diff --git a/Tests/FastTests.manifest b/Tests/FastTests.manifest index 44d9bb1f..c61f55b2 100644 --- a/Tests/FastTests.manifest +++ b/Tests/FastTests.manifest @@ -43,6 +43,7 @@ SentryPayloadSanitizerTests.swift:testSentryPayloadSanitizer SentryEventPolicyTests.swift:testSentryEventPolicy SentryRuntimeConfigurationTests.swift:testSentryRuntimeConfiguration SettingsRecentCaptureRefreshPolicyTests.swift:testSettingsRecentCaptureRefreshPolicy +HomeMeetingPreviewFormatterTests.swift:testHomeMeetingPreviewFormatter TranscriptedConstantsTests.swift:testTranscriptedConstants ParakeetShortAudioGateTests.swift:testParakeetShortAudioGate DictationAudioRecoveryTests.swift:testDictationAudioRecovery diff --git a/Tests/HomeMeetingPreviewFormatterTests.swift b/Tests/HomeMeetingPreviewFormatterTests.swift new file mode 100644 index 00000000..7958c2fb --- /dev/null +++ b/Tests/HomeMeetingPreviewFormatterTests.swift @@ -0,0 +1,71 @@ +import Foundation + +func testHomeMeetingPreviewFormatter() { + runSuite("HomeMeetingPreviewFormatter parses current styled meeting markdown") { + let content = HomeMeetingPreviewContent.make(from: styledMeetingMarkdown()) + + assertEqual(content.transcriptLines.count, 4, "Styled transcript blocks should become readable rows") + assertEqual(content.transcriptLines.first?.time, "00:00", "First row should keep the timestamp") + assertEqual(content.transcriptLines.first?.speaker, "System/Speaker 1", "First row should keep the source/speaker label") + assertTrue( + content.transcriptLines.first?.text.hasPrefix("touch screen. Yeah.") == true, + "Text on the line after the timestamp should be attached to the row" + ) + assertFalse(content.fallbackText.contains("capture_id:"), "Preview fallback should not expose YAML metadata") + } + + runSuite("HomeMeetingPreviewFormatter parses legacy inline transcript rows") { + let content = HomeMeetingPreviewContent.make(from: legacyInlineMarkdown()) + + assertEqual(content.transcriptLines.count, 2, "Legacy inline transcript rows should still preview cleanly") + assertEqual(content.transcriptLines[0].text, "Hello there.", "Inline text should stay on the row") + assertEqual(content.transcriptLines[1].speaker, "System/Alex", "Obsidian speaker links should be cleaned for preview") + } +} + +private func styledMeetingMarkdown() -> String { + """ + --- + title: "Meeting with Linus" + capture_id: "297F08B7-62AE-4291-9EA3-41EB0B17A64A" + capture_type: meeting + total_word_count: 117 + --- + + # Meeting with Linus + + Recorded Apr 25, 2026 at 12:18 PM • 31 sec • 117 words • 4 turns + + ## Transcript + + **00:00** [System/Speaker 1] + touch screen. Yeah. It actually is that it took us this long. + + **00:00** [Mic/Linus] + Yeah. It actually is and it took us a bit long to get to Or if we have + + **00:12** [Mic/Linus] + Oh + + **00:26** [System/Linus] + Dude, this is incredible. I'm honestly impressed. + """ +} + +private func legacyInlineMarkdown() -> String { + """ + --- + capture_type: meeting + --- + + # Meeting Recording + + ## Full Transcript + + [00:01] [Mic/You] Hello there. + + [00:05] [System/[[Alex]]] Nice to meet you. + + *Generated by Transcripted with Parakeet* + """ +} diff --git a/scripts/entrypoints/run-tests.sh b/scripts/entrypoints/run-tests.sh index 495f3524..36a73d88 100755 --- a/scripts/entrypoints/run-tests.sh +++ b/scripts/entrypoints/run-tests.sh @@ -175,6 +175,7 @@ APP_SOURCES=( "Sources/UI/Shared/AppSoundPlayer.swift" "Sources/UI/Settings/TranscriptedSettingsPage.swift" "Sources/UI/Settings/SettingsRecentCaptureRefreshPolicy.swift" + "Sources/UI/Settings/HomeMeetingPreviewFormatter.swift" "Sources/UI/Overlay/DictationMeterPolicy.swift" "Sources/UI/Shared/MeetingAudioArchiveResolver.swift" "Sources/UI/Shared/MeetingAudioPlayback.swift" From 3b4d0f2eab1a1866fa352067c6da252b69144abc Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 1 May 2026 21:06:35 -0500 Subject: [PATCH 22/22] refactor: nightly simplification sweep [automated] Two follow-up cleanups in HomeView.swift after the recent "simplify home activity rows" commit: - HomeHeroCard: extract @ViewBuilder var actionCards to remove the two byte-for-byte identical HomeActionChoiceCard pairs inside ViewThatFits (the HStack and VStack branches were duplicated). - HomeActivityTabsCard.activitySubtitle: collapse the two near-identical switch arms by deriving the noun from selectedTab.label.lowercased() and selecting canLoadMore once. Net diff: -41 / +29 lines, no behavior change. Build, 1118 unit tests, 2 integration smoke tests, and 107 swift tests all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/UI/Settings/HomeView.swift | 70 +++++++++++++----------------- 1 file changed, 29 insertions(+), 41 deletions(-) diff --git a/Sources/UI/Settings/HomeView.swift b/Sources/UI/Settings/HomeView.swift index b9dcaa52..1ea2f893 100644 --- a/Sources/UI/Settings/HomeView.swift +++ b/Sources/UI/Settings/HomeView.swift @@ -210,41 +210,8 @@ struct HomeHeroCard: View { .foregroundStyle(Color.primary) ViewThatFits(in: .horizontal) { - HStack(alignment: .top, spacing: 12) { - HomeActionChoiceCard( - title: primaryTitle, - subtitle: primarySubtitle, - symbolName: "mic.fill", - isPrimary: true, - action: primaryAction - ) - - HomeActionChoiceCard( - title: secondaryTitle, - subtitle: secondarySubtitle, - symbolName: "waveform", - isPrimary: false, - action: secondaryAction - ) - } - - VStack(spacing: 12) { - HomeActionChoiceCard( - title: primaryTitle, - subtitle: primarySubtitle, - symbolName: "mic.fill", - isPrimary: true, - action: primaryAction - ) - - HomeActionChoiceCard( - title: secondaryTitle, - subtitle: secondarySubtitle, - symbolName: "waveform", - isPrimary: false, - action: secondaryAction - ) - } + HStack(alignment: .top, spacing: 12) { actionCards } + VStack(spacing: 12) { actionCards } } } .padding(18) @@ -267,6 +234,25 @@ struct HomeHeroCard: View { .stroke(Color.accentColor.opacity(0.22), lineWidth: 1) ) } + + @ViewBuilder + private var actionCards: some View { + HomeActionChoiceCard( + title: primaryTitle, + subtitle: primarySubtitle, + symbolName: "mic.fill", + isPrimary: true, + action: primaryAction + ) + + HomeActionChoiceCard( + title: secondaryTitle, + subtitle: secondarySubtitle, + symbolName: "waveform", + isPrimary: false, + action: secondaryAction + ) + } } private struct HomeActionChoiceCard: View { @@ -900,12 +886,14 @@ struct HomeActivityTabsCard: View { } private var activitySubtitle: String { - switch selectedTab { - case .dictations: - return canLoadMoreDictations ? "Showing the latest 10 dictations" : "Latest dictations" - case .meetings: - return canLoadMoreMeetings ? "Showing the latest 10 meetings" : "Latest meetings" - } + let canLoadMore: Bool = { + switch selectedTab { + case .dictations: return canLoadMoreDictations + case .meetings: return canLoadMoreMeetings + } + }() + let noun = selectedTab.label.lowercased() + return canLoadMore ? "Showing the latest 10 \(noun)" : "Latest \(noun)" } }