Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 2 additions & 12 deletions Sources/Fluid/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
11 changes: 11 additions & 0 deletions Sources/Fluid/Persistence/TranscriptionHistoryStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(),
Expand Down
29 changes: 29 additions & 0 deletions Sources/Fluid/Services/MenuBarManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -303,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()
Expand Down Expand Up @@ -389,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

Expand Down Expand Up @@ -417,6 +420,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
Expand Down Expand Up @@ -497,6 +509,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
Expand Down Expand Up @@ -569,6 +582,22 @@ 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 self.canCopyLastTranscript,
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 }
Expand Down
5 changes: 1 addition & 4 deletions Sources/Fluid/Views/BottomOverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions Tests/FluidDictationIntegrationTests/DictationE2ETests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down