From 46489d7ce9f223b939e7a5822975c8ef536f22a5 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Wed, 19 Aug 2026 16:38:55 +0100 Subject: [PATCH] feat: announce a finished transcript in the meeting pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon fired an osascript notification when a recorded session finished transcribing, which lands in Notification Center looking like it came from Script Editor and gives you nowhere to click. Reuse the pill the meeting prompt already draws: same panel, same placement under the menu bar, a Glass chime, and one Open button that reveals transcript.md in Finder. No Dismiss — an announcement has nothing to decline, so it leaves on its own after six seconds. A live prompt is a question and must not be covered by an announcement, so a toast arriving while one is up falls back to the notification; a prompt arriving evicts the toast, since both occupy the same spot. The coordinator learns one injectable handler, mirroring setStatusHandler. One-shot `yap record` sets none and keeps the notification: it has no AppKit run loop, and a failure should persist rather than vanish in six seconds — so the failure branch stays on notifyUser too. --- README.md | 14 ++-- .../TranscriptionCoordinator.swift | 13 ++- Sources/yap/UI/PromptPanel.swift | 84 ++++++++++++++++--- Sources/yap/Yap.swift | 15 ++++ 4 files changed, 106 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 94915db..fc0add8 100644 --- a/README.md +++ b/README.md @@ -111,12 +111,14 @@ returns silence instead, yap notices inside a second and restarts the mic raw. argument. `transcription` is automatic transcription of recordings, on by default. -Dictation ignores it, since the hotkey always transcribes. Turn it off to use -yap as a plain recorder: `on_stop` then fires when the recording stops rather -than after the transcript. Nothing is lost either way. Turn it back on, restart, -and yap works through every session under `recordings_dir` that has no -transcript yet, firing `on_stop` again for each. Anything you put somewhere else -with `yap record --out` is left alone. +Dictation ignores it, since the hotkey always transcribes. When one finishes +while the daemon is running, a banner drops under the menu bar with an Open +button that reveals the transcript in Finder. Turn it off to use yap as a plain +recorder: `on_stop` then fires when the recording stops rather than after the +transcript. Nothing is lost either way. Turn it back on, restart, and yap works +through every session under `recordings_dir` that has no transcript yet, firing +`on_stop` again for each. Anything you put somewhere else with +`yap record --out` is left alone. ## Models diff --git a/Sources/yap/Transcription/TranscriptionCoordinator.swift b/Sources/yap/Transcription/TranscriptionCoordinator.swift index 377ad20..5c4cb0e 100644 --- a/Sources/yap/Transcription/TranscriptionCoordinator.swift +++ b/Sources/yap/Transcription/TranscriptionCoordinator.swift @@ -20,6 +20,7 @@ actor TranscriptionCoordinator { private var transcriber: (any Transcriber)? private let ownsTranscriber: Bool private var statusHandler: (@Sendable (Bool) -> Void)? + private var transcriptReadyHandler: (@Sendable (URL) -> Void)? /// Built once. `ISO8601DateFormatter` is expensive to construct and this /// one is only ever touched from the actor. private let iso = ISO8601DateFormatter() @@ -41,6 +42,12 @@ actor TranscriptionCoordinator { statusHandler = handler } + /// Called with the session directory after transcript.json/.md are written. + /// Unset (the one-shot `yap record` case), a plain notification fires instead. + func setTranscriptReadyHandler(_ handler: @escaping @Sendable (URL) -> Void) { + transcriptReadyHandler = handler + } + /// Queue a finished session. With transcription disabled in config, the /// on_stop hook still fires — it just gets an untranscribed folder. func enqueue(_ sessionDir: URL) { @@ -158,7 +165,11 @@ actor TranscriptionCoordinator { publish(true) do { try await transcribe(dir) - notifyUser(title: "yap — transcript ready", body: dir.lastPathComponent) + if let transcriptReadyHandler { + transcriptReadyHandler(dir) + } else { + notifyUser(title: "yap — transcript ready", body: dir.lastPathComponent) + } runHook(for: dir) } catch { log(dir, "transcription failed: \(error)") diff --git a/Sources/yap/UI/PromptPanel.swift b/Sources/yap/UI/PromptPanel.swift index fa2997e..519a5b0 100644 --- a/Sources/yap/UI/PromptPanel.swift +++ b/Sources/yap/UI/PromptPanel.swift @@ -24,6 +24,19 @@ func retirePrompt() { PromptPanel.retireCurrent() } +/// One-way announcement in the same pill as the meeting prompt: icon, text, +/// a single action button, gone by itself in a few seconds. If a prompt is +/// on screen it falls back to a notification rather than covering it. +@MainActor +func showToast( + title: String, + body: String, + button: String, + onAccept: @escaping @MainActor () -> Void +) { + PromptPanel.presentToast(title: title, body: body, button: button, onAccept: onAccept) +} + /// A borderless capsule panel centred under the menu bar, clear of whatever /// notch widget lives up there. /// @@ -36,9 +49,15 @@ func retirePrompt() { @MainActor final class PromptPanel: NSPanel { private static var current: PromptPanel? + /// Announcements live in the same top-centre spot, but in their own slot: + /// a toast must not be mistaken for a prompt by `retireCurrent()`. + private static var currentToast: PromptPanel? /// Long enough to catch someone settling into a call, short enough that a /// missed prompt doesn't linger for the rest of the meeting. private static let autoDismissAfter: TimeInterval = 120 + /// A toast says something already true and needs no answer, so it only has + /// to survive long enough to be read. + private static let toastDismissAfter: TimeInterval = 6 /// Clearance below the menu bar. Enough to clear a notch widget hanging /// off the menu bar without looking detached from it. private static let topGap: CGFloat = 12 @@ -48,6 +67,7 @@ final class PromptPanel: NSPanel { private let onAccept: @MainActor () -> Void private let onDismiss: @MainActor () -> Void private var autoDismiss: Timer? + private let dismissAfter: TimeInterval static func present( title: String, @@ -56,9 +76,12 @@ final class PromptPanel: NSPanel { onDismiss: @escaping @MainActor () -> Void, onAccept: @escaping @MainActor () -> Void ) { + // Both live top-centre, so a question evicts an announcement. + currentToast?.close() current?.close() let panel = PromptPanel( - heading: title, body: body, button: button, onDismiss: onDismiss, onAccept: onAccept + heading: title, body: body, button: button, + onDismiss: onDismiss, onAccept: onAccept, toast: false ) current = panel panel.appear() @@ -68,15 +91,39 @@ final class PromptPanel: NSPanel { current?.fadeOut() } + static func presentToast( + title: String, + body: String, + button: String, + onAccept: @escaping @MainActor () -> Void + ) { + // A live prompt is a question; never cover it with an announcement. + // The announcement still reaches the user, just as a notification. + guard current == nil else { + notifyUser(title: title, body: body) + return + } + currentToast?.close() + let panel = PromptPanel( + heading: title, body: body, button: button, + onDismiss: {}, onAccept: onAccept, toast: true + ) + currentToast = panel + panel.appear() + NSSound(named: "Glass")?.play() + } + private init( heading: String, body: String, button: String, onDismiss: @escaping @MainActor () -> Void, - onAccept: @escaping @MainActor () -> Void + onAccept: @escaping @MainActor () -> Void, + toast: Bool ) { self.onAccept = onAccept self.onDismiss = onDismiss + self.dismissAfter = toast ? Self.toastDismissAfter : Self.autoDismissAfter super.init( contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), styleMask: [.borderless, .nonactivatingPanel], @@ -96,7 +143,7 @@ final class PromptPanel: NSPanel { isReleasedWhenClosed = false animationBehavior = .none - let content = contentStack(heading: heading, body: body, button: button) + let content = contentStack(heading: heading, body: body, button: button, toast: toast) let background = PillView() background.material = .hudWindow background.blendingMode = .behindWindow @@ -119,7 +166,9 @@ final class PromptPanel: NSPanel { // MARK: - Layout - private func contentStack(heading: String, body: String, button: String) -> NSStackView { + private func contentStack( + heading: String, body: String, button: String, toast: Bool + ) -> NSStackView { let text = NSStackView(views: [ Self.label(heading, font: .systemFont(ofSize: 13, weight: .semibold), color: .labelColor), Self.label(body, font: .systemFont(ofSize: 11), color: .secondaryLabelColor), @@ -128,13 +177,6 @@ final class PromptPanel: NSPanel { text.alignment = .leading text.spacing = 1 - let dismiss = CapsuleButton( - title: "Dismiss", - fill: NSColor.labelColor.withAlphaComponent(0.10), - textColor: .labelColor, - target: self, - action: #selector(dismissClicked) - ) let accept = CapsuleButton( title: button, fill: .controlAccentColor, @@ -143,7 +185,22 @@ final class PromptPanel: NSPanel { action: #selector(acceptClicked) ) - let stack = NSStackView(views: [Self.icon(diameter: 34), text, dismiss, accept]) + // An announcement has nothing to decline: it is already true, and it + // leaves on its own. Only a question gets a Dismiss. + var views: [NSView] = [Self.icon(diameter: 34), text] + if !toast { + views.append( + CapsuleButton( + title: "Dismiss", + fill: NSColor.labelColor.withAlphaComponent(0.10), + textColor: .labelColor, + target: self, + action: #selector(dismissClicked) + )) + } + views.append(accept) + + let stack = NSStackView(views: views) stack.translatesAutoresizingMaskIntoConstraints = false stack.orientation = .horizontal stack.alignment = .centerY @@ -225,7 +282,7 @@ final class PromptPanel: NSPanel { } autoDismiss = Timer.scheduledTimer( - withTimeInterval: Self.autoDismissAfter, + withTimeInterval: dismissAfter, repeats: false ) { [weak self] _ in MainActor.assumeIsolated { self?.fadeOut() } @@ -247,6 +304,7 @@ final class PromptPanel: NSPanel { autoDismiss?.invalidate() autoDismiss = nil if Self.current === self { Self.current = nil } + if Self.currentToast === self { Self.currentToast = nil } super.close() } diff --git a/Sources/yap/Yap.swift b/Sources/yap/Yap.swift index b11e275..1eebcd2 100644 --- a/Sources/yap/Yap.swift +++ b/Sources/yap/Yap.swift @@ -377,6 +377,21 @@ final class Daemon: NSObject, NSApplicationDelegate { await coordinator.setStatusHandler { [weak self] busy in Task { @MainActor in self?.show(busy: busy) } } + await coordinator.setTranscriptReadyHandler { dir in + // Fires on the coordinator's executor; hop to the main actor + // for AppKit. + Task { @MainActor in + showToast( + title: "Transcript ready", + body: dir.lastPathComponent, + button: "Open" + ) { + NSWorkspace.shared.activateFileViewerSelecting( + [dir.appendingPathComponent("transcript.md")] + ) + } + } + } await coordinator.resumePending(root: root) }