From 0a9899b33052c9f2313695e6a8acc0e9f416566e Mon Sep 17 00:00:00 2001 From: Fe2-O3 Date: Thu, 9 Jul 2026 20:59:25 -0700 Subject: [PATCH 01/12] feat(history): add HistoryCopy helper for copy-gesture text selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Central, unit-tested seam for what double-click/⌘C copy from the History list. Delegates to TranscriptionHistoryEntry.clipboardText. Refs #566 [authored on non-Xcode host; pending build+test verification on studio] --- Sources/Fluid/UI/HistoryCopy.swift | 23 ++++++++++ .../HistoryCopyTests.swift | 44 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 Sources/Fluid/UI/HistoryCopy.swift create mode 100644 Tests/FluidDictationIntegrationTests/HistoryCopyTests.swift diff --git a/Sources/Fluid/UI/HistoryCopy.swift b/Sources/Fluid/UI/HistoryCopy.swift new file mode 100644 index 00000000..ef2633b2 --- /dev/null +++ b/Sources/Fluid/UI/HistoryCopy.swift @@ -0,0 +1,23 @@ +import AppKit +import Foundation + +/// Decides what a copy *gesture* (double-click / ⌘C) in the Transcription +/// History list places on the pasteboard. Keeps the gesture wiring in +/// `TranscriptionHistoryView` thin and gives the behavior a unit-testable seam. +enum HistoryCopy { + /// Text a copy gesture should place on the pasteboard for `entry`, or `nil` + /// when the entry has nothing copyable. Delegates to + /// `TranscriptionHistoryEntry.clipboardText` — the same processed-then-raw + /// selection PR #450 centralized for the menu-bar copy. + static func text(for entry: TranscriptionHistoryEntry) -> String? { + entry.clipboardText + } + + /// Item providers for the standard Copy command (⌘C / Edit ▸ Copy), used by + /// `.onCopyCommand`. Empty when there is no selection or no text, so the Copy + /// command becomes a no-op instead of clearing the pasteboard. + static func itemProviders(for entry: TranscriptionHistoryEntry?) -> [NSItemProvider] { + guard let entry, let text = text(for: entry) else { return [] } + return [NSItemProvider(object: text as NSString)] + } +} diff --git a/Tests/FluidDictationIntegrationTests/HistoryCopyTests.swift b/Tests/FluidDictationIntegrationTests/HistoryCopyTests.swift new file mode 100644 index 00000000..1a43fa48 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/HistoryCopyTests.swift @@ -0,0 +1,44 @@ +@testable import FluidVoice_Debug +import Foundation +import XCTest + +final class HistoryCopyTests: XCTestCase { + private func makeEntry(raw: String, processed: String, aiProcessed: Bool) -> TranscriptionHistoryEntry { + TranscriptionHistoryEntry( + rawText: raw, + processedText: processed, + appName: "Notes", + windowTitle: "Draft", + wasAIProcessed: aiProcessed + ) + } + + func testTextPrefersProcessedText() { + let entry = makeEntry(raw: " raw ", processed: " processed ", aiProcessed: true) + XCTAssertEqual(HistoryCopy.text(for: entry), "processed") + } + + func testTextFallsBackToRawWhenProcessedEmpty() { + let entry = makeEntry(raw: " raw ", processed: " ", aiProcessed: false) + XCTAssertEqual(HistoryCopy.text(for: entry), "raw") + } + + func testTextIsNilWhenBothEmpty() { + let entry = makeEntry(raw: " ", processed: " ", aiProcessed: false) + XCTAssertNil(HistoryCopy.text(for: entry)) + } + + func testItemProvidersEmptyForNilSelection() { + XCTAssertTrue(HistoryCopy.itemProviders(for: nil).isEmpty) + } + + func testItemProvidersEmptyForEmptyEntry() { + let entry = makeEntry(raw: " ", processed: " ", aiProcessed: false) + XCTAssertTrue(HistoryCopy.itemProviders(for: entry).isEmpty) + } + + func testItemProvidersHasOneProviderForNonEmptyEntry() { + let entry = makeEntry(raw: " raw ", processed: " processed ", aiProcessed: true) + XCTAssertEqual(HistoryCopy.itemProviders(for: entry).count, 1) + } +} From 1707aa71fe618a77e8b1c943dced93035f338531 Mon Sep 17 00:00:00 2001 From: Fe2-O3 Date: Thu, 9 Jul 2026 21:00:02 -0700 Subject: [PATCH 02/12] feat(history): double-click a history row to copy it Adds a count:2 simultaneousGesture that selects the row and copies its text via HistoryCopy. Single-click selection is unchanged. Refs #566 [authored on non-Xcode host; pending build+test verification on studio] --- Sources/Fluid/UI/TranscriptionHistoryView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/Fluid/UI/TranscriptionHistoryView.swift b/Sources/Fluid/UI/TranscriptionHistoryView.swift index b4aab1fa..c7fbf4fb 100644 --- a/Sources/Fluid/UI/TranscriptionHistoryView.swift +++ b/Sources/Fluid/UI/TranscriptionHistoryView.swift @@ -197,6 +197,14 @@ struct TranscriptionHistoryView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .simultaneousGesture( + TapGesture(count: 2).onEnded { + self.selectedEntryID = entry.id + if let text = HistoryCopy.text(for: entry) { + self.copyToClipboard(text) + } + } + ) .contextMenu { Button { self.copyToClipboard(entry.processedText) From a93cd6c677f27fe1d8bf69b4d93b8738e85a7630 Mon Sep 17 00:00:00 2001 From: Fe2-O3 Date: Thu, 9 Jul 2026 21:00:21 -0700 Subject: [PATCH 03/12] =?UTF-8?q?feat(history):=20=E2=8C=98C=20copies=20th?= =?UTF-8?q?e=20selected=20history=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .onCopyCommand on the list; responder-chain based so it does not shadow ⌘C in the search field. Also enables Edit ▸ Copy. Refs #566 [authored on non-Xcode host; pending build+test verification on studio] --- Sources/Fluid/UI/TranscriptionHistoryView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/Fluid/UI/TranscriptionHistoryView.swift b/Sources/Fluid/UI/TranscriptionHistoryView.swift index c7fbf4fb..0987bc41 100644 --- a/Sources/Fluid/UI/TranscriptionHistoryView.swift +++ b/Sources/Fluid/UI/TranscriptionHistoryView.swift @@ -129,6 +129,9 @@ struct TranscriptionHistoryView: View { .padding(.horizontal, 8) .padding(.vertical, 6) } + .onCopyCommand { + HistoryCopy.itemProviders(for: self.selectedEntry) + } } private func entryRow(_ entry: TranscriptionHistoryEntry) -> some View { From 2cb0d97bc22ca15f84e906d0f92a5c4b7252a29f Mon Sep 17 00:00:00 2001 From: Fe2-O3 Date: Thu, 9 Jul 2026 21:01:19 -0700 Subject: [PATCH 04/12] feat(history): brief "Copied" confirmation on copy gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transient per-row pill shown after double-click/⌘C copy, since neither gesture gives other feedback. Optional polish; isolated commit so it can be dropped if maintainers prefer no toast. Refs #566 [authored on non-Xcode host; pending build+test verification on studio] --- .../Fluid/UI/TranscriptionHistoryView.swift | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/Sources/Fluid/UI/TranscriptionHistoryView.swift b/Sources/Fluid/UI/TranscriptionHistoryView.swift index 0987bc41..0f2c3ff8 100644 --- a/Sources/Fluid/UI/TranscriptionHistoryView.swift +++ b/Sources/Fluid/UI/TranscriptionHistoryView.swift @@ -11,6 +11,7 @@ struct TranscriptionHistoryView: View { @State private var showReportConfirmation: Bool = false @State private var selectedReportEntry: TranscriptionHistoryEntry? @State private var selectedEntryID: UUID? + @State private var recentlyCopiedEntryID: UUID? private var filteredEntries: [TranscriptionHistoryEntry] { self.historyStore.search(query: self.searchQuery) @@ -130,7 +131,11 @@ struct TranscriptionHistoryView: View { .padding(.vertical, 6) } .onCopyCommand { - HistoryCopy.itemProviders(for: self.selectedEntry) + let providers = HistoryCopy.itemProviders(for: self.selectedEntry) + if !providers.isEmpty, let id = self.selectedEntry?.id { + self.flashCopied(id) + } + return providers } } @@ -176,6 +181,19 @@ struct TranscriptionHistoryView: View { .help(entry.aiProcessingError ?? "") } + if self.recentlyCopiedEntryID == entry.id { + Text("Copied") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(isSelected ? .white : self.theme.palette.accent) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(isSelected ? .white.opacity(0.2) : self.theme.palette.accent.opacity(0.15)) + ) + .transition(.opacity) + } + Spacer() Text(entry.relativeTimeString) @@ -205,6 +223,7 @@ struct TranscriptionHistoryView: View { self.selectedEntryID = entry.id if let text = HistoryCopy.text(for: entry) { self.copyToClipboard(text) + self.flashCopied(entry.id) } } ) @@ -575,6 +594,16 @@ struct TranscriptionHistoryView: View { NSPasteboard.general.setString(text, forType: .string) } + private func flashCopied(_ id: UUID) { + self.recentlyCopiedEntryID = id + Task { + try? await Task.sleep(for: .seconds(1.2)) + if self.recentlyCopiedEntryID == id { + self.recentlyCopiedEntryID = nil + } + } + } + private func openFeedbackReport(for entry: TranscriptionHistoryEntry) { self.selectedReportEntry = entry } From ecc652527937878abe2b9052abb08b955f85ee19 Mon Sep 17 00:00:00 2001 From: Fe2-O3 Date: Thu, 9 Jul 2026 21:10:29 -0700 Subject: [PATCH 05/12] test(history): move HistoryCopy tests into the test target The standalone HistoryCopyTests.swift was not a member of the FluidDictationIntegrationTests target (explicit membership, not folder-synced), so its cases never ran. Relocate the 6 tests next to the sibling clipboardText tests in DictationE2ETests.swift. Refs #566 --- .../DictationE2ETests.swift | 41 +++++++++++++++++ .../HistoryCopyTests.swift | 44 ------------------- 2 files changed, 41 insertions(+), 44 deletions(-) delete mode 100644 Tests/FluidDictationIntegrationTests/HistoryCopyTests.swift diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 642718a9..62539291 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -80,6 +80,47 @@ final class DictationE2ETests: XCTestCase { XCTAssertNil(entry.clipboardText) } + // MARK: - HistoryCopy (copy-gesture text selection for the History list) + + private func makeHistoryEntry(raw: String, processed: String, aiProcessed: Bool) -> TranscriptionHistoryEntry { + TranscriptionHistoryEntry( + rawText: raw, + processedText: processed, + appName: "Notes", + windowTitle: "Draft", + wasAIProcessed: aiProcessed + ) + } + + func testHistoryCopyTextPrefersProcessedText() { + let entry = self.makeHistoryEntry(raw: " raw ", processed: " processed ", aiProcessed: true) + XCTAssertEqual(HistoryCopy.text(for: entry), "processed") + } + + func testHistoryCopyTextFallsBackToRawWhenProcessedEmpty() { + let entry = self.makeHistoryEntry(raw: " raw ", processed: " ", aiProcessed: false) + XCTAssertEqual(HistoryCopy.text(for: entry), "raw") + } + + func testHistoryCopyTextIsNilWhenBothEmpty() { + let entry = self.makeHistoryEntry(raw: " ", processed: " ", aiProcessed: false) + XCTAssertNil(HistoryCopy.text(for: entry)) + } + + func testHistoryCopyItemProvidersEmptyForNilSelection() { + XCTAssertTrue(HistoryCopy.itemProviders(for: nil).isEmpty) + } + + func testHistoryCopyItemProvidersEmptyForEmptyEntry() { + let entry = self.makeHistoryEntry(raw: " ", processed: " ", aiProcessed: false) + XCTAssertTrue(HistoryCopy.itemProviders(for: entry).isEmpty) + } + + func testHistoryCopyItemProvidersHasOneProviderForNonEmptyEntry() { + let entry = self.makeHistoryEntry(raw: " raw ", processed: " processed ", aiProcessed: true) + XCTAssertEqual(HistoryCopy.itemProviders(for: entry).count, 1) + } + func testTranscriptionStartSound_noneOptionHasNoFile() { XCTAssertEqual(SettingsStore.TranscriptionStartSound.none.displayName, "None") XCTAssertNil(SettingsStore.TranscriptionStartSound.none.startSoundFileName) diff --git a/Tests/FluidDictationIntegrationTests/HistoryCopyTests.swift b/Tests/FluidDictationIntegrationTests/HistoryCopyTests.swift deleted file mode 100644 index 1a43fa48..00000000 --- a/Tests/FluidDictationIntegrationTests/HistoryCopyTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -@testable import FluidVoice_Debug -import Foundation -import XCTest - -final class HistoryCopyTests: XCTestCase { - private func makeEntry(raw: String, processed: String, aiProcessed: Bool) -> TranscriptionHistoryEntry { - TranscriptionHistoryEntry( - rawText: raw, - processedText: processed, - appName: "Notes", - windowTitle: "Draft", - wasAIProcessed: aiProcessed - ) - } - - func testTextPrefersProcessedText() { - let entry = makeEntry(raw: " raw ", processed: " processed ", aiProcessed: true) - XCTAssertEqual(HistoryCopy.text(for: entry), "processed") - } - - func testTextFallsBackToRawWhenProcessedEmpty() { - let entry = makeEntry(raw: " raw ", processed: " ", aiProcessed: false) - XCTAssertEqual(HistoryCopy.text(for: entry), "raw") - } - - func testTextIsNilWhenBothEmpty() { - let entry = makeEntry(raw: " ", processed: " ", aiProcessed: false) - XCTAssertNil(HistoryCopy.text(for: entry)) - } - - func testItemProvidersEmptyForNilSelection() { - XCTAssertTrue(HistoryCopy.itemProviders(for: nil).isEmpty) - } - - func testItemProvidersEmptyForEmptyEntry() { - let entry = makeEntry(raw: " ", processed: " ", aiProcessed: false) - XCTAssertTrue(HistoryCopy.itemProviders(for: entry).isEmpty) - } - - func testItemProvidersHasOneProviderForNonEmptyEntry() { - let entry = makeEntry(raw: " raw ", processed: " processed ", aiProcessed: true) - XCTAssertEqual(HistoryCopy.itemProviders(for: entry).count, 1) - } -} From 22754c18dfe12bf42e35a24aebcd67d4ee046a88 Mon Sep 17 00:00:00 2001 From: Fe2-O3 Date: Thu, 9 Jul 2026 21:57:10 -0700 Subject: [PATCH 06/12] feat(history): unify "Copied" feedback across all copy actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The copy button, right-click Copy (Text/Raw/Both), double-click, and ⌘C now all trigger the same "Copied" confirmation. Adds a matching indicator in the detail header next to the Copy button. Refs #566 [authored on non-Xcode host; pending build+test verification on studio] --- .../Fluid/UI/TranscriptionHistoryView.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Sources/Fluid/UI/TranscriptionHistoryView.swift b/Sources/Fluid/UI/TranscriptionHistoryView.swift index 0f2c3ff8..a698d033 100644 --- a/Sources/Fluid/UI/TranscriptionHistoryView.swift +++ b/Sources/Fluid/UI/TranscriptionHistoryView.swift @@ -230,6 +230,7 @@ struct TranscriptionHistoryView: View { .contextMenu { Button { self.copyToClipboard(entry.processedText) + self.flashCopied(entry.id) } label: { Label(entry.wasAIProcessed ? "Copy AI Text" : "Copy Text", systemImage: "doc.on.doc") } @@ -237,12 +238,14 @@ struct TranscriptionHistoryView: View { if entry.wasAIProcessed { Button { self.copyToClipboard(entry.rawText) + self.flashCopied(entry.id) } label: { Label("Copy Raw Text", systemImage: "doc.on.doc.fill") } Button { self.copyToClipboard(self.combinedText(for: entry)) + self.flashCopied(entry.id) } label: { Label("Copy Both", systemImage: "doc.on.doc") } @@ -360,10 +363,24 @@ struct TranscriptionHistoryView: View { Text("Transcription Details") .font(.system(size: 18, weight: .semibold)) + if self.recentlyCopiedEntryID == entry.id { + Text("Copied") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(self.theme.palette.accent) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(self.theme.palette.accent.opacity(0.15)) + ) + .transition(.opacity) + } + Spacer() Button { self.copyToClipboard(entry.processedText) + self.flashCopied(entry.id) } label: { Label(entry.wasAIProcessed ? "Copy AI" : "Copy", systemImage: "doc.on.doc") .font(.system(size: 12, weight: .medium)) @@ -404,6 +421,7 @@ struct TranscriptionHistoryView: View { if entry.wasAIProcessed { Button { self.copyToClipboard(entry.rawText) + self.flashCopied(entry.id) } label: { Label("Raw", systemImage: "doc.on.doc.fill") .font(.system(size: 12, weight: .medium)) @@ -413,6 +431,7 @@ struct TranscriptionHistoryView: View { Button { self.copyToClipboard(self.combinedText(for: entry)) + self.flashCopied(entry.id) } label: { Label("Both", systemImage: "doc.on.doc") .font(.system(size: 12, weight: .medium)) From 4cd1f5d31ca3703d24c9188544dc63f8319bdf7d Mon Sep 17 00:00:00 2001 From: Fe2-O3 Date: Thu, 9 Jul 2026 22:32:20 -0700 Subject: [PATCH 07/12] feat(history): show "Copied" as a transient toast at the cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the inline row/header "Copied" pills with a small floating panel that appears next to the mouse cursor and fades out — the expected copy feedback, shown where the user is looking. Non-activating panel so ⌘V still targets the frontmost app. All copy actions share it. Refs #566 [authored on non-Xcode host; pending build verification on studio] --- Sources/Fluid/UI/CursorCopyToast.swift | 107 ++++++++++++++++++ .../Fluid/UI/TranscriptionHistoryView.swift | 37 +----- 2 files changed, 109 insertions(+), 35 deletions(-) create mode 100644 Sources/Fluid/UI/CursorCopyToast.swift diff --git a/Sources/Fluid/UI/CursorCopyToast.swift b/Sources/Fluid/UI/CursorCopyToast.swift new file mode 100644 index 00000000..f0c47eee --- /dev/null +++ b/Sources/Fluid/UI/CursorCopyToast.swift @@ -0,0 +1,107 @@ +import AppKit +import SwiftUI + +/// A small, transient "Copied" confirmation that appears next to the mouse +/// cursor and fades away — the pattern people expect for a copy action, so the +/// feedback shows up where the user is already looking instead of docked to a +/// row. Presented in a borderless, non-activating floating panel so it never +/// steals focus (a subsequent ⌘V still pastes into the user's target app). +/// +/// Shared by every copy action in the Transcription History view (double-click, +/// ⌘C, the Copy button, and the right-click menu). +@MainActor +final class CursorCopyToast { + static let shared = CursorCopyToast() + + private var panel: NSPanel? + private var dismissTask: Task? + + private init() {} + + /// Show the toast at the current cursor location. Safe to call repeatedly; + /// each call resets the auto-dismiss timer. + func show(_ text: String = "Copied") { + let panel = self.panel ?? self.makePanel() + self.panel = panel + + let host = NSHostingView(rootView: CursorCopyToastView(text: text)) + host.layout() + let size = host.fittingSize + panel.contentView = host + panel.setContentSize(size) + + // Position just above-right of the cursor, clamped to its screen. + let mouse = NSEvent.mouseLocation + var origin = CGPoint(x: mouse.x + 14, y: mouse.y + 14) + let screen = NSScreen.screens.first(where: { $0.frame.contains(mouse) }) ?? NSScreen.main + if let visible = screen?.visibleFrame { + origin.x = min(max(origin.x, visible.minX + 8), visible.maxX - size.width - 8) + origin.y = min(max(origin.y, visible.minY + 8), visible.maxY - size.height - 8) + } + panel.setFrameOrigin(origin) + + panel.alphaValue = 0 + panel.orderFrontRegardless() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + panel.animator().alphaValue = 1 + } + + self.dismissTask?.cancel() + self.dismissTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(0.9)) + guard !Task.isCancelled else { return } + self?.dismiss() + } + } + + private func dismiss() { + guard let panel = self.panel else { return } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.2 + panel.animator().alphaValue = 0 + } completionHandler: { + panel.orderOut(nil) + } + } + + private func makePanel() -> NSPanel { + let panel = NSPanel( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + panel.isFloatingPanel = true + panel.level = .floating + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = true + panel.ignoresMouseEvents = true + panel.hidesOnDeactivate = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle] + return panel + } +} + +private struct CursorCopyToastView: View { + let text: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 12, weight: .semibold)) + Text(self.text) + .font(.system(size: 12, weight: .semibold)) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(.primary.opacity(0.08)) + ) + .fixedSize() + .padding(4) + } +} diff --git a/Sources/Fluid/UI/TranscriptionHistoryView.swift b/Sources/Fluid/UI/TranscriptionHistoryView.swift index a698d033..dd0dafcf 100644 --- a/Sources/Fluid/UI/TranscriptionHistoryView.swift +++ b/Sources/Fluid/UI/TranscriptionHistoryView.swift @@ -11,7 +11,6 @@ struct TranscriptionHistoryView: View { @State private var showReportConfirmation: Bool = false @State private var selectedReportEntry: TranscriptionHistoryEntry? @State private var selectedEntryID: UUID? - @State private var recentlyCopiedEntryID: UUID? private var filteredEntries: [TranscriptionHistoryEntry] { self.historyStore.search(query: self.searchQuery) @@ -181,19 +180,6 @@ struct TranscriptionHistoryView: View { .help(entry.aiProcessingError ?? "") } - if self.recentlyCopiedEntryID == entry.id { - Text("Copied") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(isSelected ? .white : self.theme.palette.accent) - .padding(.horizontal, 4) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 3) - .fill(isSelected ? .white.opacity(0.2) : self.theme.palette.accent.opacity(0.15)) - ) - .transition(.opacity) - } - Spacer() Text(entry.relativeTimeString) @@ -363,19 +349,6 @@ struct TranscriptionHistoryView: View { Text("Transcription Details") .font(.system(size: 18, weight: .semibold)) - if self.recentlyCopiedEntryID == entry.id { - Text("Copied") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(self.theme.palette.accent) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(self.theme.palette.accent.opacity(0.15)) - ) - .transition(.opacity) - } - Spacer() Button { @@ -613,14 +586,8 @@ struct TranscriptionHistoryView: View { NSPasteboard.general.setString(text, forType: .string) } - private func flashCopied(_ id: UUID) { - self.recentlyCopiedEntryID = id - Task { - try? await Task.sleep(for: .seconds(1.2)) - if self.recentlyCopiedEntryID == id { - self.recentlyCopiedEntryID = nil - } - } + private func flashCopied(_: UUID) { + CursorCopyToast.shared.show() } private func openFeedbackReport(for entry: TranscriptionHistoryEntry) { From bc16449cab6fe172a419269c754ad7457b46bf45 Mon Sep 17 00:00:00 2001 From: Fe2-O3 Date: Thu, 9 Jul 2026 22:37:24 -0700 Subject: [PATCH 08/12] =?UTF-8?q?refactor(history):=20defer=20=E2=8C=98C?= =?UTF-8?q?=20to=20keyboard-support=20follow-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘C via .onCopyCommand never fires because the custom Button-based list never becomes first responder (same root cause as arrow keys driving the sidebar instead of the list). Remove the non-working path and its helper so this PR ships only what works: double-click + right-click + Copy button, all with the cursor toast. ⌘C is tracked with keyboard navigation in a follow-up issue. Refs #566 --- Sources/Fluid/UI/HistoryCopy.swift | 13 ++----------- Sources/Fluid/UI/TranscriptionHistoryView.swift | 7 ------- .../DictationE2ETests.swift | 14 -------------- 3 files changed, 2 insertions(+), 32 deletions(-) diff --git a/Sources/Fluid/UI/HistoryCopy.swift b/Sources/Fluid/UI/HistoryCopy.swift index ef2633b2..10b98f09 100644 --- a/Sources/Fluid/UI/HistoryCopy.swift +++ b/Sources/Fluid/UI/HistoryCopy.swift @@ -1,8 +1,7 @@ -import AppKit import Foundation -/// Decides what a copy *gesture* (double-click / ⌘C) in the Transcription -/// History list places on the pasteboard. Keeps the gesture wiring in +/// Decides what a copy *gesture* (double-click) in the Transcription History +/// list places on the pasteboard. Keeps the gesture wiring in /// `TranscriptionHistoryView` thin and gives the behavior a unit-testable seam. enum HistoryCopy { /// Text a copy gesture should place on the pasteboard for `entry`, or `nil` @@ -12,12 +11,4 @@ enum HistoryCopy { static func text(for entry: TranscriptionHistoryEntry) -> String? { entry.clipboardText } - - /// Item providers for the standard Copy command (⌘C / Edit ▸ Copy), used by - /// `.onCopyCommand`. Empty when there is no selection or no text, so the Copy - /// command becomes a no-op instead of clearing the pasteboard. - static func itemProviders(for entry: TranscriptionHistoryEntry?) -> [NSItemProvider] { - guard let entry, let text = text(for: entry) else { return [] } - return [NSItemProvider(object: text as NSString)] - } } diff --git a/Sources/Fluid/UI/TranscriptionHistoryView.swift b/Sources/Fluid/UI/TranscriptionHistoryView.swift index dd0dafcf..b758ffb7 100644 --- a/Sources/Fluid/UI/TranscriptionHistoryView.swift +++ b/Sources/Fluid/UI/TranscriptionHistoryView.swift @@ -129,13 +129,6 @@ struct TranscriptionHistoryView: View { .padding(.horizontal, 8) .padding(.vertical, 6) } - .onCopyCommand { - let providers = HistoryCopy.itemProviders(for: self.selectedEntry) - if !providers.isEmpty, let id = self.selectedEntry?.id { - self.flashCopied(id) - } - return providers - } } private func entryRow(_ entry: TranscriptionHistoryEntry) -> some View { diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 62539291..cbacf049 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -107,20 +107,6 @@ final class DictationE2ETests: XCTestCase { XCTAssertNil(HistoryCopy.text(for: entry)) } - func testHistoryCopyItemProvidersEmptyForNilSelection() { - XCTAssertTrue(HistoryCopy.itemProviders(for: nil).isEmpty) - } - - func testHistoryCopyItemProvidersEmptyForEmptyEntry() { - let entry = self.makeHistoryEntry(raw: " ", processed: " ", aiProcessed: false) - XCTAssertTrue(HistoryCopy.itemProviders(for: entry).isEmpty) - } - - func testHistoryCopyItemProvidersHasOneProviderForNonEmptyEntry() { - let entry = self.makeHistoryEntry(raw: " raw ", processed: " processed ", aiProcessed: true) - XCTAssertEqual(HistoryCopy.itemProviders(for: entry).count, 1) - } - func testTranscriptionStartSound_noneOptionHasNoFile() { XCTAssertEqual(SettingsStore.TranscriptionStartSound.none.displayName, "None") XCTAssertNil(SettingsStore.TranscriptionStartSound.none.startSoundFileName) From b21a280cafe3acb3fddb2d86fd1cc0f3d3b7f63c Mon Sep 17 00:00:00 2001 From: Fe2-O3 Date: Thu, 9 Jul 2026 22:50:49 -0700 Subject: [PATCH 09/12] =?UTF-8?q?feat(history):=20keyboard=20navigation=20?= =?UTF-8?q?+=20=E2=8C=98C=20for=20the=20History=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the entry list a real keyboard focus target: focusable + .onMoveCommand (↑/↓ moves the selection and scrolls it into view) + .onCopyCommand (⌘C copies the selected entry, respecting the search field). Clicking a row focuses the list. Nav index math lives in HistoryCopy.nextIndex with unit tests. Fixes the root cause behind both the non-firing ⌘C and arrow keys driving the sidebar instead of the list. Refs #566, #567 [authored on non-Xcode host; pending build+test verification on studio] --- Sources/Fluid/UI/HistoryCopy.swift | 11 ++++ .../Fluid/UI/TranscriptionHistoryView.swift | 57 +++++++++++++++++-- .../DictationE2ETests.swift | 28 +++++++++ 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/Sources/Fluid/UI/HistoryCopy.swift b/Sources/Fluid/UI/HistoryCopy.swift index 10b98f09..cec99d64 100644 --- a/Sources/Fluid/UI/HistoryCopy.swift +++ b/Sources/Fluid/UI/HistoryCopy.swift @@ -11,4 +11,15 @@ enum HistoryCopy { static func text(for entry: TranscriptionHistoryEntry) -> String? { entry.clipboardText } + + /// The index to select when the user presses ↑ (`moveUp == true`) or ↓ in + /// the History list, clamped to the list bounds. Returns `nil` for an empty + /// list. A `nil` current selection moves to the first row either way. + static func nextIndex(current: Int?, count: Int, moveUp: Bool) -> Int? { + guard count > 0 else { return nil } + if moveUp { + return max(0, (current ?? 0) - 1) + } + return min(count - 1, (current ?? -1) + 1) + } } diff --git a/Sources/Fluid/UI/TranscriptionHistoryView.swift b/Sources/Fluid/UI/TranscriptionHistoryView.swift index b758ffb7..2f0d3625 100644 --- a/Sources/Fluid/UI/TranscriptionHistoryView.swift +++ b/Sources/Fluid/UI/TranscriptionHistoryView.swift @@ -11,6 +11,7 @@ struct TranscriptionHistoryView: View { @State private var showReportConfirmation: Bool = false @State private var selectedReportEntry: TranscriptionHistoryEntry? @State private var selectedEntryID: UUID? + @FocusState private var listFocused: Bool private var filteredEntries: [TranscriptionHistoryEntry] { self.historyStore.search(query: self.searchQuery) @@ -120,14 +121,26 @@ struct TranscriptionHistoryView: View { // MARK: - Entry List private var entryListView: some View { - ScrollView { - LazyVStack(spacing: 2) { - ForEach(self.filteredEntries) { entry in - self.entryRow(entry) + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 2) { + ForEach(self.filteredEntries) { entry in + self.entryRow(entry) + .id(entry.id) + } } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + .focusable() + .focusEffectDisabled() + .focused(self.$listFocused) + .onMoveCommand { direction in + self.moveSelection(direction, scrollProxy: proxy) + } + .onCopyCommand { + self.copyCommandProviders() } - .padding(.horizontal, 8) - .padding(.vertical, 6) } } @@ -138,6 +151,7 @@ struct TranscriptionHistoryView: View { withAnimation(.easeInOut(duration: 0.15)) { self.selectedEntryID = entry.id } + self.listFocused = true } label: { VStack(alignment: .leading, spacing: 4) { // Top row: App name and time @@ -200,6 +214,7 @@ struct TranscriptionHistoryView: View { .simultaneousGesture( TapGesture(count: 2).onEnded { self.selectedEntryID = entry.id + self.listFocused = true if let text = HistoryCopy.text(for: entry) { self.copyToClipboard(text) self.flashCopied(entry.id) @@ -583,6 +598,36 @@ struct TranscriptionHistoryView: View { CursorCopyToast.shared.show() } + private func moveSelection(_ direction: MoveCommandDirection, scrollProxy: ScrollViewProxy) { + let entries = self.filteredEntries + let currentIndex = self.selectedEntry.flatMap { selected in + entries.firstIndex(where: { $0.id == selected.id }) + } + let moveUp: Bool + switch direction { + case .up: + moveUp = true + case .down: + moveUp = false + default: + return + } + guard let newIndex = HistoryCopy.nextIndex(current: currentIndex, count: entries.count, moveUp: moveUp) else { + return + } + let id = entries[newIndex].id + self.selectedEntryID = id + withAnimation(.easeInOut(duration: 0.15)) { + scrollProxy.scrollTo(id, anchor: .center) + } + } + + private func copyCommandProviders() -> [NSItemProvider] { + guard let entry = self.selectedEntry, let text = HistoryCopy.text(for: entry) else { return [] } + self.flashCopied(entry.id) + return [NSItemProvider(object: text as NSString)] + } + private func openFeedbackReport(for entry: TranscriptionHistoryEntry) { self.selectedReportEntry = entry } diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index cbacf049..55a1cdb5 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -107,6 +107,34 @@ final class DictationE2ETests: XCTestCase { XCTAssertNil(HistoryCopy.text(for: entry)) } + func testHistoryNextIndexDownFromNilSelectsFirst() { + XCTAssertEqual(HistoryCopy.nextIndex(current: nil, count: 3, moveUp: false), 0) + } + + func testHistoryNextIndexUpFromNilSelectsFirst() { + XCTAssertEqual(HistoryCopy.nextIndex(current: nil, count: 3, moveUp: true), 0) + } + + func testHistoryNextIndexDownMovesToNext() { + XCTAssertEqual(HistoryCopy.nextIndex(current: 0, count: 3, moveUp: false), 1) + } + + func testHistoryNextIndexUpMovesToPrevious() { + XCTAssertEqual(HistoryCopy.nextIndex(current: 2, count: 3, moveUp: true), 1) + } + + func testHistoryNextIndexDownClampsAtEnd() { + XCTAssertEqual(HistoryCopy.nextIndex(current: 2, count: 3, moveUp: false), 2) + } + + func testHistoryNextIndexUpClampsAtStart() { + XCTAssertEqual(HistoryCopy.nextIndex(current: 0, count: 3, moveUp: true), 0) + } + + func testHistoryNextIndexEmptyListIsNil() { + XCTAssertNil(HistoryCopy.nextIndex(current: nil, count: 0, moveUp: false)) + } + func testTranscriptionStartSound_noneOptionHasNoFile() { XCTAssertEqual(SettingsStore.TranscriptionStartSound.none.displayName, "None") XCTAssertNil(SettingsStore.TranscriptionStartSound.none.startSoundFileName) From b1e16dbf9ad6ce0c1324e27e7ffe5fab350058c6 Mon Sep 17 00:00:00 2001 From: Fe2 O3 <33189015+Fe2-O3@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:02:55 -0700 Subject: [PATCH 10/12] fix(history): address Greptile review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CursorCopyToast: guard the fade-out completion with a generation counter so a copy fired during the 0.2s dismiss no longer hides the fresh confirmation toast. - copyCommandProviders: ignore ⌘C when no row is explicitly selected (was silently copying the first entry after Clear All). - flashCopied: drop the unused UUID parameter. Co-Authored-By: Claude Opus 4.8 --- Sources/Fluid/UI/CursorCopyToast.swift | 7 +++++- .../Fluid/UI/TranscriptionHistoryView.swift | 22 ++++++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/Sources/Fluid/UI/CursorCopyToast.swift b/Sources/Fluid/UI/CursorCopyToast.swift index f0c47eee..9e7d98b1 100644 --- a/Sources/Fluid/UI/CursorCopyToast.swift +++ b/Sources/Fluid/UI/CursorCopyToast.swift @@ -15,6 +15,7 @@ final class CursorCopyToast { private var panel: NSPanel? private var dismissTask: Task? + private var generation = 0 private init() {} @@ -23,6 +24,7 @@ final class CursorCopyToast { func show(_ text: String = "Copied") { let panel = self.panel ?? self.makePanel() self.panel = panel + self.generation &+= 1 let host = NSHostingView(rootView: CursorCopyToastView(text: text)) host.layout() @@ -57,10 +59,13 @@ final class CursorCopyToast { private func dismiss() { guard let panel = self.panel else { return } + let dismissGeneration = self.generation NSAnimationContext.runAnimationGroup { context in context.duration = 0.2 panel.animator().alphaValue = 0 - } completionHandler: { + } completionHandler: { [weak self] in + // Skip hiding if a newer show() re-displayed the toast during the fade. + guard let self, self.generation == dismissGeneration else { return } panel.orderOut(nil) } } diff --git a/Sources/Fluid/UI/TranscriptionHistoryView.swift b/Sources/Fluid/UI/TranscriptionHistoryView.swift index 2f0d3625..39210f66 100644 --- a/Sources/Fluid/UI/TranscriptionHistoryView.swift +++ b/Sources/Fluid/UI/TranscriptionHistoryView.swift @@ -217,14 +217,14 @@ struct TranscriptionHistoryView: View { self.listFocused = true if let text = HistoryCopy.text(for: entry) { self.copyToClipboard(text) - self.flashCopied(entry.id) + self.flashCopied() } } ) .contextMenu { Button { self.copyToClipboard(entry.processedText) - self.flashCopied(entry.id) + self.flashCopied() } label: { Label(entry.wasAIProcessed ? "Copy AI Text" : "Copy Text", systemImage: "doc.on.doc") } @@ -232,14 +232,14 @@ struct TranscriptionHistoryView: View { if entry.wasAIProcessed { Button { self.copyToClipboard(entry.rawText) - self.flashCopied(entry.id) + self.flashCopied() } label: { Label("Copy Raw Text", systemImage: "doc.on.doc.fill") } Button { self.copyToClipboard(self.combinedText(for: entry)) - self.flashCopied(entry.id) + self.flashCopied() } label: { Label("Copy Both", systemImage: "doc.on.doc") } @@ -361,7 +361,7 @@ struct TranscriptionHistoryView: View { Button { self.copyToClipboard(entry.processedText) - self.flashCopied(entry.id) + self.flashCopied() } label: { Label(entry.wasAIProcessed ? "Copy AI" : "Copy", systemImage: "doc.on.doc") .font(.system(size: 12, weight: .medium)) @@ -402,7 +402,7 @@ struct TranscriptionHistoryView: View { if entry.wasAIProcessed { Button { self.copyToClipboard(entry.rawText) - self.flashCopied(entry.id) + self.flashCopied() } label: { Label("Raw", systemImage: "doc.on.doc.fill") .font(.system(size: 12, weight: .medium)) @@ -412,7 +412,7 @@ struct TranscriptionHistoryView: View { Button { self.copyToClipboard(self.combinedText(for: entry)) - self.flashCopied(entry.id) + self.flashCopied() } label: { Label("Both", systemImage: "doc.on.doc") .font(.system(size: 12, weight: .medium)) @@ -594,7 +594,7 @@ struct TranscriptionHistoryView: View { NSPasteboard.general.setString(text, forType: .string) } - private func flashCopied(_: UUID) { + private func flashCopied() { CursorCopyToast.shared.show() } @@ -623,8 +623,10 @@ struct TranscriptionHistoryView: View { } private func copyCommandProviders() -> [NSItemProvider] { - guard let entry = self.selectedEntry, let text = HistoryCopy.text(for: entry) else { return [] } - self.flashCopied(entry.id) + guard self.selectedEntryID != nil, + let entry = self.selectedEntry, + let text = HistoryCopy.text(for: entry) else { return [] } + self.flashCopied() return [NSItemProvider(object: text as NSString)] } From 4a9258c14288663aefa15895286ec6530a5f616f Mon Sep 17 00:00:00 2001 From: Fe2 O3 <33189015+Fe2-O3@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:39:14 -0700 Subject: [PATCH 11/12] fix(history): harmonize copy paths on clipboardText + arrow-nav - Route the detail-pane Copy button and context-menu Copy Text through the same clipboardText selection (#450) already used by double-click, Cmd-C, and the Copy Last Transcript menu action. Fixes the Copy button copying raw, untrimmed processedText (and an empty string for transcriptions with no AI text). - moveSelection: derive the current index from the explicit selection so the first arrow press with nothing selected lands on the first row. Co-Authored-By: Claude Opus 4.8 --- Sources/Fluid/UI/TranscriptionHistoryView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Fluid/UI/TranscriptionHistoryView.swift b/Sources/Fluid/UI/TranscriptionHistoryView.swift index 39210f66..597470bd 100644 --- a/Sources/Fluid/UI/TranscriptionHistoryView.swift +++ b/Sources/Fluid/UI/TranscriptionHistoryView.swift @@ -223,7 +223,7 @@ struct TranscriptionHistoryView: View { ) .contextMenu { Button { - self.copyToClipboard(entry.processedText) + self.copyToClipboard(entry.clipboardText ?? entry.processedText) self.flashCopied() } label: { Label(entry.wasAIProcessed ? "Copy AI Text" : "Copy Text", systemImage: "doc.on.doc") @@ -360,7 +360,7 @@ struct TranscriptionHistoryView: View { Spacer() Button { - self.copyToClipboard(entry.processedText) + self.copyToClipboard(entry.clipboardText ?? entry.processedText) self.flashCopied() } label: { Label(entry.wasAIProcessed ? "Copy AI" : "Copy", systemImage: "doc.on.doc") @@ -600,8 +600,8 @@ struct TranscriptionHistoryView: View { private func moveSelection(_ direction: MoveCommandDirection, scrollProxy: ScrollViewProxy) { let entries = self.filteredEntries - let currentIndex = self.selectedEntry.flatMap { selected in - entries.firstIndex(where: { $0.id == selected.id }) + let currentIndex = self.selectedEntryID.flatMap { id in + entries.firstIndex(where: { $0.id == id }) } let moveUp: Bool switch direction { From 4074066709fae679ef04b99e37c1608d75e62926 Mon Sep 17 00:00:00 2001 From: Fe2 O3 <33189015+Fe2-O3@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:03:55 -0700 Subject: [PATCH 12/12] feat(history): inline Copied state on the copy buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback, the detail-pane copy buttons (Copy/Copy AI, Raw, Both) now confirm inline — the button briefly becomes a checkmark and 'Copied' — instead of showing the cursor toast, so the feedback lands on the control the user pressed. The cursor toast stays on the paths that have no button to morph: double-click, Cmd-C, and the right-click menu (which dismisses on click). The copied state is scoped to its entry id, so the confirmation cannot leak onto another entry when the selection changes. Co-Authored-By: Claude Opus 4.8 --- .../Fluid/UI/TranscriptionHistoryView.swift | 66 ++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/Sources/Fluid/UI/TranscriptionHistoryView.swift b/Sources/Fluid/UI/TranscriptionHistoryView.swift index 597470bd..97f98652 100644 --- a/Sources/Fluid/UI/TranscriptionHistoryView.swift +++ b/Sources/Fluid/UI/TranscriptionHistoryView.swift @@ -11,8 +11,23 @@ struct TranscriptionHistoryView: View { @State private var showReportConfirmation: Bool = false @State private var selectedReportEntry: TranscriptionHistoryEntry? @State private var selectedEntryID: UUID? + @State private var copiedMark: CopiedMark? + @State private var copiedResetTask: Task? @FocusState private var listFocused: Bool + /// A detail-pane copy button that just copied. Scoped to its entry so the + /// inline confirmation can never leak onto a different entry. + private enum CopiedButton: Equatable { + case primary + case raw + case both + } + + private struct CopiedMark: Equatable { + let entryID: UUID + let button: CopiedButton + } + private var filteredEntries: [TranscriptionHistoryEntry] { self.historyStore.search(query: self.searchQuery) } @@ -361,10 +376,14 @@ struct TranscriptionHistoryView: View { Button { self.copyToClipboard(entry.clipboardText ?? entry.processedText) - self.flashCopied() + self.markButtonCopied(.primary, for: entry) } label: { - Label(entry.wasAIProcessed ? "Copy AI" : "Copy", systemImage: "doc.on.doc") - .font(.system(size: 12, weight: .medium)) + self.copyButtonLabel( + .primary, + for: entry, + title: entry.wasAIProcessed ? "Copy AI" : "Copy", + systemImage: "doc.on.doc" + ) } .buttonStyle(.bordered) .controlSize(.small) @@ -402,20 +421,18 @@ struct TranscriptionHistoryView: View { if entry.wasAIProcessed { Button { self.copyToClipboard(entry.rawText) - self.flashCopied() + self.markButtonCopied(.raw, for: entry) } label: { - Label("Raw", systemImage: "doc.on.doc.fill") - .font(.system(size: 12, weight: .medium)) + self.copyButtonLabel(.raw, for: entry, title: "Raw", systemImage: "doc.on.doc.fill") } .buttonStyle(.bordered) .controlSize(.small) Button { self.copyToClipboard(self.combinedText(for: entry)) - self.flashCopied() + self.markButtonCopied(.both, for: entry) } label: { - Label("Both", systemImage: "doc.on.doc") - .font(.system(size: 12, weight: .medium)) + self.copyButtonLabel(.both, for: entry, title: "Both", systemImage: "doc.on.doc") } .buttonStyle(.bordered) .controlSize(.small) @@ -594,10 +611,41 @@ struct TranscriptionHistoryView: View { NSPasteboard.general.setString(text, forType: .string) } + /// Cursor toast — for the copy paths with no button to morph: double-click, + /// ⌘C, and the right-click menu (which dismisses the moment it's clicked). private func flashCopied() { CursorCopyToast.shared.show() } + /// Inline confirmation for the detail-pane copy buttons: the button itself + /// briefly becomes "Copied", so the feedback lands on the control the user + /// actually pressed. + private func markButtonCopied(_ button: CopiedButton, for entry: TranscriptionHistoryEntry) { + withAnimation(.easeInOut(duration: 0.15)) { + self.copiedMark = CopiedMark(entryID: entry.id, button: button) + } + self.copiedResetTask?.cancel() + self.copiedResetTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + withAnimation(.easeInOut(duration: 0.15)) { + self.copiedMark = nil + } + } + } + + @ViewBuilder + private func copyButtonLabel( + _ button: CopiedButton, + for entry: TranscriptionHistoryEntry, + title: String, + systemImage: String + ) -> some View { + let copied = self.copiedMark == CopiedMark(entryID: entry.id, button: button) + Label(copied ? "Copied" : title, systemImage: copied ? "checkmark" : systemImage) + .font(.system(size: 12, weight: .medium)) + } + private func moveSelection(_ direction: MoveCommandDirection, scrollProxy: ScrollViewProxy) { let entries = self.filteredEntries let currentIndex = self.selectedEntryID.flatMap { id in