Bladeren bron

Add local meeting audio recordings in AI Companion and fix window width

- Wire MeetingRecordingManager into the app (menu bar, join/start flows,
  notifications) and add the new manager source file to the target.
- Replace AI Companion primary UI with recorded meetings list, playback,
  Show folder, and Stop & save controls.
- Stop embedding the sandbox path in a single-line status field; wrap the
  status label and lower compression resistance so intrinsic width no
  longer widens the main window.
- Update entitlements and AppDelegate as required for recording support.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 maanden geleden
bovenliggende
commit
aab29f035a

+ 4 - 0
zoom_app.xcodeproj/project.pbxproj

@@ -260,7 +260,9 @@
 				INFOPLIST_KEY_CFBundleName = "Zoom App";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
+				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Zoom App records microphone audio during meetings so you can replay them in Ai Companion.";
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
+				INFOPLIST_KEY_NSScreenCaptureUsageDescription = "Zoom App captures system audio during meetings (when allowed) so recordings include computer sound in Ai Companion.";
 				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
@@ -296,7 +298,9 @@
 				INFOPLIST_KEY_CFBundleName = "Zoom App";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
+				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Zoom App records microphone audio during meetings so you can replay them in Ai Companion.";
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
+				INFOPLIST_KEY_NSScreenCaptureUsageDescription = "Zoom App captures system audio during meetings (when allowed) so recordings include computer sound in Ai Companion.";
 				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",

+ 17 - 0
zoom_app/AppDelegate.swift

@@ -27,6 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
         case newMeeting = 20
         case joinMeeting = 21
         case scheduleMeeting = 22
+        case stopRecording = 23
         case auth = 30
         case quit = 99
     }
@@ -186,6 +187,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
         appendMenuItem("New Meeting", tag: .newMeeting, to: menu)
         appendMenuItem("Join Meeting", tag: .joinMeeting, to: menu)
         appendMenuItem("Schedule Meeting", tag: .scheduleMeeting, to: menu)
+        if MeetingRecordingManager.shared.isRecording {
+            menu.addItem(.separator())
+            appendMenuItem("Stop Recording", tag: .stopRecording, to: menu)
+        }
         menu.addItem(.separator())
 
         let authTitle = isUserLoggedInForMenu() ? "Sign Out" : "Sign In"
@@ -230,6 +235,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
             controller.menuBarTriggerJoinMeeting()
         case .scheduleMeeting:
             controller.menuBarTriggerScheduleMeeting()
+        case .stopRecording:
+            Task { @MainActor in
+                do {
+                    try await MeetingRecordingManager.shared.stopRecording()
+                } catch {
+                    let alert = NSAlert()
+                    alert.messageText = "Could not save recording"
+                    alert.informativeText = error.localizedDescription
+                    alert.alertStyle = .warning
+                    alert.runModal()
+                }
+            }
         case .auth:
             controller.menuBarTriggerAuthAction()
         case .quit:

+ 404 - 0
zoom_app/MeetingRecordingManager.swift

@@ -0,0 +1,404 @@
+import AppKit
+import Foundation
+import AVFoundation
+import ScreenCaptureKit
+import CoreMedia
+
+final class MeetingRecordingManager: NSObject {
+    static let shared = MeetingRecordingManager()
+
+    struct RecordedMeeting: Codable, Equatable, Identifiable {
+        let id: String
+        let startedAt: Date
+        let endedAt: Date
+        let sourceURLString: String?
+        let audioFilename: String
+
+        var audioFilenameURLSafe: String { audioFilename }
+    }
+
+    enum RecordingError: Error, LocalizedError {
+        case alreadyRecording
+        case notRecording
+        case microphonePermissionDenied
+        case noShareableContent
+        case systemAudioUnavailable(String)
+        case exportFailed
+        case micCaptureFailed(String)
+
+        var errorDescription: String? {
+            switch self {
+            case .alreadyRecording:
+                return "A recording is already in progress. Stop it first, then start again."
+            case .notRecording:
+                return "Nothing is being recorded right now."
+            case .microphonePermissionDenied:
+                return "Microphone access was denied. Turn it on in System Settings → Privacy & Security → Microphone for this app."
+            case .noShareableContent:
+                return "Could not access a display for system audio capture."
+            case .systemAudioUnavailable(let reason):
+                return "System audio capture failed (\(reason)). Microphone-only recording may still work."
+            case .exportFailed:
+                return "Could not save the recording file. Check free disk space and try again."
+            case .micCaptureFailed(let reason):
+                return "Microphone recording failed: \(reason)"
+            }
+        }
+    }
+
+    static let recordingsDidChangeNotification = Notification.Name("MeetingRecordingManager.recordingsDidChange")
+    static let recordingStateDidChangeNotification = Notification.Name("MeetingRecordingManager.recordingStateDidChange")
+
+    private let encoder: JSONEncoder = {
+        let e = JSONEncoder()
+        e.outputFormatting = [.prettyPrinted, .sortedKeys]
+        e.dateEncodingStrategy = .iso8601
+        return e
+    }()
+
+    private let decoder: JSONDecoder = {
+        let d = JSONDecoder()
+        d.dateDecodingStrategy = .iso8601
+        return d
+    }()
+
+    private(set) var recordings: [RecordedMeeting] = []
+    private(set) var isRecording: Bool = false
+
+    private var recordingID: String?
+    private var recordingStartedAt: Date?
+    private var recordingSourceURLString: String?
+
+    // Mic capture
+    private let micEngine = AVAudioEngine()
+    private var micFile: AVAudioFile?
+
+    // System audio capture (ScreenCaptureKit)
+    // Stored as `AnyObject` so we can compile with lower deployment targets.
+    private var scStream: AnyObject?
+    private var scAudioOutput: AnyObject?
+    fileprivate var systemAudioFile: AVAudioFile?
+    fileprivate var systemAudioFormat: AVAudioFormat?
+
+    // Output
+    private var outputAudioURL: URL?
+
+    private override init() {
+        super.init()
+        loadRecordingsFromDisk()
+    }
+
+    // MARK: - Public API
+
+    func recordingsDirectoryURL() throws -> URL {
+        let fm = FileManager.default
+        let appSupport = try fm.url(
+            for: .applicationSupportDirectory,
+            in: .userDomainMask,
+            appropriateFor: nil,
+            create: true
+        )
+        // Sandboxed apps: `appSupport` is inside the app container (Finder: ~/Library/Containers/…/Data/Library/Application Support/).
+        // Use bundle ID so the folder matches this app (not a generic name).
+        let bundleFolder = Bundle.main.bundleIdentifier ?? "zoom_app"
+        let dir = appSupport.appendingPathComponent(bundleFolder, isDirectory: true)
+            .appendingPathComponent("Recordings", isDirectory: true)
+        if fm.fileExists(atPath: dir.path) == false {
+            try fm.createDirectory(at: dir, withIntermediateDirectories: true)
+        }
+        return dir
+    }
+
+    func audioURL(for recording: RecordedMeeting) throws -> URL {
+        try recordingsDirectoryURL().appendingPathComponent(recording.audioFilename)
+    }
+
+    /// Full path to the Recordings folder (sandboxed apps: inside `~/Library/Containers/…/Data/Library/Application Support/`).
+    var recordingsDirectoryPath: String {
+        (try? recordingsDirectoryURL().path) ?? ""
+    }
+
+    func revealRecordingsInFinder() {
+        guard let url = try? recordingsDirectoryURL() else { return }
+        NSWorkspace.shared.open(url)
+    }
+
+    func startRecording(sourceURL: URL?) async throws {
+        if isRecording { throw RecordingError.alreadyRecording }
+
+        let micGranted = await AVCaptureDevice.requestAccess(for: .audio)
+        if micGranted == false { throw RecordingError.microphonePermissionDenied }
+
+        let id = UUID().uuidString
+        let startedAt = Date()
+        let dir = try recordingsDirectoryURL()
+
+        let micURL = dir.appendingPathComponent("raw_mic_\(id).caf")
+        let sysURL = dir.appendingPathComponent("raw_sys_\(id).caf")
+        let outURL = dir.appendingPathComponent("meeting_\(id).m4a")
+
+        recordingID = id
+        recordingStartedAt = startedAt
+        recordingSourceURLString = sourceURL?.absoluteString
+        outputAudioURL = outURL
+
+        try startMicCapture(to: micURL)
+        if #available(macOS 13.0, *) {
+            do {
+                try await startSystemAudioCapture(to: sysURL)
+            } catch {
+                // Best-effort: keep mic recording even if system audio fails.
+                systemAudioFile = nil
+                systemAudioFormat = nil
+            }
+        } else {
+            systemAudioFile = nil
+            systemAudioFormat = nil
+        }
+
+        isRecording = true
+        NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self)
+    }
+
+    func stopRecording() async throws {
+        guard isRecording else { throw RecordingError.notRecording }
+        guard let id = recordingID, let startedAt = recordingStartedAt, let outURL = outputAudioURL else {
+            throw RecordingError.exportFailed
+        }
+
+        let endedAt = Date()
+        let dir = try recordingsDirectoryURL()
+        let micURL = dir.appendingPathComponent("raw_mic_\(id).caf")
+        let sysURL = dir.appendingPathComponent("raw_sys_\(id).caf")
+
+        stopMicCapture()
+        await stopSystemAudioCapture()
+
+        // Mix mic + system (if available) into a single m4a.
+        let mixedURL = try await mixAndExport(micURL: micURL, systemURL: sysURL, outputURL: outURL)
+
+        // Cleanup raw files (ignore failures).
+        try? FileManager.default.removeItem(at: micURL)
+        try? FileManager.default.removeItem(at: sysURL)
+
+        let rec = RecordedMeeting(
+            id: id,
+            startedAt: startedAt,
+            endedAt: endedAt,
+            sourceURLString: recordingSourceURLString,
+            audioFilename: mixedURL.lastPathComponent
+        )
+        recordings.insert(rec, at: 0)
+        saveRecordingsToDisk()
+
+        recordingID = nil
+        recordingStartedAt = nil
+        recordingSourceURLString = nil
+        outputAudioURL = nil
+
+        isRecording = false
+        NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self)
+        NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
+    }
+
+    // MARK: - Persistence
+
+    private func indexURL() throws -> URL {
+        try recordingsDirectoryURL().appendingPathComponent("index.json")
+    }
+
+    private func loadRecordingsFromDisk() {
+        do {
+            let url = try indexURL()
+            guard FileManager.default.fileExists(atPath: url.path) else { return }
+            let data = try Data(contentsOf: url)
+            recordings = try decoder.decode([RecordedMeeting].self, from: data)
+        } catch {
+            recordings = []
+        }
+    }
+
+    private func saveRecordingsToDisk() {
+        do {
+            let url = try indexURL()
+            let data = try encoder.encode(recordings)
+            try data.write(to: url, options: [.atomic])
+        } catch {
+            NSLog("MeetingRecordingManager: failed to save index.json — \(error.localizedDescription)")
+        }
+    }
+
+    // MARK: - Mic capture
+
+    private func startMicCapture(to url: URL) throws {
+        let input = micEngine.inputNode
+        let format = input.outputFormat(forBus: 0)
+        micFile = try AVAudioFile(forWriting: url, settings: format.settings)
+
+        input.removeTap(onBus: 0)
+        input.installTap(onBus: 0, bufferSize: 4096, format: format) { [weak self] buffer, _ in
+            guard let self, let file = self.micFile else { return }
+            do { try file.write(from: buffer) } catch { /* ignore */ }
+        }
+
+        micEngine.prepare()
+        do {
+            try micEngine.start()
+        } catch {
+            input.removeTap(onBus: 0)
+            micFile = nil
+            throw RecordingError.micCaptureFailed(error.localizedDescription)
+        }
+    }
+
+    private func stopMicCapture() {
+        micEngine.inputNode.removeTap(onBus: 0)
+        micEngine.stop()
+        micFile = nil
+    }
+
+    // MARK: - System audio capture (ScreenCaptureKit)
+
+    @available(macOS 13.0, *)
+    private func startSystemAudioCapture(to url: URL) async throws {
+        let content = try await SCShareableContent.current
+        guard let display = content.displays.first else { throw RecordingError.noShareableContent }
+
+        let filter = SCContentFilter(display: display, excludingWindows: [])
+        let config = SCStreamConfiguration()
+        config.capturesAudio = true
+        config.sampleRate = 48_000
+        config.channelCount = 2
+
+        let stream = SCStream(filter: filter, configuration: config, delegate: nil)
+        scStream = stream
+
+        // Prepare file once we see the first audio buffer (format can vary).
+        systemAudioFile = nil
+        systemAudioFormat = nil
+
+        let output = SystemAudioOutput(owner: self)
+        scAudioOutput = output
+        try stream.addStreamOutput(output, type: .audio, sampleHandlerQueue: DispatchQueue(label: "meeting.systemaudio.queue"))
+        try await stream.startCapture()
+
+        // Pre-create an assumed format for writing (48k stereo float32).
+        let fmt = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 2, interleaved: false)
+        systemAudioFormat = fmt
+        if let fmt {
+            systemAudioFile = try? AVAudioFile(forWriting: url, settings: fmt.settings)
+        }
+    }
+
+    private func stopSystemAudioCapture() async {
+        guard #available(macOS 13.0, *) else { return }
+        guard let stream = scStream as? SCStream else { return }
+        if let output = scAudioOutput as? SystemAudioOutput {
+            try? stream.removeStreamOutput(output, type: .audio)
+        }
+        try? await stream.stopCapture()
+        scStream = nil
+        scAudioOutput = nil
+        systemAudioFile = nil
+        systemAudioFormat = nil
+    }
+
+    // MARK: - Mix/export
+
+    private func mixAndExport(micURL: URL, systemURL: URL, outputURL: URL) async throws -> URL {
+        let fm = FileManager.default
+        if fm.fileExists(atPath: outputURL.path) {
+            try? fm.removeItem(at: outputURL)
+        }
+
+        let micAsset = AVURLAsset(url: micURL)
+        let sysExists = fm.fileExists(atPath: systemURL.path)
+        let sysAsset = sysExists ? AVURLAsset(url: systemURL) : nil
+
+        let composition = AVMutableComposition()
+        let micTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
+        if let src = micAsset.tracks(withMediaType: .audio).first {
+            let duration = micAsset.duration
+            if duration.isValid && !duration.isIndefinite && duration.seconds > 0 {
+                try micTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
+            }
+        }
+
+        if let sysAsset, let src = sysAsset.tracks(withMediaType: .audio).first {
+            let duration = sysAsset.duration
+            if duration.isValid && !duration.isIndefinite && duration.seconds > 0 {
+                let sysTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
+                try sysTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
+            }
+        }
+
+        if composition.tracks(withMediaType: .audio).isEmpty == false {
+            do {
+                return try await exportAssetToM4A(asset: composition, outputURL: outputURL)
+            } catch {
+                // Fall through to mic-only export.
+            }
+        }
+
+        return try await exportAssetToM4A(asset: micAsset, outputURL: outputURL)
+    }
+
+    private func exportAssetToM4A(asset: AVAsset, outputURL: URL) async throws -> URL {
+        let fm = FileManager.default
+        if fm.fileExists(atPath: outputURL.path) {
+            try? fm.removeItem(at: outputURL)
+        }
+        guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetAppleM4A) else {
+            throw RecordingError.exportFailed
+        }
+        exporter.outputURL = outputURL
+        exporter.outputFileType = .m4a
+        return try await withCheckedThrowingContinuation { cont in
+            exporter.exportAsynchronously {
+                if exporter.status == .completed {
+                    cont.resume(returning: outputURL)
+                } else {
+                    cont.resume(throwing: exporter.error ?? RecordingError.exportFailed)
+                }
+            }
+        }
+    }
+}
+
+@available(macOS 13.0, *)
+private final class SystemAudioOutput: NSObject, SCStreamOutput {
+    private weak var owner: MeetingRecordingManager?
+
+    init(owner: MeetingRecordingManager) {
+        self.owner = owner
+    }
+
+    func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
+        guard type == .audio else { return }
+        guard let owner, owner.isRecording else { return }
+        guard let file = owner.systemAudioFile, let format = owner.systemAudioFormat else { return }
+
+        guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { return }
+        var length: Int = 0
+        var dataPointer: UnsafeMutablePointer<Int8>?
+        guard CMBlockBufferGetDataPointer(blockBuffer, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, dataPointerOut: &dataPointer) == kCMBlockBufferNoErr,
+              let dataPointer else { return }
+
+        let frameCount = AVAudioFrameCount(length) / format.streamDescription.pointee.mBytesPerFrame
+        guard let pcm = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return }
+        pcm.frameLength = frameCount
+
+        if format.isInterleaved { return }
+        if format.commonFormat != AVAudioCommonFormat.pcmFormatFloat32 { return }
+
+        let bytesPerChannel = Int(frameCount) * MemoryLayout<Float>.size
+        for ch in 0..<Int(format.channelCount) {
+            guard let dst = pcm.floatChannelData?[ch] else { continue }
+            let srcOffset = ch * bytesPerChannel
+            memcpy(dst, dataPointer.advanced(by: srcOffset), bytesPerChannel)
+        }
+
+        try? file.write(from: pcm)
+    }
+}
+

+ 232 - 165
zoom_app/ViewController.swift

@@ -8,6 +8,7 @@
 import Cocoa
 import Combine
 import CryptoKit
+import AVFoundation
 import StoreKit
 import SwiftUI
 import UserNotifications
@@ -328,6 +329,13 @@ class ViewController: NSViewController {
     private var aiCompanionIsLoading: Bool = false
     private var aiCompanionLastLoadedAt: Date?
     private var aiCompanionSelectedRange: String = "Last 30 days"
+
+    // New AI Companion mode: local meeting recordings (audio)
+    private var recordedMeetings: [MeetingRecordingManager.RecordedMeeting] = []
+    private var recordedMeetingsPlayer: AVAudioPlayer?
+    private var recordedMeetingsPlayingID: String?
+    private weak var aiCompanionStopRecordingButton: NSButton?
+    private weak var aiCompanionOpenRecordingsFolderButton: NSButton?
     private weak var meetingsPageCategoryFilter: NSPopUpButton?
     private weak var meetingsPageFromDatePicker: NSDatePicker?
     private weak var meetingsPageToDatePicker: NSDatePicker?
@@ -641,6 +649,27 @@ class ViewController: NSViewController {
         meetingTimeZoneOverrides = UserDefaults.standard.dictionary(forKey: meetingTimeZoneOverrideKey) as? [String: String] ?? [:]
         ensureUsageSessionStartDate()
         scheduleUsageBasedRatingPromptIfNeeded()
+        recordedMeetings = MeetingRecordingManager.shared.recordings
+        NotificationCenter.default.addObserver(
+            forName: MeetingRecordingManager.recordingsDidChangeNotification,
+            object: nil,
+            queue: .main
+        ) { [weak self] _ in
+            guard let self else { return }
+            self.recordedMeetings = MeetingRecordingManager.shared.recordings
+            Task { @MainActor in
+                self.renderAiCompanionList()
+            }
+        }
+        NotificationCenter.default.addObserver(
+            forName: MeetingRecordingManager.recordingStateDidChangeNotification,
+            object: nil,
+            queue: .main
+        ) { [weak self] _ in
+            Task { @MainActor in
+                self?.updateAiCompanionRecordingUI()
+            }
+        }
         setupUI()
         setupWidgetBridge()
         startStoreKit()
@@ -679,6 +708,9 @@ class ViewController: NSViewController {
         if (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
             updateEmbeddedRightPanelWidthForOpenState()
         }
+        if aiCompanionPageRootView?.isHidden == false {
+            refreshAiCompanionStatusPreferredMaxLayoutWidth()
+        }
     }
 
     private func setupUI() {
@@ -764,6 +796,8 @@ class ViewController: NSViewController {
         aiCompanionDetailEmptyLabel = nil
         aiCompanionOpenDocButton = nil
         aiCompanionCopyMarkdownButton = nil
+        aiCompanionStopRecordingButton = nil
+        aiCompanionOpenRecordingsFolderButton = nil
         aiCompanionRowButtons.removeAll()
         aiCompanionLastLoadedAt = nil
         homeView?.removeFromSuperview()
@@ -1666,6 +1700,15 @@ class ViewController: NSViewController {
         let opened = openURLInDefaultBrowser(url)
         if opened {
             joinMeetingWindow?.performClose(nil)
+            // AI Companion mode requirement: automatically record every meeting we start/join.
+            Task { @MainActor in
+                do {
+                    try await MeetingRecordingManager.shared.startRecording(sourceURL: url)
+                    self.updateAiCompanionRecordingUI()
+                } catch {
+                    self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
+                }
+            }
         } else {
             showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
         }
@@ -1812,15 +1855,11 @@ class ViewController: NSViewController {
         zoomOAuth.clearSavedTokens()
         persistLoggedInState(false)
         allScheduledMeetings = []
-        aiCompanionSummaries = []
-        aiCompanionSelectedIndex = nil
-        aiCompanionLastLoadedAt = nil
         applyAuthenticationUIState(triggerInitialRefresh: false)
         Task { @MainActor in
             self.applyMeetings([])
             self.renderAiCompanionList()
-            self.renderAiCompanionDetail()
-            self.aiCompanionStatusLabel?.stringValue = "Sign in with Zoom to view AI notes."
+            self.updateAiCompanionRecordingUI()
         }
     }
 
@@ -6518,6 +6557,8 @@ class ViewController: NSViewController {
         let root = NSView()
         root.wantsLayer = true
         root.layer?.backgroundColor = NSColor.clear.cgColor
+        // Let the window dictate width; long status copy must not inflate the layout minimum.
+        root.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
 
         let container = NSView()
         container.translatesAutoresizingMaskIntoConstraints = false
@@ -6528,40 +6569,47 @@ class ViewController: NSViewController {
         container.layer?.borderColor = palette.inputBorder.cgColor
         root.addSubview(container)
 
-        // Header row: title / subtitle / count + range selector + refresh.
+        // Header row: recordings list (AI Companion mode).
         let title = makeLabel("Ai Companion", size: 28, color: primaryText, weight: .bold, centered: false)
-        let subtitle = makeLabel("AI-generated notes from your past Zoom meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
-        let count = makeLabel("0 summaries", size: 12, color: secondaryText, weight: .medium, centered: false)
+        let subtitle = makeLabel("Recorded meetings (audio)", size: 13, color: secondaryText, weight: .regular, centered: false)
+        let count = makeLabel("0 recordings", size: 12, color: secondaryText, weight: .medium, centered: false)
         let titleStack = NSStackView(views: [title, subtitle, count])
         titleStack.orientation = .vertical
         titleStack.spacing = 4
         titleStack.alignment = .leading
         titleStack.translatesAutoresizingMaskIntoConstraints = false
 
-        let rangePopup = NSPopUpButton()
-        rangePopup.translatesAutoresizingMaskIntoConstraints = false
-        rangePopup.addItems(withTitles: ["Last 7 days", "Last 30 days", "Last 90 days", "All available"])
-        rangePopup.selectItem(withTitle: aiCompanionSelectedRange)
-        rangePopup.target = self
-        rangePopup.action = #selector(aiCompanionRangeChanged(_:))
-
-        let refresh = NSButton(title: "Refresh", target: self, action: #selector(aiCompanionRefreshTapped))
-        refresh.translatesAutoresizingMaskIntoConstraints = false
-        refresh.isBordered = false
-        refresh.wantsLayer = true
-        refresh.layer?.backgroundColor = accentBlue.cgColor
-        refresh.layer?.cornerRadius = 10
-        refresh.contentTintColor = .white
-        refresh.font = .systemFont(ofSize: 13, weight: .semibold)
-
+        // Old Zoom AI summaries controls are intentionally hidden/unused.
         let loadingIndicator = NSProgressIndicator()
         loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
         loadingIndicator.style = .spinning
         loadingIndicator.controlSize = .small
         loadingIndicator.isDisplayedWhenStopped = false
         loadingIndicator.isIndeterminate = true
-
-        let toolbarRight = NSStackView(views: [loadingIndicator, rangePopup, refresh])
+        loadingIndicator.isHidden = true
+
+        let stopRecording = NSButton(title: "Stop & save", target: self, action: #selector(aiCompanionStopRecordingTapped))
+        stopRecording.translatesAutoresizingMaskIntoConstraints = false
+        stopRecording.isBordered = false
+        stopRecording.wantsLayer = true
+        stopRecording.layer?.backgroundColor = accentBlue.cgColor
+        stopRecording.layer?.cornerRadius = 10
+        stopRecording.contentTintColor = .white
+        stopRecording.font = .systemFont(ofSize: 13, weight: .semibold)
+        stopRecording.isHidden = true
+
+        let openFolder = NSButton(title: "Show folder", target: self, action: #selector(aiCompanionOpenRecordingsFolderTapped))
+        openFolder.translatesAutoresizingMaskIntoConstraints = false
+        openFolder.isBordered = false
+        openFolder.wantsLayer = true
+        openFolder.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06)).cgColor
+        openFolder.layer?.cornerRadius = 10
+        openFolder.layer?.borderWidth = 1
+        openFolder.layer?.borderColor = palette.inputBorder.cgColor
+        openFolder.contentTintColor = primaryText
+        openFolder.font = .systemFont(ofSize: 13, weight: .semibold)
+
+        let toolbarRight = NSStackView(views: [openFolder, stopRecording, loadingIndicator])
         toolbarRight.orientation = .horizontal
         toolbarRight.alignment = .centerY
         toolbarRight.spacing = 10
@@ -6571,8 +6619,14 @@ class ViewController: NSViewController {
         container.addSubview(toolbarRight)
 
         // Status label below header.
-        let status = makeLabel("Sign in with Zoom to view AI notes.", size: 12, color: secondaryText, weight: .regular, centered: false)
+        let status = makeLabel("", size: 12, color: secondaryText, weight: .regular, centered: false)
         status.translatesAutoresizingMaskIntoConstraints = false
+        status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        status.lineBreakMode = .byWordWrapping
+        status.maximumNumberOfLines = 0
+        if let cell = status.cell as? NSTextFieldCell {
+            cell.wraps = true
+        }
         container.addSubview(status)
 
         // List scroll view (left column).
@@ -6598,47 +6652,19 @@ class ViewController: NSViewController {
         listScroll.documentView = listStack
         container.addSubview(listScroll)
 
-        let listEmpty = makeLabel("No AI notes yet for the selected range.", size: 12, color: secondaryText, weight: .regular, centered: true)
+        let listEmpty = makeLabel("No recordings yet.", size: 12, color: secondaryText, weight: .regular, centered: true)
         listEmpty.translatesAutoresizingMaskIntoConstraints = false
         listEmpty.maximumNumberOfLines = 3
         listEmpty.lineBreakMode = .byWordWrapping
         listEmpty.isHidden = true
         container.addSubview(listEmpty)
 
-        // Detail scroll view (right column).
-        let detailScroll = NSScrollView()
-        detailScroll.translatesAutoresizingMaskIntoConstraints = false
-        detailScroll.drawsBackground = false
-        detailScroll.hasHorizontalScroller = false
-        detailScroll.hasVerticalScroller = true
-        detailScroll.autohidesScrollers = true
-        detailScroll.borderType = .noBorder
-        detailScroll.scrollerStyle = .overlay
-        detailScroll.automaticallyAdjustsContentInsets = false
-
-        let detailClip = TopAlignedClipView()
-        detailClip.drawsBackground = false
-        detailScroll.contentView = detailClip
-
-        let detailStack = NSStackView()
-        detailStack.translatesAutoresizingMaskIntoConstraints = false
-        detailStack.orientation = .vertical
-        detailStack.alignment = .leading
-        detailStack.spacing = 14
-        detailStack.edgeInsets = NSEdgeInsets(top: 18, left: 20, bottom: 20, right: 20)
-        detailScroll.documentView = detailStack
-        container.addSubview(detailScroll)
-
-        let detailEmpty = makeLabel("Select a meeting on the left to view its AI notes.", size: 13, color: secondaryText, weight: .regular, centered: true)
-        detailEmpty.translatesAutoresizingMaskIntoConstraints = false
-        detailEmpty.maximumNumberOfLines = 3
-        detailEmpty.lineBreakMode = .byWordWrapping
-        container.addSubview(detailEmpty)
-
+        // Old detail pane (AI notes) removed.
         let divider = NSView()
         divider.translatesAutoresizingMaskIntoConstraints = false
         divider.wantsLayer = true
         divider.layer?.backgroundColor = palette.inputBorder.cgColor
+        divider.isHidden = true
         container.addSubview(divider)
 
         NSLayoutConstraint.activate([
@@ -6654,10 +6680,10 @@ class ViewController: NSViewController {
             toolbarRight.centerYAnchor.constraint(equalTo: titleStack.centerYAnchor),
             toolbarRight.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
 
-            refresh.heightAnchor.constraint(equalToConstant: 30),
-            refresh.widthAnchor.constraint(greaterThanOrEqualToConstant: 84),
-            rangePopup.heightAnchor.constraint(equalToConstant: 28),
-            rangePopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 140),
+            stopRecording.heightAnchor.constraint(equalToConstant: 30),
+            stopRecording.widthAnchor.constraint(greaterThanOrEqualToConstant: 110),
+            openFolder.heightAnchor.constraint(equalToConstant: 30),
+            openFolder.widthAnchor.constraint(greaterThanOrEqualToConstant: 110),
 
             status.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
             status.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
@@ -6666,12 +6692,13 @@ class ViewController: NSViewController {
             listScroll.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 10),
             listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
             listScroll.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -16),
-            listScroll.widthAnchor.constraint(equalTo: container.widthAnchor, multiplier: 0.34),
+            listScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
 
+            // Pin document view to the clip view without horizontal insets: when the clip is still
+            // width 0 during layout, leading/trailing + width - 16 over-constrains and breaks.
             listStack.topAnchor.constraint(equalTo: listClip.topAnchor, constant: 8),
-            listStack.leadingAnchor.constraint(equalTo: listClip.leadingAnchor, constant: 8),
-            listStack.trailingAnchor.constraint(equalTo: listClip.trailingAnchor, constant: -8),
-            listStack.widthAnchor.constraint(equalTo: listClip.widthAnchor, constant: -16),
+            listStack.leadingAnchor.constraint(equalTo: listClip.leadingAnchor),
+            listStack.trailingAnchor.constraint(equalTo: listClip.trailingAnchor),
 
             listEmpty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
             listEmpty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
@@ -6680,40 +6707,67 @@ class ViewController: NSViewController {
             divider.topAnchor.constraint(equalTo: listScroll.topAnchor),
             divider.bottomAnchor.constraint(equalTo: listScroll.bottomAnchor),
             divider.leadingAnchor.constraint(equalTo: listScroll.trailingAnchor, constant: 10),
-            divider.widthAnchor.constraint(equalToConstant: 1),
-
-            detailScroll.topAnchor.constraint(equalTo: listScroll.topAnchor),
-            detailScroll.bottomAnchor.constraint(equalTo: listScroll.bottomAnchor),
-            detailScroll.leadingAnchor.constraint(equalTo: divider.trailingAnchor, constant: 10),
-            detailScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
-
-            detailStack.topAnchor.constraint(equalTo: detailClip.topAnchor),
-            detailStack.leadingAnchor.constraint(equalTo: detailClip.leadingAnchor),
-            detailStack.trailingAnchor.constraint(equalTo: detailClip.trailingAnchor),
-            detailStack.widthAnchor.constraint(equalTo: detailClip.widthAnchor),
-
-            detailEmpty.centerXAnchor.constraint(equalTo: detailScroll.centerXAnchor),
-            detailEmpty.centerYAnchor.constraint(equalTo: detailScroll.centerYAnchor),
-            detailEmpty.widthAnchor.constraint(lessThanOrEqualTo: detailScroll.widthAnchor, constant: -32)
+            divider.widthAnchor.constraint(equalToConstant: 1)
         ])
 
         aiCompanionListScroll = listScroll
         aiCompanionListStack = listStack
-        aiCompanionDetailScroll = detailScroll
-        aiCompanionDetailStack = detailStack
         aiCompanionStatusLabel = status
         aiCompanionCountLabel = count
-        aiCompanionRefreshButton = refresh
-        aiCompanionRangePopup = rangePopup
         aiCompanionEmptyLabel = listEmpty
         aiCompanionLoadingIndicator = loadingIndicator
-        aiCompanionDetailEmptyLabel = detailEmpty
+        aiCompanionDetailEmptyLabel = nil
+        aiCompanionDetailScroll = nil
+        aiCompanionDetailStack = nil
+        aiCompanionStopRecordingButton = stopRecording
+        aiCompanionOpenRecordingsFolderButton = openFolder
 
         renderAiCompanionList()
-        renderAiCompanionDetail()
+        updateAiCompanionRecordingUI()
         return root
     }
 
+    @MainActor
+    private func updateAiCompanionRecordingUI() {
+        let mgr = MeetingRecordingManager.shared
+        aiCompanionStopRecordingButton?.isHidden = !mgr.isRecording
+        if mgr.isRecording {
+            aiCompanionStatusLabel?.stringValue =
+                "Recording… When you leave the meeting, click Stop & save (here or in the menu bar). Files are stored in the app sandbox—use Show folder to open the Recordings location in Finder."
+        } else {
+            aiCompanionStatusLabel?.stringValue =
+                "Start or join a meeting from this app to record. Saved audio is stored in the app sandbox; use Show folder to open the Recordings folder in Finder."
+        }
+        refreshAiCompanionStatusPreferredMaxLayoutWidth()
+    }
+
+    /// Keeps the status field wrapping inside the card so a long path or error string cannot widen the window.
+    @MainActor
+    private func refreshAiCompanionStatusPreferredMaxLayoutWidth() {
+        guard let status = aiCompanionStatusLabel, let container = status.superview else { return }
+        let w = container.bounds.width
+        guard w > 1 else { return }
+        let margin: CGFloat = 48
+        status.preferredMaxLayoutWidth = max(160, w - margin)
+    }
+
+    @objc private func aiCompanionStopRecordingTapped() {
+        Task { @MainActor in
+            do {
+                try await MeetingRecordingManager.shared.stopRecording()
+                self.recordedMeetings = MeetingRecordingManager.shared.recordings
+                self.renderAiCompanionList()
+                self.updateAiCompanionRecordingUI()
+            } catch {
+                self.showSimpleAlert(title: "Could not save recording", message: error.localizedDescription)
+            }
+        }
+    }
+
+    @objc private func aiCompanionOpenRecordingsFolderTapped() {
+        MeetingRecordingManager.shared.revealRecordingsInFinder()
+    }
+
     // MARK: - AI Companion list / detail rendering
 
     @MainActor
@@ -6725,85 +6779,102 @@ class ViewController: NSViewController {
             view.removeFromSuperview()
         }
 
-        let count = aiCompanionSummaries.count
-        aiCompanionCountLabel?.stringValue = count == 1 ? "1 summary" : "\(count) summaries"
+        let count = recordedMeetings.count
+        aiCompanionCountLabel?.stringValue = count == 1 ? "1 recording" : "\(count) recordings"
 
-        let showEmpty: Bool
-        if isUserLoggedIn() == false {
-            showEmpty = true
-            aiCompanionEmptyLabel?.stringValue = "Please sign in with Zoom to view your AI notes."
-        } else if count == 0 {
-            showEmpty = true
-            aiCompanionEmptyLabel?.stringValue = "No AI notes yet for the selected range."
-        } else {
-            showEmpty = false
+        let showEmpty = count == 0
+        if showEmpty {
+            aiCompanionEmptyLabel?.stringValue = "No recordings yet. Start or join a meeting to record it."
         }
         aiCompanionEmptyLabel?.isHidden = !showEmpty
 
-        for (idx, summary) in aiCompanionSummaries.enumerated() {
-            let row = makeAiCompanionListRow(summary: summary, index: idx, isSelected: idx == aiCompanionSelectedIndex)
+        for (idx, recording) in recordedMeetings.enumerated() {
+            let row = makeRecordedMeetingCard(recording: recording, index: idx)
             stack.addArrangedSubview(row)
             row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
         }
     }
 
     @MainActor
-    private func makeAiCompanionListRow(summary: AICompanionSummary, index: Int, isSelected: Bool) -> NSButton {
-        let button = NSButton(title: "", target: self, action: #selector(aiCompanionRowTapped(_:)))
-        button.translatesAutoresizingMaskIntoConstraints = false
-        button.bezelStyle = .regularSquare
-        button.isBordered = false
-        button.setButtonType(.momentaryChange)
-        button.tag = index
-        button.wantsLayer = true
-        button.layer?.cornerRadius = 12
-        button.layer?.borderWidth = 1
-
-        let baseBg: NSColor = palette.isDarkMode
-            ? NSColor.white.withAlphaComponent(0.04)
-            : NSColor.black.withAlphaComponent(0.03)
-        let selectedBg: NSColor = accentBlue.withAlphaComponent(palette.isDarkMode ? 0.22 : 0.14)
-        let baseBorder: NSColor = palette.inputBorder
-        let selectedBorder: NSColor = accentBlue
-
-        button.layer?.backgroundColor = (isSelected ? selectedBg : baseBg).cgColor
-        button.layer?.borderColor = (isSelected ? selectedBorder : baseBorder).cgColor
+    private func makeRecordedMeetingCard(recording: MeetingRecordingManager.RecordedMeeting, index: Int) -> NSView {
+        let card = NSView()
+        card.translatesAutoresizingMaskIntoConstraints = false
+        card.wantsLayer = true
+        card.layer?.cornerRadius = 12
+        card.layer?.borderWidth = 1
+        card.layer?.borderColor = palette.inputBorder.cgColor
+        card.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.04) : NSColor.black.withAlphaComponent(0.03)).cgColor
 
-        let topic = summary.summaryTitle?.isEmpty == false
-            ? summary.summaryTitle!
-            : (summary.meetingTopic?.isEmpty == false ? summary.meetingTopic! : "Untitled meeting")
-        let titleLabel = makeLabel(topic, size: 14, color: primaryText, weight: .semibold, centered: false)
-        titleLabel.translatesAutoresizingMaskIntoConstraints = false
-        titleLabel.maximumNumberOfLines = 2
-        titleLabel.lineBreakMode = .byTruncatingTail
+        let title = makeLabel("Meeting recording", size: 14, color: primaryText, weight: .semibold, centered: false)
+        title.translatesAutoresizingMaskIntoConstraints = false
+        title.maximumNumberOfLines = 1
+        title.lineBreakMode = .byTruncatingTail
 
-        let dateText = aiCompanionDateText(for: summary)
+        let dateFormatter = DateFormatter()
+        dateFormatter.dateStyle = .medium
+        dateFormatter.timeStyle = .short
+        let dateText = "\(dateFormatter.string(from: recording.startedAt))"
         let dateLabel = makeLabel(dateText, size: 11, color: secondaryText, weight: .regular, centered: false)
         dateLabel.translatesAutoresizingMaskIntoConstraints = false
 
-        let badge = makeLabel("AI notes", size: 10, color: accentBlue, weight: .semibold, centered: false)
-        badge.translatesAutoresizingMaskIntoConstraints = false
+        let play = NSButton(title: recordedMeetingsPlayingID == recording.id ? "Stop" : "Play", target: self, action: #selector(recordingPlayTapped(_:)))
+        play.translatesAutoresizingMaskIntoConstraints = false
+        play.isBordered = false
+        play.wantsLayer = true
+        play.layer?.cornerRadius = 10
+        play.layer?.backgroundColor = accentBlue.cgColor
+        play.contentTintColor = .white
+        play.font = .systemFont(ofSize: 12, weight: .semibold)
+        play.tag = index
 
-        button.addSubview(titleLabel)
-        button.addSubview(dateLabel)
-        button.addSubview(badge)
+        card.addSubview(title)
+        card.addSubview(dateLabel)
+        card.addSubview(play)
 
         NSLayoutConstraint.activate([
-            titleLabel.topAnchor.constraint(equalTo: button.topAnchor, constant: 10),
-            titleLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 12),
-            titleLabel.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -12),
+            title.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
+            title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
+            title.trailingAnchor.constraint(lessThanOrEqualTo: play.leadingAnchor, constant: -10),
+
+            dateLabel.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
+            dateLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
+            dateLabel.trailingAnchor.constraint(equalTo: title.trailingAnchor),
+            dateLabel.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10),
+
+            play.centerYAnchor.constraint(equalTo: card.centerYAnchor),
+            play.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12),
+            play.heightAnchor.constraint(equalToConstant: 30),
+            play.widthAnchor.constraint(greaterThanOrEqualToConstant: 72)
+        ])
 
-            dateLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
-            dateLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
-            dateLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
+        return card
+    }
 
-            badge.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 6),
-            badge.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
-            badge.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -10)
-        ])
+    @objc
+    private func recordingPlayTapped(_ sender: NSButton) {
+        let idx = sender.tag
+        guard recordedMeetings.indices.contains(idx) else { return }
+        let recording = recordedMeetings[idx]
 
-        aiCompanionRowButtons.append(button)
-        return button
+        if recordedMeetingsPlayingID == recording.id {
+            recordedMeetingsPlayer?.stop()
+            recordedMeetingsPlayer = nil
+            recordedMeetingsPlayingID = nil
+            renderAiCompanionList()
+            return
+        }
+
+        do {
+            let url = try MeetingRecordingManager.shared.audioURL(for: recording)
+            recordedMeetingsPlayer?.stop()
+            recordedMeetingsPlayer = try AVAudioPlayer(contentsOf: url)
+            recordedMeetingsPlayer?.prepareToPlay()
+            recordedMeetingsPlayer?.play()
+            recordedMeetingsPlayingID = recording.id
+            renderAiCompanionList()
+        } catch {
+            showSimpleAlert(title: "Unable to play", message: "Could not open the audio recording.")
+        }
     }
 
     @MainActor
@@ -7060,38 +7131,23 @@ class ViewController: NSViewController {
     }
 
     @objc private func aiCompanionRefreshTapped() {
-        Task { await self.loadAICompanionSummaries(force: true) }
+        // Removed: Zoom AI summaries fetch (AI Companion now shows local recordings only).
     }
 
     @objc private func aiCompanionRangeChanged(_ sender: NSPopUpButton) {
-        aiCompanionSelectedRange = sender.titleOfSelectedItem ?? "Last 30 days"
-        Task { await self.loadAICompanionSummaries(force: true) }
+        // Removed: Zoom AI summaries range filter (AI Companion now shows local recordings only).
     }
 
     @objc private func aiCompanionRowTapped(_ sender: NSButton) {
-        let index = sender.tag
-        guard aiCompanionSummaries.indices.contains(index) else { return }
-        aiCompanionSelectedIndex = index
-        renderAiCompanionList()
-        renderAiCompanionDetail()
+        // Removed: Zoom AI summaries row selection (AI Companion now shows local recordings only).
     }
 
     @objc private func aiCompanionOpenDocTapped() {
-        guard let index = aiCompanionSelectedIndex,
-              aiCompanionSummaries.indices.contains(index),
-              let url = aiCompanionSummaries[index].summaryDocURL else { return }
-        openWebURLPreferringInApp(url)
+        // Removed: Zoom Docs deep link (AI Companion now shows local recordings only).
     }
 
     @objc private func aiCompanionCopyMarkdownTapped() {
-        guard let index = aiCompanionSelectedIndex,
-              aiCompanionSummaries.indices.contains(index) else { return }
-        let summary = aiCompanionSummaries[index]
-        let markdown = aiCompanionMarkdown(for: summary)
-        let pasteboard = NSPasteboard.general
-        pasteboard.clearContents()
-        pasteboard.setString(markdown, forType: .string)
-        aiCompanionStatusLabel?.stringValue = "Copied AI notes as Markdown."
+        // Removed: Zoom AI summary markdown export (AI Companion now shows local recordings only).
     }
 
     private func aiCompanionMarkdown(for summary: AICompanionSummary) -> String {
@@ -7680,6 +7736,14 @@ class ViewController: NSViewController {
             return
         }
         openWebURLPreferringInApp(url)
+        Task { @MainActor in
+            do {
+                try await MeetingRecordingManager.shared.startRecording(sourceURL: url)
+                self.updateAiCompanionRecordingUI()
+            } catch {
+                self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
+            }
+        }
     }
 
     func menuBarTriggerJoinMeeting() {
@@ -7771,6 +7835,9 @@ class ViewController: NSViewController {
             applyMeetingsPageFilters(resetVisible: false)
         }
         if isAiCompanion {
+            recordedMeetings = MeetingRecordingManager.shared.recordings
+            renderAiCompanionList()
+            updateAiCompanionRecordingUI()
             aiCompanionRefreshIfStale()
         }
     }

+ 2 - 0
zoom_app/zoom_app.entitlements

@@ -4,6 +4,8 @@
 <dict>
 	<key>com.apple.security.app-sandbox</key>
 	<true/>
+	<key>com.apple.security.device.audio-input</key>
+	<true/>
 	<key>com.apple.security.network.client</key>
 	<true/>
 	<key>com.apple.security.network.server</key>