From c71b94e8c9a592295f870fcab4d716ce2f727b43 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:56:39 -0700 Subject: [PATCH 1/2] Add copy last transcript menu item --- Sources/Fluid/ContentView.swift | 14 ++------ .../TranscriptionHistoryStore.swift | 11 ++++++ Sources/Fluid/Services/MenuBarManager.swift | 25 +++++++++++++ .../DictationE2ETests.swift | 36 +++++++++++++++++++ 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 65ba6239..0b527808 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2444,18 +2444,8 @@ struct ContentView: View { } private func copyLastDictationFromHistory() { - guard let last = TranscriptionHistoryStore.shared.entries.first else { - DebugLogger.shared.info("Actions: Copy requested but history is empty", source: "ContentView") - return - } - - // Fallback to raw text when no processed text is available - // (for example older entries or edge cases with AI enhancement off). - let processed = last.processedText.trimmingCharacters(in: .whitespacesAndNewlines) - let raw = last.rawText.trimmingCharacters(in: .whitespacesAndNewlines) - let text = processed.isEmpty ? raw : processed - guard !text.isEmpty else { - DebugLogger.shared.info("Actions: Copy skipped because latest history text is empty", source: "ContentView") + guard let text = TranscriptionHistoryStore.shared.latestClipboardText else { + DebugLogger.shared.info("Actions: Copy requested but no transcription is available", source: "ContentView") return } diff --git a/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift b/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift index 0656a392..997b2c44 100644 --- a/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift +++ b/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift @@ -106,6 +106,13 @@ struct TranscriptionHistoryEntry: Codable, Identifiable, Equatable { return text } + var clipboardText: String? { + let processed = self.processedText.trimmingCharacters(in: .whitespacesAndNewlines) + let raw = self.rawText.trimmingCharacters(in: .whitespacesAndNewlines) + let text = processed.isEmpty ? raw : processed + return text.isEmpty ? nil : text + } + /// Relative time string for display var relativeTimeString: String { let formatter = RelativeDateTimeFormatter() @@ -169,6 +176,10 @@ final class TranscriptionHistoryStore: ObservableObject { return self.entries.first(where: { $0.id == id }) } + var latestClipboardText: String? { + self.entries.first?.clipboardText + } + /// Add a new transcription entry func addEntry( id: UUID = UUID(), diff --git a/Sources/Fluid/Services/MenuBarManager.swift b/Sources/Fluid/Services/MenuBarManager.swift index 35c207ef..ae132421 100644 --- a/Sources/Fluid/Services/MenuBarManager.swift +++ b/Sources/Fluid/Services/MenuBarManager.swift @@ -17,6 +17,7 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { // Cached menu items to avoid rebuilding entire menu private var statusMenuItem: NSMenuItem? + private var copyLastTranscriptMenuItem: NSMenuItem? private var rollbackMenuItem: NSMenuItem? private var microphoneMenuItem: NSMenuItem? private var microphoneSubmenu: NSMenu? @@ -417,6 +418,15 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { menu.addItem(statusItem) } + let copyLastTranscriptItem = NSMenuItem( + title: "Copy Last Transcript", + action: #selector(copyLastTranscript(_:)), + keyEquivalent: "" + ) + copyLastTranscriptItem.target = self + menu.addItem(copyLastTranscriptItem) + self.copyLastTranscriptMenuItem = copyLastTranscriptItem + menu.addItem(.separator()) // Open Main Window @@ -497,6 +507,7 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { let hotkeyInfo = hotkeyDisplay.isEmpty ? "" : " (\(hotkeyDisplay))" let statusTitle = self.isRecording ? "Recording...\(hotkeyInfo)" : "Ready to Record\(hotkeyInfo)" self.statusMenuItem?.title = statusTitle + self.copyLastTranscriptMenuItem?.isEnabled = self.canCopyLastTranscript self.microphoneMenuItem?.isEnabled = true // Update rollback availability text @@ -569,6 +580,20 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { return defaultInputUID } + private var canCopyLastTranscript: Bool { + !self.isProcessingActive && TranscriptionHistoryStore.shared.latestClipboardText != nil + } + + @objc private func copyLastTranscript(_ sender: Any?) { + guard let text = TranscriptionHistoryStore.shared.latestClipboardText else { + DebugLogger.shared.info("Menu action: Copy last transcript requested but history is empty", source: "MenuBarManager") + return + } + + _ = ClipboardService.copyToClipboard(text) + DebugLogger.shared.info("Menu action: Copied latest transcription to clipboard", source: "MenuBarManager") + } + @objc private func selectMicrophone(_ sender: NSMenuItem) { guard self.isRecording == false else { return } guard let uid = sender.representedObject as? String, !uid.isEmpty else { return } diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index d659e03b..41bfd2a5 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -36,6 +36,42 @@ final class DictationE2ETests: XCTestCase { private let verifiedProviderFingerprintsKey = "VerifiedProviderFingerprints" + func testTranscriptionHistoryEntryClipboardTextPrefersProcessedText() { + let entry = TranscriptionHistoryEntry( + rawText: " raw transcript ", + processedText: " processed transcript ", + appName: "Notes", + windowTitle: "Draft", + wasAIProcessed: true + ) + + XCTAssertEqual(entry.clipboardText, "processed transcript") + } + + func testTranscriptionHistoryEntryClipboardTextFallsBackToRawText() { + let entry = TranscriptionHistoryEntry( + rawText: " raw transcript ", + processedText: " ", + appName: "Notes", + windowTitle: "Draft", + wasAIProcessed: false + ) + + XCTAssertEqual(entry.clipboardText, "raw transcript") + } + + func testTranscriptionHistoryEntryClipboardTextSkipsEmptyText() { + let entry = TranscriptionHistoryEntry( + rawText: " ", + processedText: " ", + appName: "Notes", + windowTitle: "Draft", + wasAIProcessed: false + ) + + XCTAssertNil(entry.clipboardText) + } + func testTranscriptionStartSound_noneOptionHasNoFile() { XCTAssertEqual(SettingsStore.TranscriptionStartSound.none.displayName, "None") XCTAssertNil(SettingsStore.TranscriptionStartSound.none.startSoundFileName) From 58211064a6813bcd1b1ab36723caaf875385cb72 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:23:15 -0700 Subject: [PATCH 2/2] Tighten copy transcript menu state --- Sources/Fluid/Services/MenuBarManager.swift | 6 +++++- Sources/Fluid/Views/BottomOverlayView.swift | 5 +---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/Fluid/Services/MenuBarManager.swift b/Sources/Fluid/Services/MenuBarManager.swift index ae132421..139654e3 100644 --- a/Sources/Fluid/Services/MenuBarManager.swift +++ b/Sources/Fluid/Services/MenuBarManager.swift @@ -304,6 +304,7 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { // Track processing state to prevent hide during AI refinement self.isProcessingActive = processing + self.updateMenuItemsText() if processing { self.pendingProcessingShowOperation?.cancel() @@ -390,6 +391,7 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { // Create menu self.menu = NSMenu() + self.menu?.autoenablesItems = false self.menu?.delegate = self statusItem.menu = self.menu @@ -585,7 +587,9 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { } @objc private func copyLastTranscript(_ sender: Any?) { - guard let text = TranscriptionHistoryStore.shared.latestClipboardText else { + guard self.canCopyLastTranscript, + let text = TranscriptionHistoryStore.shared.latestClipboardText + else { DebugLogger.shared.info("Menu action: Copy last transcript requested but history is empty", source: "MenuBarManager") return } diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index c8aa2e3f..eee90ac8 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -1533,10 +1533,7 @@ private struct BottomOverlayActionsMenuView: View { private var canCopyLast: Bool { guard !self.contentState.isProcessing else { return false } - guard let latest = self.latestEntry else { return false } - let processed = latest.processedText.trimmingCharacters(in: .whitespacesAndNewlines) - let raw = latest.rawText.trimmingCharacters(in: .whitespacesAndNewlines) - return !(processed.isEmpty && raw.isEmpty) + return self.latestEntry?.clipboardText != nil } private var canUndoLastAI: Bool {