From 7d42c792244a19dcbf82020ed96a25204e9ea64d Mon Sep 17 00:00:00 2001 From: r3dbars Date: Mon, 15 Jun 2026 21:47:48 -0500 Subject: [PATCH 1/2] feat(speakers): play/pause toggle, overflow menu, name autocomplete Three UX changes scoped to the Speakers settings screen's "N voices to name" rows (SpeakerVoiceToNameRow): 1. Play/pause toggle with a clear active state. The circular button now renders through SpeakerClipPlaybackPresentation (pause glyph + accent ring while playing) and the whole row gets a subtle accent highlight, so with many clips the playing one is unambiguous. One-clip-at-a-time and finish->reset are already guaranteed by SpeakerClipPlayback. 2. The bare transcript-document icon is replaced by a three-dots overflow menu after Save Name: "Show transcript" (the old reveal action) and a destructive "Delete voice" wired to the real SpeakerDatabase deletion path. Delete is confirmed, verified (getSpeaker == nil) and surfaces an error instead of silently no-opping. 3. The "Who is this?" field becomes a name autocomplete. A new SpeakerNameAutocompleteField wraps NSComboBox and reuses SpeakerNameSelectionPolicy exactly like the post-meeting naming sheet; suggestions come from named profiles via SpeakerNameSuggestionSource. Testable logic is extracted into Foundation-pure helpers (SpeakerVoiceRowPresentation) covered by SpeakerVoiceRowPresentationTests and wired into the fast-test manifest + APP_SOURCES. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SpeakerNameAutocompleteField.swift | 155 ++++++++++++++++++ .../SpeakerPeopleSettingsSection.swift | 149 ++++++++++++++--- .../SpeakerVoiceRowPresentation.swift | 112 +++++++++++++ Tests/FastTests.manifest | 1 + Tests/SpeakerVoiceRowPresentationTests.swift | 122 ++++++++++++++ scripts/entrypoints/run-tests.sh | 1 + 6 files changed, 515 insertions(+), 25 deletions(-) create mode 100644 Sources/UI/Settings/SpeakerNameAutocompleteField.swift create mode 100644 Sources/UI/Settings/SpeakerVoiceRowPresentation.swift create mode 100644 Tests/SpeakerVoiceRowPresentationTests.swift diff --git a/Sources/UI/Settings/SpeakerNameAutocompleteField.swift b/Sources/UI/Settings/SpeakerNameAutocompleteField.swift new file mode 100644 index 00000000..649c071c --- /dev/null +++ b/Sources/UI/Settings/SpeakerNameAutocompleteField.swift @@ -0,0 +1,155 @@ +import SwiftUI +import AppKit +import TranscriptedCore + +/// SwiftUI wrapper around the same `NSComboBox`-based autocomplete the +/// post-meeting speaker naming sheet uses (`SpeakerRowView`'s `nameField`). +/// It reuses `SpeakerNameSelectionPolicy` for label building, suggestion +/// ordering, and inline completion so the Speakers screen's "Who is this?" +/// field matches existing voices exactly the way naming does elsewhere. +struct SpeakerNameAutocompleteField: NSViewRepresentable { + @Binding var text: String + var placeholder: String + var options: [SpeakerIdentityOption] + var accessibilityIdentifier: String? + var onSubmit: () -> Void + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> NSComboBox { + let combo = NSComboBox() + combo.isEditable = true + combo.completes = true + combo.usesDataSource = true + combo.dataSource = context.coordinator + combo.delegate = context.coordinator + combo.font = NSFont.systemFont(ofSize: 13) + combo.placeholderString = placeholder + combo.stringValue = text + if let accessibilityIdentifier { + combo.identifier = NSUserInterfaceItemIdentifier(accessibilityIdentifier) + combo.setAccessibilityIdentifier(accessibilityIdentifier) + } + context.coordinator.rebuild(options: options) + combo.numberOfVisibleItems = Self.visibleItemCount(for: options) + return combo + } + + func updateNSView(_ combo: NSComboBox, context: Context) { + context.coordinator.parent = self + context.coordinator.rebuild(options: options) + if combo.stringValue != text { + combo.stringValue = text + } + combo.placeholderString = placeholder + combo.numberOfVisibleItems = Self.visibleItemCount(for: options) + } + + private static func visibleItemCount(for options: [SpeakerIdentityOption]) -> Int { + min(max(options.count, 4), 8) + } + + final class Coordinator: NSObject, NSComboBoxDataSource, NSComboBoxDelegate { + var parent: SpeakerNameAutocompleteField + private var labels: [String] = [] + private var optionsByLabel: [String: SpeakerIdentityOption] = [:] + + init(_ parent: SpeakerNameAutocompleteField) { + self.parent = parent + } + + func rebuild(options: [SpeakerIdentityOption]) { + let built = SpeakerNameSelectionPolicy.makeIdentityLabels( + for: options, + id: { $0.id }, + displayName: { $0.displayName }, + callCount: { $0.callCount } + ) + labels = built.labels + optionsByLabel = built.lookup + } + + private func visibleLabels(for query: String) -> [String] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return labels } + return SpeakerNameSelectionPolicy.sortedLabels( + matching: trimmed, + labels: labels, + optionsByLabel: optionsByLabel, + displayName: { $0.displayName }, + callCount: { $0.callCount } + ) + } + + // MARK: NSComboBoxDataSource + + func numberOfItems(in comboBox: NSComboBox) -> Int { + visibleLabels(for: comboBox.stringValue).count + } + + func comboBox(_ comboBox: NSComboBox, objectValueForItemAt index: Int) -> Any? { + let visible = visibleLabels(for: comboBox.stringValue) + guard visible.indices.contains(index) else { return nil } + return visible[index] + } + + func comboBox(_ comboBox: NSComboBox, completedString string: String) -> String? { + SpeakerNameSelectionPolicy.completedLabel( + for: string, + labels: labels, + optionsByLabel: optionsByLabel, + displayName: { $0.displayName }, + callCount: { $0.callCount } + ) + } + + func comboBox(_ comboBox: NSComboBox, indexOfItemWithStringValue string: String) -> Int { + visibleLabels(for: comboBox.stringValue).firstIndex(of: string) ?? NSNotFound + } + + // MARK: NSComboBoxDelegate + + func controlTextDidChange(_ obj: Notification) { + guard let combo = obj.object as? NSComboBox else { return } + parent.text = combo.stringValue + combo.reloadData() + } + + func comboBoxSelectionDidChange(_ notification: Notification) { + guard let combo = notification.object as? NSComboBox else { return } + // Resolve on the next runloop tick: at notification time the combo's + // selected index is set but `objectValueOfSelectedItem` is not yet + // committed to the field. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let index = combo.indexOfSelectedItem + let visible = self.visibleLabels(for: combo.stringValue) + guard visible.indices.contains(index) else { return } + let label = visible[index] + // Map the (possibly decorated) dropdown label back to the plain + // display name, matching what the naming sheet stores. + let resolved = SpeakerNameSelectionPolicy.option( + matching: label, + optionsByLabel: self.optionsByLabel, + displayName: { $0.displayName } + )?.displayName ?? label + combo.stringValue = resolved + self.parent.text = resolved + } + } + + func control( + _ control: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + guard let combo = control as? NSComboBox else { return false } + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + parent.text = combo.stringValue + parent.onSubmit() + return true + } + return false + } + } +} diff --git a/Sources/UI/Settings/SpeakerPeopleSettingsSection.swift b/Sources/UI/Settings/SpeakerPeopleSettingsSection.swift index 87cf2e7b..1d4ada3a 100644 --- a/Sources/UI/Settings/SpeakerPeopleSettingsSection.swift +++ b/Sources/UI/Settings/SpeakerPeopleSettingsSection.swift @@ -289,7 +289,14 @@ final class SpeakerPeopleSettingsViewModel: ObservableObject { } } - func delete(profile: SpeakerProfile) { + /// Deletes the voice behind a pending review group. Thin wrapper over + /// `delete(profile:)` so the "voices to name" overflow menu shares the same + /// real deletion path as the all-speakers list. + func deleteVoice(_ group: SpeakerPendingVoiceGroup, completion: ((Bool) -> Void)? = nil) { + delete(profile: group.representative.profile, completion: completion) + } + + func delete(profile: SpeakerProfile, completion: ((Bool) -> Void)? = nil) { let profileId = profile.id let speakerDatabase = self.speakerDatabase let preferredClipsDirectory = self.preferredClipsDirectory @@ -302,6 +309,9 @@ final class SpeakerPeopleSettingsViewModel: ObservableObject { preferredClipsDirectory: preferredClipsDirectory, legacyClipsDirectory: legacyClipsDirectory ) + // Confirm the row is actually gone before reporting success so a + // failed delete surfaces an error instead of silently no-opping. + let didDelete = speakerDatabase.getSpeaker(id: profileId) == nil let snapshot = Self.snapshot( from: speakerDatabase, preferredClipsDirectory: preferredClipsDirectory, @@ -309,6 +319,7 @@ final class SpeakerPeopleSettingsViewModel: ObservableObject { ) DispatchQueue.main.async { self?.applySnapshot(snapshot) + completion?(didDelete) } } } @@ -733,13 +744,24 @@ private struct SpeakerVoiceToNameRow: View { @State private var nameDraft = "" @State private var isSaving = false @State private var saveErrorMessage: String? + @State private var showDeleteConfirmation = false + @State private var isDeleting = false + @State private var deleteErrorMessage: String? + + private var isPlaying: Bool { + model.isPlayingSample(for: group.representative) + } + + private var hasClip: Bool { + group.representative.clipURL != nil + } var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top, spacing: 12) { SpeakerPlayClipButton( - hasClip: group.representative.clipURL != nil, - isPlaying: model.isPlayingSample(for: group.representative), + hasClip: hasClip, + isPlaying: isPlaying, action: { model.playSample(for: group.representative) } ) @@ -776,8 +798,25 @@ private struct SpeakerVoiceToNameRow: View { .foregroundStyle(.red) .fixedSize(horizontal: false, vertical: true) } + + if let deleteErrorMessage { + Text(deleteErrorMessage) + .font(.caption) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } } .padding(.vertical, 4) + .padding(.horizontal, isActiveHighlight ? 8 : 0) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.accentColor.opacity(isActiveHighlight ? 0.08 : 0)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.accentColor.opacity(isActiveHighlight ? 0.35 : 0), lineWidth: 1) + ) + .animation(.easeInOut(duration: 0.15), value: isActiveHighlight) .onAppear { if nameDraft.isEmpty { nameDraft = group.representative.profile.displayName ?? "" @@ -786,13 +825,39 @@ private struct SpeakerVoiceToNameRow: View { .onChange(of: nameDraft) { _, _ in saveErrorMessage = nil } + .alert( + SpeakerVoiceRowMenuPolicy.deleteConfirmationTitle, + isPresented: $showDeleteConfirmation + ) { + Button("Delete", role: .destructive) { + deleteVoice() + } + Button("Cancel", role: .cancel) {} + } message: { + Text(SpeakerVoiceRowMenuPolicy.deleteConfirmationMessage) + } + } + + private var isActiveHighlight: Bool { + SpeakerClipPlaybackPresentation.isActiveHighlight(hasClip: hasClip, isPlaying: isPlaying) + } + + private var nameSuggestions: [SpeakerIdentityOption] { + SpeakerNameSuggestionSource.options( + from: model.profiles, + excluding: group.representative.speakerId + ) } private var nameField: some View { - TextField("Who is this?", text: $nameDraft) - .textFieldStyle(.roundedBorder) - .frame(minWidth: 200) - .onSubmit(saveName) + SpeakerNameAutocompleteField( + text: $nameDraft, + placeholder: "Who is this?", + options: nameSuggestions, + accessibilityIdentifier: "transcripted.speakers.voice-to-name.name", + onSubmit: saveName + ) + .frame(minWidth: 200) } private var actionButtons: some View { @@ -802,21 +867,38 @@ private struct SpeakerVoiceToNameRow: View { } .disabled(!canSave || isSaving) - Button { + overflowMenu + } + } + + private var overflowMenu: some View { + Menu { + Button(SpeakerVoiceRowMenuAction.showTranscript.title) { model.openTranscript(for: group.representative) - } label: { - Image(systemName: "doc.text") - .font(.system(size: 12, weight: .semibold)) - .padding(7) } - .buttonStyle(SettingsHoverButtonStyle( - cornerRadius: 8, - normalFill: Color.primary.opacity(0.025), - normalStroke: Color.primary.opacity(0.06) - )) - .help("Open the meeting this voice was heard in") - .accessibilityLabel("Open meeting transcript") + + Divider() + + Button(SpeakerVoiceRowMenuAction.deleteVoice.title, role: .destructive) { + showDeleteConfirmation = true + } + .disabled(isDeleting) + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(8) } + .buttonStyle(SettingsHoverButtonStyle( + cornerRadius: 8, + normalFill: Color.primary.opacity(0.025), + normalStroke: Color.primary.opacity(0.06) + )) + .menuIndicator(.hidden) + .fixedSize() + .help("Show transcript or delete this voice") + .accessibilityLabel("Voice actions") + .accessibilityIdentifier("transcripted.speakers.voice-to-name.menu") } private var quoteLine: String { @@ -858,6 +940,19 @@ private struct SpeakerVoiceToNameRow: View { } } + private func deleteVoice() { + guard !isDeleting else { return } + isDeleting = true + deleteErrorMessage = nil + model.deleteVoice(group) { didDelete in + isDeleting = false + // On success the row's voice group drops out of the refreshed + // snapshot, so this view disappears. Only a failed delete keeps the + // row around to show the surfaced error. + deleteErrorMessage = SpeakerVoiceRowMenuPolicy.deleteErrorMessage(didDelete: didDelete) + } + } + private static let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .medium @@ -873,21 +968,25 @@ private struct SpeakerPlayClipButton: View { var body: some View { Button(action: action) { - Image(systemName: isPlaying ? "pause.fill" : "play.fill") + Image(systemName: SpeakerClipPlaybackPresentation.symbolName(isPlaying: isPlaying)) .font(.system(size: 13, weight: .bold)) .foregroundStyle(hasClip ? Color.white : Color.secondary) .frame(width: 36, height: 36) .background(Circle().fill(hasClip ? Color.accentColor : Color.primary.opacity(0.06))) + .overlay( + Circle() + .stroke(Color.accentColor.opacity(isActive ? 0.9 : 0), lineWidth: 2) + .padding(-2) + ) } .buttonStyle(.plain) .disabled(!hasClip) - .help(helpText) - .accessibilityLabel(isPlaying ? "Pause voice sample" : "Play voice sample") + .help(SpeakerClipPlaybackPresentation.helpText(hasClip: hasClip, isPlaying: isPlaying)) + .accessibilityLabel(SpeakerClipPlaybackPresentation.accessibilityLabel(isPlaying: isPlaying)) } - private var helpText: String { - guard hasClip else { return "No voice clip was saved for this speaker" } - return isPlaying ? "Pause this voice sample" : "Play a short clip of this voice" + private var isActive: Bool { + SpeakerClipPlaybackPresentation.isActiveHighlight(hasClip: hasClip, isPlaying: isPlaying) } } diff --git a/Sources/UI/Settings/SpeakerVoiceRowPresentation.swift b/Sources/UI/Settings/SpeakerVoiceRowPresentation.swift new file mode 100644 index 00000000..9d61ec9a --- /dev/null +++ b/Sources/UI/Settings/SpeakerVoiceRowPresentation.swift @@ -0,0 +1,112 @@ +import Foundation +#if canImport(TranscriptedCore) +import TranscriptedCore +#endif + +// Foundation-pure presentation + policy helpers for the "voices to name" rows on +// the Speakers settings surface. Kept view-free so the play/pause state machine, +// the overflow-menu actions, and the name autocomplete data source can be unit +// tested without instantiating SwiftUI/AppKit views. + +// MARK: - Play / pause button presentation + +/// Drives the circular play/pause toggle on a voice row. The toggle itself is a +/// thin renderer over `SpeakerClipPlayback`, which already guarantees only one +/// clip plays at a time; this helper only decides what the button should look +/// and read like for a given (hasClip, isPlaying) pair. +enum SpeakerClipPlaybackPresentation { + static let playSymbol = "play.fill" + static let pauseSymbol = "pause.fill" + + /// SF Symbol name for the toggle: a pause glyph while this row's clip plays, + /// otherwise the play glyph. + static func symbolName(isPlaying: Bool) -> String { + isPlaying ? pauseSymbol : playSymbol + } + + /// Whether the row should render its "currently playing" highlight. Only a + /// row that actually has a clip *and* is playing lights up, so with many + /// rows on screen the active one stays unambiguous. + static func isActiveHighlight(hasClip: Bool, isPlaying: Bool) -> Bool { + hasClip && isPlaying + } + + static func accessibilityLabel(isPlaying: Bool) -> String { + isPlaying ? "Pause voice sample" : "Play voice sample" + } + + static func helpText(hasClip: Bool, isPlaying: Bool) -> String { + guard hasClip else { return "No voice clip was saved for this speaker" } + return isPlaying ? "Pause this voice sample" : "Play a short clip of this voice" + } +} + +// MARK: - Overflow (three-dots) menu + +/// Actions exposed by the three-dots overflow menu on a voice row. Replaces the +/// former bare transcript-document icon. +enum SpeakerVoiceRowMenuAction: String, CaseIterable { + case showTranscript + case deleteVoice + + var title: String { + switch self { + case .showTranscript: return "Show transcript" + case .deleteVoice: return "Delete voice" + } + } + + /// Whether this action removes data and should be presented destructively + /// (and behind a confirmation prompt). + var isDestructive: Bool { + switch self { + case .showTranscript: return false + case .deleteVoice: return true + } + } +} + +enum SpeakerVoiceRowMenuPolicy { + /// Menu items in display order. "Show transcript" first (the safe, + /// previously icon-only action), then the destructive "Delete voice". + static let actions: [SpeakerVoiceRowMenuAction] = SpeakerVoiceRowMenuAction.allCases + + /// Error copy to surface when a delete request did not actually remove the + /// speaker. Returns `nil` on success so callers never show a false error, + /// and never let a failed delete silently no-op. + static func deleteErrorMessage(didDelete: Bool) -> String? { + didDelete ? nil : "Couldn't delete this voice. The speaker may still be saved — try again." + } + + static let deleteConfirmationTitle = "Delete this voice?" + static let deleteConfirmationMessage = + "This removes the saved voice profile and sample clip. Past transcripts stay unchanged." +} + +// MARK: - Name autocomplete data source + +/// Builds the suggestion list that feeds the "Who is this?" autocomplete. Reuses +/// the same `SpeakerIdentityOption` shape that the post-meeting naming sheet +/// passes into `SpeakerNameSelectionPolicy`, so the two surfaces match new +/// speakers the same way. +enum SpeakerNameSuggestionSource { + /// Returns one identity option per *named* profile, excluding the voice + /// currently being named (a voice should never suggest itself). Profiles + /// with a blank/whitespace-only display name are skipped because they offer + /// nothing to autocomplete to. + static func options( + from profiles: [SpeakerProfile], + excluding excludedId: UUID? + ) -> [SpeakerIdentityOption] { + profiles.compactMap { profile in + guard profile.id != excludedId else { return nil } + let name = profile.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !name.isEmpty else { return nil } + return SpeakerIdentityOption( + id: profile.id, + displayName: name, + callCount: profile.callCount + ) + } + } +} diff --git a/Tests/FastTests.manifest b/Tests/FastTests.manifest index 7779b246..c79eda53 100644 --- a/Tests/FastTests.manifest +++ b/Tests/FastTests.manifest @@ -126,6 +126,7 @@ LiveTranscriptPlainTextRendererTests.swift:testLiveTranscriptPlainTextRenderer MeetingStartFailureClassifierTests.swift:testMeetingStartFailureClassifier HomePresentationTests.swift:testHomePresentation HomeRootAlertPolicyTests.swift:testHomeRootAlertPolicy +SpeakerVoiceRowPresentationTests.swift:testSpeakerVoiceRowPresentation STTRouterPolicyTests.swift:testSTTRouterPolicy ContextCaptureEnginePolicyTests.swift:testContextCaptureEnginePolicy JSONLWriterTests.swift:testJSONLWriter diff --git a/Tests/SpeakerVoiceRowPresentationTests.swift b/Tests/SpeakerVoiceRowPresentationTests.swift new file mode 100644 index 00000000..8b4c6975 --- /dev/null +++ b/Tests/SpeakerVoiceRowPresentationTests.swift @@ -0,0 +1,122 @@ +import Foundation + +func testSpeakerVoiceRowPresentation() { + runSuite("Play/pause toggle shows a pause glyph only while a clip plays") { + assertEqual( + SpeakerClipPlaybackPresentation.symbolName(isPlaying: false), + "play.fill", + "idle rows should show the play glyph" + ) + assertEqual( + SpeakerClipPlaybackPresentation.symbolName(isPlaying: true), + "pause.fill", + "a playing row should flip to the pause glyph" + ) + } + + runSuite("Only a row that is actually playing a clip lights up as active") { + assertTrue( + SpeakerClipPlaybackPresentation.isActiveHighlight(hasClip: true, isPlaying: true), + "a clip that is playing should highlight" + ) + assertFalse( + SpeakerClipPlaybackPresentation.isActiveHighlight(hasClip: true, isPlaying: false), + "a clip that is not playing should not highlight" + ) + assertFalse( + SpeakerClipPlaybackPresentation.isActiveHighlight(hasClip: false, isPlaying: true), + "a row with no clip can never be the active playing row" + ) + } + + runSuite("Play/pause accessibility + help copy track playing state") { + assertEqual(SpeakerClipPlaybackPresentation.accessibilityLabel(isPlaying: false), "Play voice sample") + assertEqual(SpeakerClipPlaybackPresentation.accessibilityLabel(isPlaying: true), "Pause voice sample") + assertEqual( + SpeakerClipPlaybackPresentation.helpText(hasClip: false, isPlaying: false), + "No voice clip was saved for this speaker", + "a clipless row should explain why playback is unavailable" + ) + assertEqual(SpeakerClipPlaybackPresentation.helpText(hasClip: true, isPlaying: true), "Pause this voice sample") + assertEqual(SpeakerClipPlaybackPresentation.helpText(hasClip: true, isPlaying: false), "Play a short clip of this voice") + } + + runSuite("Overflow menu exposes Show transcript then a destructive Delete voice") { + let actions = SpeakerVoiceRowMenuPolicy.actions + assertEqual(actions.count, 2, "the menu should expose exactly two actions") + assertEqual(actions.first, .showTranscript, "Show transcript should come first") + assertEqual(actions.last, .deleteVoice, "Delete voice should come last") + assertEqual(SpeakerVoiceRowMenuAction.showTranscript.title, "Show transcript") + assertEqual(SpeakerVoiceRowMenuAction.deleteVoice.title, "Delete voice") + assertFalse(SpeakerVoiceRowMenuAction.showTranscript.isDestructive, "showing a transcript is non-destructive") + assertTrue(SpeakerVoiceRowMenuAction.deleteVoice.isDestructive, "deleting a voice is destructive") + } + + runSuite("Delete never silently no-ops: a failed delete yields a surfaced error") { + assertNil( + SpeakerVoiceRowMenuPolicy.deleteErrorMessage(didDelete: true), + "a confirmed delete should produce no error copy" + ) + let failure = SpeakerVoiceRowMenuPolicy.deleteErrorMessage(didDelete: false) + assertNotNil(failure, "a delete that did not remove the speaker must surface an error") + assertFalse(failure?.isEmpty ?? true, "the surfaced error must not be empty") + } + + runSuite("Name autocomplete suggests only named profiles and never the voice itself") { + let current = UUID() + let other = UUID() + let profiles = [ + makeVoiceRowProfile(id: current, name: "Sasha Kim", calls: 6), + makeVoiceRowProfile(id: other, name: "Devon Park", calls: 3), + makeVoiceRowProfile(id: UUID(), name: nil, calls: 2), + makeVoiceRowProfile(id: UUID(), name: " ", calls: 1), + ] + + let options = SpeakerNameSuggestionSource.options(from: profiles, excluding: current) + assertEqual(options.count, 1, "the unnamed, the blank, and the voice itself should all be excluded") + assertEqual(options.first?.id, other, "only the other named profile should be suggested") + assertEqual(options.first?.displayName, "Devon Park") + assertEqual(options.first?.callCount, 3, "call count should carry through for ranking") + } + + runSuite("Name autocomplete reuses SpeakerNameSelectionPolicy for inline completion") { + let profiles = [ + makeVoiceRowProfile(id: UUID(), name: "Taylor Wolfe", calls: 9), + makeVoiceRowProfile(id: UUID(), name: "Matt Bentley", calls: 4), + ] + let options = SpeakerNameSuggestionSource.options(from: profiles, excluding: nil) + let labels = SpeakerNameSelectionPolicy.makeIdentityLabels( + for: options, + id: { $0.id }, + displayName: { $0.displayName }, + callCount: { $0.callCount } + ) + + let completion = SpeakerNameSelectionPolicy.completedLabel( + for: "tay", + labels: labels.labels, + optionsByLabel: labels.lookup, + displayName: { $0.displayName }, + callCount: { $0.callCount } + ) + assertEqual( + completion, + "Taylor Wolfe", + "the Speakers field should auto-complete a unique prefix exactly like the naming sheet" + ) + } +} + +private func makeVoiceRowProfile(id: UUID, name: String?, calls: Int) -> SpeakerProfile { + SpeakerProfile( + id: id, + displayName: name, + nameSource: name == nil ? nil : NameSource.userManual, + embedding: [], + firstSeen: Date(timeIntervalSince1970: 0), + lastSeen: Date(timeIntervalSince1970: 0), + callCount: calls, + confidence: 1.0, + disputeCount: 0 + ) +} diff --git a/scripts/entrypoints/run-tests.sh b/scripts/entrypoints/run-tests.sh index 06ba0f86..20110332 100755 --- a/scripts/entrypoints/run-tests.sh +++ b/scripts/entrypoints/run-tests.sh @@ -366,6 +366,7 @@ APP_SOURCES=( "Sources/UI/Shared/HomeMeetingRowActionTargets.swift" "Sources/UI/Shared/HomeMeetingRename.swift" "Sources/UI/Settings/HomeMeetingSummaryBetaPresentationPolicy.swift" + "Sources/UI/Settings/SpeakerVoiceRowPresentation.swift" "Sources/UI/Settings/HomeFailedMeetingInlinePresentation.swift" "Sources/UI/Settings/HomeMeetingPreviewFormatter.swift" "Sources/UI/Overlay/DictationMeterPolicy.swift" From 69833f0f8d69a9925c0e1be64ba82fecb7204003 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Tue, 16 Jun 2026 05:56:47 -0500 Subject: [PATCH 2/2] fix(speakers): propagate non-first autocomplete selection to binding Codex peer-review P2: comboBoxSelectionDidChange re-filtered by the already-committed stringValue inside the async block, so the selected index could fall outside the shrunken visible list and picking a 2nd-or-later dropdown suggestion never reached the SwiftUI binding (Save stayed on the old prefix). Resolve the picked label synchronously while the typed query still matches the displayed list; defer only the field assignment so it lands after the combo commits its own value. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SpeakerNameAutocompleteField.swift | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/Sources/UI/Settings/SpeakerNameAutocompleteField.swift b/Sources/UI/Settings/SpeakerNameAutocompleteField.swift index 649c071c..2f3baae0 100644 --- a/Sources/UI/Settings/SpeakerNameAutocompleteField.swift +++ b/Sources/UI/Settings/SpeakerNameAutocompleteField.swift @@ -117,24 +117,28 @@ struct SpeakerNameAutocompleteField: NSViewRepresentable { func comboBoxSelectionDidChange(_ notification: Notification) { guard let combo = notification.object as? NSComboBox else { return } - // Resolve on the next runloop tick: at notification time the combo's - // selected index is set but `objectValueOfSelectedItem` is not yet - // committed to the field. + // Resolve the picked label *now*, while `stringValue` still holds the + // typed query and the visible list matches what the user clicked. The + // selected index desyncs if we re-filter after the field commits the + // (decorated) label, so capturing here is what makes picking a + // non-first suggestion work. + let index = combo.indexOfSelectedItem + let visible = visibleLabels(for: combo.stringValue) + guard visible.indices.contains(index) else { return } + let label = visible[index] + // Map the (possibly decorated) dropdown label back to the plain + // display name, matching what the naming sheet stores. + let resolved = SpeakerNameSelectionPolicy.option( + matching: label, + optionsByLabel: optionsByLabel, + displayName: { $0.displayName } + )?.displayName ?? label + // Assign on the next tick so this lands *after* the combo commits its + // own selected value, overriding the decorated label with the plain + // name instead of being clobbered by it. DispatchQueue.main.async { [weak self] in - guard let self else { return } - let index = combo.indexOfSelectedItem - let visible = self.visibleLabels(for: combo.stringValue) - guard visible.indices.contains(index) else { return } - let label = visible[index] - // Map the (possibly decorated) dropdown label back to the plain - // display name, matching what the naming sheet stores. - let resolved = SpeakerNameSelectionPolicy.option( - matching: label, - optionsByLabel: self.optionsByLabel, - displayName: { $0.displayName } - )?.displayName ?? label combo.stringValue = resolved - self.parent.text = resolved + self?.parent.text = resolved } }