소스 검색

Add AI Companion meeting summaries from Zoom API

- Extend Zoom OAuth scope check for meeting:read:summary (and admin variant).
- Call GET /v2/meetings/meeting_summaries with date range and pagination, plus
  GET /v2/meetings/{id}/meeting_summary with double-encoding for UUID paths.
- Replace Ai Companion placeholder with list/detail UI: range filter, refresh,
  overview/sections/next steps, open Zoom Docs, copy Markdown export.
- Load summaries when entering Ai Companion (stale after 60s); clear state on
  logout and when rebuilding the home view.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 달 전
부모
커밋
6ece620b28
1개의 변경된 파일853개의 추가작업 그리고 5개의 파일을 삭제
  1. 853 5
      zoom_app/ViewController.swift

+ 853 - 5
zoom_app/ViewController.swift

@@ -309,6 +309,25 @@ class ViewController: NSViewController {
     private weak var meetingsPageRootView: NSView?
     private weak var widgetsPageRootView: NSView?
     private weak var aiCompanionPageRootView: NSView?
+    private weak var aiCompanionListStack: NSStackView?
+    private weak var aiCompanionListScroll: NSScrollView?
+    private weak var aiCompanionDetailScroll: NSScrollView?
+    private weak var aiCompanionDetailStack: NSStackView?
+    private weak var aiCompanionStatusLabel: NSTextField?
+    private weak var aiCompanionCountLabel: NSTextField?
+    private weak var aiCompanionRefreshButton: NSButton?
+    private weak var aiCompanionRangePopup: NSPopUpButton?
+    private weak var aiCompanionEmptyLabel: NSTextField?
+    private weak var aiCompanionLoadingIndicator: NSProgressIndicator?
+    private weak var aiCompanionDetailEmptyLabel: NSTextField?
+    private weak var aiCompanionOpenDocButton: NSButton?
+    private weak var aiCompanionCopyMarkdownButton: NSButton?
+    private var aiCompanionRowButtons: [NSButton] = []
+    private var aiCompanionSummaries: [AICompanionSummary] = []
+    private var aiCompanionSelectedIndex: Int?
+    private var aiCompanionIsLoading: Bool = false
+    private var aiCompanionLastLoadedAt: Date?
+    private var aiCompanionSelectedRange: String = "Last 30 days"
     private weak var meetingsPageCategoryFilter: NSPopUpButton?
     private weak var meetingsPageFromDatePicker: NSDatePicker?
     private weak var meetingsPageToDatePicker: NSDatePicker?
@@ -732,6 +751,21 @@ class ViewController: NSViewController {
         meetingsPageRootView = nil
         widgetsPageRootView = nil
         aiCompanionPageRootView = nil
+        aiCompanionListStack = nil
+        aiCompanionListScroll = nil
+        aiCompanionDetailScroll = nil
+        aiCompanionDetailStack = nil
+        aiCompanionStatusLabel = nil
+        aiCompanionCountLabel = nil
+        aiCompanionRefreshButton = nil
+        aiCompanionRangePopup = nil
+        aiCompanionEmptyLabel = nil
+        aiCompanionLoadingIndicator = nil
+        aiCompanionDetailEmptyLabel = nil
+        aiCompanionOpenDocButton = nil
+        aiCompanionCopyMarkdownButton = nil
+        aiCompanionRowButtons.removeAll()
+        aiCompanionLastLoadedAt = nil
         homeView?.removeFromSuperview()
         if let profileName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines), profileName.isEmpty == false {
             topBarProfileInitial = String(profileName.prefix(1)).uppercased()
@@ -1775,9 +1809,15 @@ 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."
         }
     }
 
@@ -3317,6 +3357,222 @@ class ViewController: NSViewController {
         return mapMeetings(allMeetings)
     }
 
+    // MARK: - AI Companion Meeting Summaries (Zoom API)
+
+    /// In-memory model for an AI Companion meeting summary. Mirrors the fields returned by
+    /// `GET /v2/meetings/meeting_summaries` and `GET /v2/meetings/{meetingId}/meeting_summary`.
+    /// Docs: https://developers.zoom.us/changelog/meetings/november-20-2023/
+    struct AICompanionSummary: Equatable {
+        struct SummarySection: Equatable {
+            let label: String
+            let summary: String
+        }
+
+        let meetingUUID: String?
+        let meetingID: String?
+        let meetingTopic: String?
+        let meetingHostEmail: String?
+        let meetingStartTime: Date?
+        let meetingEndTime: Date?
+
+        let summaryTitle: String?
+        let summaryOverview: String?
+        let summaryDetails: [SummarySection]
+        let nextSteps: [String]
+        let summaryContent: String?
+        let summaryDocURL: URL?
+
+        let summaryCreatedTime: Date?
+        let summaryLastModifiedTime: Date?
+    }
+
+    private struct ZoomMeetingSummaryAPIItem: Decodable {
+        struct SummaryDetail: Decodable {
+            let label: String?
+            let summary: String?
+        }
+
+        let meeting_host_id: String?
+        let meeting_host_email: String?
+        let meeting_uuid: String?
+        let meeting_id: Int64?
+        let meeting_topic: String?
+        let meeting_start_time: String?
+        let meeting_end_time: String?
+
+        let summary_start_time: String?
+        let summary_end_time: String?
+        let summary_created_time: String?
+        let summary_last_modified_time: String?
+
+        let summary_title: String?
+        let summary_overview: String?
+        let summary_details: [SummaryDetail]?
+        let next_steps: [String]?
+        let summary_content: String?
+        let summary_doc_url: String?
+
+        func toModel(using iso: ISO8601DateFormatter, fallback: ISO8601DateFormatter) -> ViewController.AICompanionSummary {
+            func parseDate(_ s: String?) -> Date? {
+                guard let s, s.isEmpty == false else { return nil }
+                return iso.date(from: s) ?? fallback.date(from: s)
+            }
+            let details: [AICompanionSummary.SummarySection] = (summary_details ?? []).compactMap { detail in
+                let label = (detail.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+                let body = (detail.summary ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+                if label.isEmpty && body.isEmpty { return nil }
+                return AICompanionSummary.SummarySection(label: label, summary: body)
+            }
+            let steps: [String] = (next_steps ?? [])
+                .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+                .filter { $0.isEmpty == false }
+            let docURL: URL? = {
+                guard let raw = summary_doc_url?.trimmingCharacters(in: .whitespacesAndNewlines),
+                      raw.isEmpty == false else { return nil }
+                return URL(string: raw)
+            }()
+            return AICompanionSummary(
+                meetingUUID: meeting_uuid,
+                meetingID: meeting_id.map(String.init),
+                meetingTopic: meeting_topic,
+                meetingHostEmail: meeting_host_email,
+                meetingStartTime: parseDate(meeting_start_time),
+                meetingEndTime: parseDate(meeting_end_time),
+                summaryTitle: summary_title,
+                summaryOverview: summary_overview,
+                summaryDetails: details,
+                nextSteps: steps,
+                summaryContent: summary_content,
+                summaryDocURL: docURL,
+                summaryCreatedTime: parseDate(summary_created_time),
+                summaryLastModifiedTime: parseDate(summary_last_modified_time)
+            )
+        }
+    }
+
+    /// Fetches the signed-in user's AI Companion meeting summaries via Zoom's
+    /// `GET /v2/meetings/meeting_summaries` endpoint. Requires the `meeting:read:summary` scope.
+    private func fetchZoomMeetingSummaries(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary] {
+        struct Page: Decodable {
+            let summaries: [ZoomMeetingSummaryAPIItem]?
+            let meeting_summaries: [ZoomMeetingSummaryAPIItem]?
+            let next_page_token: String?
+        }
+
+        let iso = ISO8601DateFormatter()
+        iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+        let fallbackISO = ISO8601DateFormatter()
+        fallbackISO.formatOptions = [.withInternetDateTime]
+
+        let dayFormatter = DateFormatter()
+        dayFormatter.dateFormat = "yyyy-MM-dd"
+        dayFormatter.timeZone = TimeZone(identifier: "UTC")
+
+        var collected: [AICompanionSummary] = []
+        var seenUUIDs = Set<String>()
+        var nextPageToken: String?
+        var safetyCounter = 0
+        repeat {
+            var components = URLComponents(string: "https://api.zoom.us/v2/meetings/meeting_summaries")!
+            var items: [URLQueryItem] = [
+                URLQueryItem(name: "page_size", value: "30")
+            ]
+            if let from { items.append(URLQueryItem(name: "from", value: dayFormatter.string(from: from))) }
+            if let to { items.append(URLQueryItem(name: "to", value: dayFormatter.string(from: to))) }
+            if let nextPageToken, nextPageToken.isEmpty == false {
+                items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
+            }
+            components.queryItems = items
+            var request = URLRequest(url: components.url!)
+            request.httpMethod = "GET"
+            request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+            let (data, response) = try await URLSession.shared.data(for: request)
+            guard let http = response as? HTTPURLResponse else {
+                throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summaries)")
+            }
+            if http.statusCode == 429 {
+                let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
+                let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
+                throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
+            }
+            guard (200..<300).contains(http.statusCode) else {
+                let raw = String(data: data, encoding: .utf8) ?? "Failed to load AI summaries"
+                if raw.localizedCaseInsensitiveContains("does not contain scopes") {
+                    throw ZoomOAuthError.missingRequiredScope(raw)
+                }
+                throw GoogleOAuthError.tokenExchangeFailed(raw)
+            }
+
+            let decoded = try JSONDecoder().decode(Page.self, from: data)
+            let raw = decoded.summaries ?? decoded.meeting_summaries ?? []
+            for item in raw {
+                let model = item.toModel(using: iso, fallback: fallbackISO)
+                let dedupKey = model.meetingUUID ?? (model.meetingID ?? UUID().uuidString)
+                if seenUUIDs.insert(dedupKey).inserted {
+                    collected.append(model)
+                }
+            }
+            let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
+            nextPageToken = (token?.isEmpty == false) ? token : nil
+            safetyCounter += 1
+            if safetyCounter >= 10 { break }
+        } while nextPageToken != nil
+
+        return collected
+    }
+
+    /// Fetches a single AI Companion summary by meeting ID or UUID via
+    /// `GET /v2/meetings/{meetingId}/meeting_summary`. UUIDs containing `/` or `//` must be
+    /// double-URL-encoded per Zoom's API conventions.
+    private func fetchZoomMeetingSummaryDetail(accessToken: String, meetingIdOrUUID: String) async throws -> AICompanionSummary? {
+        let trimmed = meetingIdOrUUID.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard trimmed.isEmpty == false else { return nil }
+
+        let isLikelyUUID = trimmed.contains("/") || trimmed.contains("=")
+        let pathSegment: String
+        if isLikelyUUID {
+            // Zoom requires double encoding for UUIDs that begin with `/` or contain `//`.
+            let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))
+            let once = trimmed.addingPercentEncoding(withAllowedCharacters: allowed) ?? trimmed
+            pathSegment = once.addingPercentEncoding(withAllowedCharacters: allowed) ?? once
+        } else {
+            pathSegment = trimmed
+        }
+        let urlString = "https://api.zoom.us/v2/meetings/\(pathSegment)/meeting_summary"
+        guard let url = URL(string: urlString) else {
+            throw GoogleOAuthError.tokenExchangeFailed("Invalid Zoom meeting summary URL")
+        }
+        var request = URLRequest(url: url)
+        request.httpMethod = "GET"
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summary)")
+        }
+        if http.statusCode == 404 || http.statusCode == 204 { return nil }
+        if http.statusCode == 429 {
+            let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
+            let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
+            throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
+        }
+        guard (200..<300).contains(http.statusCode) else {
+            let raw = String(data: data, encoding: .utf8) ?? "Failed to load AI summary"
+            if raw.localizedCaseInsensitiveContains("does not contain scopes") {
+                throw ZoomOAuthError.missingRequiredScope(raw)
+            }
+            throw GoogleOAuthError.tokenExchangeFailed(raw)
+        }
+
+        let iso = ISO8601DateFormatter()
+        iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+        let fallbackISO = ISO8601DateFormatter()
+        fallbackISO.formatOptions = [.withInternetDateTime]
+        let item = try JSONDecoder().decode(ZoomMeetingSummaryAPIItem.self, from: data)
+        return item.toModel(using: iso, fallback: fallbackISO)
+    }
+
     private func makeSettingsView() -> NSView {
         let panel = NSView()
         panel.translatesAutoresizingMaskIntoConstraints = false
@@ -6138,9 +6394,118 @@ class ViewController: NSViewController {
         container.layer?.borderColor = palette.inputBorder.cgColor
         root.addSubview(container)
 
+        // Header row: title / subtitle / count + range selector + refresh.
         let title = makeLabel("Ai Companion", size: 28, color: primaryText, weight: .bold, centered: false)
-        title.translatesAutoresizingMaskIntoConstraints = false
-        container.addSubview(title)
+        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 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)
+
+        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])
+        toolbarRight.orientation = .horizontal
+        toolbarRight.alignment = .centerY
+        toolbarRight.spacing = 10
+        toolbarRight.translatesAutoresizingMaskIntoConstraints = false
+
+        container.addSubview(titleStack)
+        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)
+        status.translatesAutoresizingMaskIntoConstraints = false
+        container.addSubview(status)
+
+        // List scroll view (left column).
+        let listScroll = NSScrollView()
+        listScroll.translatesAutoresizingMaskIntoConstraints = false
+        listScroll.drawsBackground = false
+        listScroll.hasHorizontalScroller = false
+        listScroll.hasVerticalScroller = true
+        listScroll.autohidesScrollers = true
+        listScroll.borderType = .noBorder
+        listScroll.scrollerStyle = .overlay
+        listScroll.automaticallyAdjustsContentInsets = false
+
+        let listClip = TopAlignedClipView()
+        listClip.drawsBackground = false
+        listScroll.contentView = listClip
+
+        let listStack = NSStackView()
+        listStack.translatesAutoresizingMaskIntoConstraints = false
+        listStack.orientation = .vertical
+        listStack.alignment = .leading
+        listStack.spacing = 10
+        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)
+        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)
+
+        let divider = NSView()
+        divider.translatesAutoresizingMaskIntoConstraints = false
+        divider.wantsLayer = true
+        divider.layer?.backgroundColor = palette.inputBorder.cgColor
+        container.addSubview(divider)
 
         NSLayoutConstraint.activate([
             container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
@@ -6148,13 +6513,490 @@ class ViewController: NSViewController {
             container.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
             container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
 
-            title.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
-            title.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
-            title.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -20)
+            titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
+            titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+            titleStack.trailingAnchor.constraint(lessThanOrEqualTo: toolbarRight.leadingAnchor, constant: -16),
+
+            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),
+
+            status.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
+            status.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+            status.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
+
+            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),
+
+            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),
+
+            listEmpty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
+            listEmpty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
+            listEmpty.widthAnchor.constraint(lessThanOrEqualTo: listScroll.widthAnchor, constant: -24),
+
+            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)
         ])
+
+        aiCompanionListScroll = listScroll
+        aiCompanionListStack = listStack
+        aiCompanionDetailScroll = detailScroll
+        aiCompanionDetailStack = detailStack
+        aiCompanionStatusLabel = status
+        aiCompanionCountLabel = count
+        aiCompanionRefreshButton = refresh
+        aiCompanionRangePopup = rangePopup
+        aiCompanionEmptyLabel = listEmpty
+        aiCompanionLoadingIndicator = loadingIndicator
+        aiCompanionDetailEmptyLabel = detailEmpty
+
+        renderAiCompanionList()
+        renderAiCompanionDetail()
         return root
     }
 
+    // MARK: - AI Companion list / detail rendering
+
+    @MainActor
+    private func renderAiCompanionList() {
+        guard let stack = aiCompanionListStack else { return }
+        aiCompanionRowButtons.removeAll()
+        stack.arrangedSubviews.forEach { view in
+            stack.removeArrangedSubview(view)
+            view.removeFromSuperview()
+        }
+
+        let count = aiCompanionSummaries.count
+        aiCompanionCountLabel?.stringValue = count == 1 ? "1 summary" : "\(count) summaries"
+
+        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
+        }
+        aiCompanionEmptyLabel?.isHidden = !showEmpty
+
+        for (idx, summary) in aiCompanionSummaries.enumerated() {
+            let row = makeAiCompanionListRow(summary: summary, index: idx, isSelected: idx == aiCompanionSelectedIndex)
+            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
+
+        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 dateText = aiCompanionDateText(for: summary)
+        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
+
+        button.addSubview(titleLabel)
+        button.addSubview(dateLabel)
+        button.addSubview(badge)
+
+        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),
+
+            dateLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
+            dateLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
+            dateLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
+
+            badge.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 6),
+            badge.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
+            badge.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -10)
+        ])
+
+        aiCompanionRowButtons.append(button)
+        return button
+    }
+
+    @MainActor
+    private func renderAiCompanionDetail() {
+        guard let stack = aiCompanionDetailStack else { return }
+        stack.arrangedSubviews.forEach { view in
+            stack.removeArrangedSubview(view)
+            view.removeFromSuperview()
+        }
+
+        guard isUserLoggedIn() else {
+            aiCompanionDetailEmptyLabel?.stringValue = "Please sign in with Zoom to view your AI notes."
+            aiCompanionDetailEmptyLabel?.isHidden = false
+            return
+        }
+        guard let index = aiCompanionSelectedIndex,
+              aiCompanionSummaries.indices.contains(index) else {
+            aiCompanionDetailEmptyLabel?.stringValue = aiCompanionSummaries.isEmpty
+                ? "Run an AI Companion-enabled meeting to see notes here."
+                : "Select a meeting on the left to view its AI notes."
+            aiCompanionDetailEmptyLabel?.isHidden = false
+            return
+        }
+        aiCompanionDetailEmptyLabel?.isHidden = true
+
+        let summary = aiCompanionSummaries[index]
+
+        let topic = summary.summaryTitle?.isEmpty == false
+            ? summary.summaryTitle!
+            : (summary.meetingTopic?.isEmpty == false ? summary.meetingTopic! : "Untitled meeting")
+        let titleLabel = makeLabel(topic, size: 22, color: primaryText, weight: .bold, centered: false)
+        titleLabel.maximumNumberOfLines = 0
+        titleLabel.lineBreakMode = .byWordWrapping
+        stack.addArrangedSubview(titleLabel)
+
+        let metaText = aiCompanionDateText(for: summary)
+        let metaLabel = makeLabel(metaText, size: 12, color: secondaryText, weight: .regular, centered: false)
+        metaLabel.maximumNumberOfLines = 0
+        stack.addArrangedSubview(metaLabel)
+
+        let actionsRow = NSStackView()
+        actionsRow.orientation = .horizontal
+        actionsRow.spacing = 10
+        actionsRow.alignment = .centerY
+        actionsRow.translatesAutoresizingMaskIntoConstraints = false
+
+        let openDocButton = NSButton(title: "Open in Zoom Docs", target: self, action: #selector(aiCompanionOpenDocTapped))
+        openDocButton.translatesAutoresizingMaskIntoConstraints = false
+        openDocButton.isBordered = false
+        openDocButton.wantsLayer = true
+        openDocButton.layer?.backgroundColor = accentBlue.cgColor
+        openDocButton.layer?.cornerRadius = 10
+        openDocButton.contentTintColor = .white
+        openDocButton.font = .systemFont(ofSize: 12, weight: .semibold)
+        openDocButton.isEnabled = summary.summaryDocURL != nil
+        openDocButton.alphaValue = summary.summaryDocURL != nil ? 1.0 : 0.45
+
+        let copyButton = NSButton(title: "Copy as Markdown", target: self, action: #selector(aiCompanionCopyMarkdownTapped))
+        copyButton.translatesAutoresizingMaskIntoConstraints = false
+        copyButton.isBordered = false
+        copyButton.wantsLayer = true
+        copyButton.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06)).cgColor
+        copyButton.layer?.cornerRadius = 10
+        copyButton.layer?.borderWidth = 1
+        copyButton.layer?.borderColor = palette.inputBorder.cgColor
+        copyButton.contentTintColor = primaryText
+        copyButton.font = .systemFont(ofSize: 12, weight: .semibold)
+
+        [openDocButton, copyButton].forEach { btn in
+            btn.heightAnchor.constraint(equalToConstant: 30).isActive = true
+            btn.widthAnchor.constraint(greaterThanOrEqualToConstant: 130).isActive = true
+        }
+        actionsRow.addArrangedSubview(openDocButton)
+        actionsRow.addArrangedSubview(copyButton)
+        stack.addArrangedSubview(actionsRow)
+
+        aiCompanionOpenDocButton = openDocButton
+        aiCompanionCopyMarkdownButton = copyButton
+
+        if let overview = summary.summaryOverview?.trimmingCharacters(in: .whitespacesAndNewlines), overview.isEmpty == false {
+            stack.addArrangedSubview(makeAiCompanionSectionHeader("Overview"))
+            stack.addArrangedSubview(makeAiCompanionBodyLabel(overview))
+        }
+
+        for section in summary.summaryDetails {
+            let labelText = section.label.isEmpty ? "Details" : section.label
+            stack.addArrangedSubview(makeAiCompanionSectionHeader(labelText))
+            if section.summary.isEmpty == false {
+                stack.addArrangedSubview(makeAiCompanionBodyLabel(section.summary))
+            }
+        }
+
+        if summary.nextSteps.isEmpty == false {
+            stack.addArrangedSubview(makeAiCompanionSectionHeader("Next steps"))
+            let bullets = summary.nextSteps.map { "•  \($0)" }.joined(separator: "\n")
+            stack.addArrangedSubview(makeAiCompanionBodyLabel(bullets))
+        }
+
+        if summary.summaryOverview == nil && summary.summaryDetails.isEmpty && summary.nextSteps.isEmpty,
+           let fullContent = summary.summaryContent?.trimmingCharacters(in: .whitespacesAndNewlines),
+           fullContent.isEmpty == false {
+            stack.addArrangedSubview(makeAiCompanionSectionHeader("Summary"))
+            stack.addArrangedSubview(makeAiCompanionBodyLabel(fullContent))
+        }
+    }
+
+    @MainActor
+    private func makeAiCompanionSectionHeader(_ text: String) -> NSTextField {
+        let label = makeLabel(text.uppercased(), size: 11, color: accentBlue, weight: .bold, centered: false)
+        label.maximumNumberOfLines = 1
+        return label
+    }
+
+    @MainActor
+    private func makeAiCompanionBodyLabel(_ text: String) -> NSTextField {
+        let label = makeLabel(text, size: 13, color: primaryText, weight: .regular, centered: false)
+        label.maximumNumberOfLines = 0
+        label.lineBreakMode = .byWordWrapping
+        label.isSelectable = true
+        return label
+    }
+
+    private func aiCompanionDateText(for summary: AICompanionSummary) -> String {
+        let formatter = DateFormatter()
+        formatter.dateStyle = .medium
+        formatter.timeStyle = .short
+        formatter.timeZone = TimeZone.current
+        let startText = summary.meetingStartTime.map { formatter.string(from: $0) }
+        let endText = summary.meetingEndTime.map { formatter.string(from: $0) }
+        switch (startText, endText) {
+        case let (start?, end?): return "\(start)  →  \(end)"
+        case let (start?, nil): return start
+        case let (nil, end?): return "Ended \(end)"
+        case (nil, nil): return "Date unavailable"
+        }
+    }
+
+    // MARK: - AI Companion data loading & actions
+
+    private func aiCompanionDateRange() -> (from: Date?, to: Date?) {
+        let now = Date()
+        let calendar = Calendar.current
+        let days: Int
+        switch aiCompanionSelectedRange {
+        case "Last 7 days": days = 7
+        case "Last 30 days": days = 30
+        case "Last 90 days": days = 90
+        case "All available": return (nil, nil)
+        default: days = 30
+        }
+        let from = calendar.date(byAdding: .day, value: -days, to: now) ?? now
+        return (from, now)
+    }
+
+    @MainActor
+    private func setAiCompanionLoadingUI(_ loading: Bool) {
+        if loading {
+            aiCompanionLoadingIndicator?.startAnimation(nil)
+        } else {
+            aiCompanionLoadingIndicator?.stopAnimation(nil)
+        }
+        aiCompanionRefreshButton?.isEnabled = !loading
+        aiCompanionRefreshButton?.alphaValue = loading ? 0.55 : 1.0
+        aiCompanionRangePopup?.isEnabled = !loading
+    }
+
+    private func loadAICompanionSummaries(force: Bool) async {
+        guard isUserLoggedIn() else {
+            await MainActor.run {
+                self.aiCompanionSummaries = []
+                self.aiCompanionSelectedIndex = nil
+                self.renderAiCompanionList()
+                self.renderAiCompanionDetail()
+                self.aiCompanionStatusLabel?.stringValue = "Sign in with Zoom to view AI notes."
+            }
+            return
+        }
+        if aiCompanionIsLoading && force == false { return }
+        aiCompanionIsLoading = true
+        await MainActor.run {
+            self.setAiCompanionLoadingUI(true)
+            self.aiCompanionStatusLabel?.stringValue = "Loading AI notes…"
+        }
+        defer {
+            aiCompanionIsLoading = false
+            Task { @MainActor in self.setAiCompanionLoadingUI(false) }
+        }
+
+        do {
+            let token = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
+            let (from, to) = aiCompanionDateRange()
+            let summaries = try await fetchZoomMeetingSummaries(accessToken: token, from: from, to: to)
+            await MainActor.run {
+                self.aiCompanionSummaries = summaries.sorted(by: { lhs, rhs in
+                    (lhs.meetingStartTime ?? .distantPast) > (rhs.meetingStartTime ?? .distantPast)
+                })
+                self.aiCompanionLastLoadedAt = Date()
+                if self.aiCompanionSummaries.isEmpty {
+                    self.aiCompanionSelectedIndex = nil
+                } else {
+                    if let idx = self.aiCompanionSelectedIndex, idx >= self.aiCompanionSummaries.count {
+                        self.aiCompanionSelectedIndex = 0
+                    } else if self.aiCompanionSelectedIndex == nil {
+                        self.aiCompanionSelectedIndex = 0
+                    }
+                }
+                self.renderAiCompanionList()
+                self.renderAiCompanionDetail()
+                if self.aiCompanionSummaries.isEmpty {
+                    self.aiCompanionStatusLabel?.stringValue = "No AI notes found for this range yet."
+                } else {
+                    let formatter = DateFormatter()
+                    formatter.dateStyle = .none
+                    formatter.timeStyle = .short
+                    self.aiCompanionStatusLabel?.stringValue = "Updated \(formatter.string(from: Date()))"
+                }
+            }
+        } catch {
+            await MainActor.run {
+                self.aiCompanionSummaries = []
+                self.aiCompanionSelectedIndex = nil
+                self.renderAiCompanionList()
+                self.renderAiCompanionDetail()
+
+                if case ZoomOAuthError.missingRequiredScope = error {
+                    self.zoomOAuth.clearSavedTokens()
+                    self.aiCompanionStatusLabel?.stringValue = "Zoom permissions are missing. Add meeting:read:summary in your Zoom Marketplace app, then sign in again."
+                } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
+                    let minutes = max(1, Int(ceil(Double(retryAfterSeconds ?? 300) / 60.0)))
+                    self.aiCompanionStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
+                } else if case ZoomOAuthError.missingClientId = error {
+                    self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
+                } else if case ZoomOAuthError.missingClientSecret = error {
+                    self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
+                } else if case ZoomOAuthError.authenticationCancelled = error {
+                    self.aiCompanionStatusLabel?.stringValue = "Sign-in cancelled. Please sign in with Zoom to load AI notes."
+                } else {
+                    self.aiCompanionStatusLabel?.stringValue = "Unable to load AI notes right now. Please try again shortly."
+                }
+            }
+        }
+    }
+
+    private func aiCompanionRefreshIfStale() {
+        let stale: Bool
+        if let lastLoaded = aiCompanionLastLoadedAt {
+            stale = Date().timeIntervalSince(lastLoaded) > 60
+        } else {
+            stale = true
+        }
+        if stale {
+            Task { await self.loadAICompanionSummaries(force: false) }
+        }
+    }
+
+    @objc private func aiCompanionRefreshTapped() {
+        Task { await self.loadAICompanionSummaries(force: true) }
+    }
+
+    @objc private func aiCompanionRangeChanged(_ sender: NSPopUpButton) {
+        aiCompanionSelectedRange = sender.titleOfSelectedItem ?? "Last 30 days"
+        Task { await self.loadAICompanionSummaries(force: true) }
+    }
+
+    @objc private func aiCompanionRowTapped(_ sender: NSButton) {
+        let index = sender.tag
+        guard aiCompanionSummaries.indices.contains(index) else { return }
+        aiCompanionSelectedIndex = index
+        renderAiCompanionList()
+        renderAiCompanionDetail()
+    }
+
+    @objc private func aiCompanionOpenDocTapped() {
+        guard let index = aiCompanionSelectedIndex,
+              aiCompanionSummaries.indices.contains(index),
+              let url = aiCompanionSummaries[index].summaryDocURL else { return }
+        openWebURLPreferringInApp(url)
+    }
+
+    @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."
+    }
+
+    private func aiCompanionMarkdown(for summary: AICompanionSummary) -> String {
+        if let content = summary.summaryContent?.trimmingCharacters(in: .whitespacesAndNewlines),
+           content.isEmpty == false {
+            return content
+        }
+        var lines: [String] = []
+        let title = summary.summaryTitle?.isEmpty == false ? summary.summaryTitle! : (summary.meetingTopic ?? "Meeting summary")
+        lines.append("# \(title)")
+        lines.append("")
+        lines.append("_\(aiCompanionDateText(for: summary))_")
+        lines.append("")
+        if let overview = summary.summaryOverview, overview.isEmpty == false {
+            lines.append("## Overview")
+            lines.append(overview)
+            lines.append("")
+        }
+        for section in summary.summaryDetails {
+            let header = section.label.isEmpty ? "Details" : section.label
+            lines.append("## \(header)")
+            if section.summary.isEmpty == false {
+                lines.append(section.summary)
+            }
+            lines.append("")
+        }
+        if summary.nextSteps.isEmpty == false {
+            lines.append("## Next steps")
+            for step in summary.nextSteps {
+                lines.append("- \(step)")
+            }
+            lines.append("")
+        }
+        if let url = summary.summaryDocURL {
+            lines.append("Full document: \(url.absoluteString)")
+        }
+        return lines.joined(separator: "\n")
+    }
+
     @MainActor
     private func refreshWidgetsPagePremiumState() {
         guard let host = widgetsPageRootView as? NSHostingView<WidgetsRootView> else { return }
@@ -6794,6 +7636,9 @@ class ViewController: NSViewController {
         if isMeetings {
             applyMeetingsPageFilters(resetVisible: false)
         }
+        if isAiCompanion {
+            aiCompanionRefreshIfStale()
+        }
     }
 
     private func sidebarSymbolName(for item: String, filled: Bool = false) -> String {
@@ -7677,10 +8522,13 @@ final class ZoomOAuthService: NSObject {
                 || part == "meeting:read:admin"
                 || part == "meeting:write"
                 || part == "meeting:write:admin"
+                || part == "meeting:read:summary"
+                || part == "meeting:read:summary:admin"
                 || part.contains("meeting:read")
                 || part.contains("meeting:write")
                 || part.contains("list_meetings")
                 || part.contains("list_user_meetings")
+                || part.contains("meeting:read:summary")
         }
     }