Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 65 additions & 5 deletions Sources/UI/Settings/HomeMeetingSummaryBetaPresentationPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,76 @@ enum HomeMeetingSummaryBetaPresentationPolicy {
}
}

enum HomeLocalSummaryNoticeKind: Equatable {
case saved(chunkCount: Int)
case failed(message: String)
}

struct HomeLocalSummaryNotice: Identifiable, Equatable {
let id = UUID()
let transcriptURL: URL
let chunkCount: Int
let meetingTitle: String
let kind: HomeLocalSummaryNoticeKind

init(transcriptURL: URL, meetingTitle: String, chunkCount: Int) {
self.transcriptURL = transcriptURL
self.meetingTitle = meetingTitle
self.kind = .saved(chunkCount: chunkCount)
}

init(transcriptURL: URL, meetingTitle: String, failureMessage: String) {
self.transcriptURL = transcriptURL
self.meetingTitle = meetingTitle
self.kind = .failed(message: Self.normalizedFailureMessage(failureMessage))
}

var title: String {
isFailure ? "AI summary failed" : "AI summary saved"
}

var status: String {
switch kind {
case .saved:
return "Saved"
case .failed:
return "Needs retry"
}
}

var actionTitle: String {
isFailure ? "Retry summary" : "Open enhanced transcript"
}

var shouldAutoDismiss: Bool {
!isFailure
}

var isFailure: Bool {
if case .failed = kind { return true }
return false
}

var title: String { "AI summary saved" }
var status: String { "Saved" }
var detail: String {
let passText = chunkCount == 1 ? "one local Gemma pass" : "\(chunkCount) local Gemma passes"
return "The meeting Markdown was enhanced with a generated title and summary preview using \(passText)."
switch kind {
case .saved(let chunkCount):
let passText = chunkCount == 1 ? "one local Gemma pass" : "\(chunkCount) local Gemma passes"
return "The meeting Markdown was enhanced with a generated title and summary preview using \(passText)."
case .failed(let message):
return "The local AI summary did not finish: \(message) Nothing was changed. Retry when setup is ready."
}
}

private static func normalizedFailureMessage(_ raw: String) -> String {
let message = raw
.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")
let fallback = message.isEmpty ? "Check Beta setup, then try again." : message
let limit = 180
guard fallback.count > limit else { return fallback }
let shortened = String(fallback.prefix(limit))
.trimmingCharacters(in: .whitespacesAndNewlines)
return "\(shortened)..."
}
}

Expand Down
76 changes: 54 additions & 22 deletions Sources/UI/Settings/TranscriptedSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -499,17 +499,27 @@ struct TranscriptedSettingsView: View {

if let notice = homeLocalSummaryNotice {
SettingsActivityCard(
symbolName: "sparkles",
symbolName: notice.isFailure ? "exclamationmark.circle.fill" : "sparkles",
title: notice.title,
status: notice.status,
detail: notice.detail,
tone: .success,
tone: notice.isFailure ? .caution : .success,
progress: nil,
actionTitle: "Open enhanced transcript",
actionTitle: notice.actionTitle,
action: {
trackSettingsAction("open_local_meeting_summary_notice", page: .home)
clearHomeLocalSummaryNotice(id: notice.id)
NSWorkspace.shared.open(notice.transcriptURL)
if notice.isFailure {
trackSettingsAction("retry_local_meeting_summary_notice", page: .home)
clearHomeLocalSummaryNotice(id: notice.id)
generateLocalSummary(
transcriptURL: notice.transcriptURL,
title: notice.meetingTitle,
hasExistingSummary: false
)
} else {
trackSettingsAction("open_local_meeting_summary_notice", page: .home)
clearHomeLocalSummaryNotice(id: notice.id)
NSWorkspace.shared.open(notice.transcriptURL)
}
}
)
.transition(.move(edge: .top).combined(with: .opacity))
Expand Down Expand Up @@ -824,8 +834,21 @@ struct TranscriptedSettingsView: View {
}

private func generateLocalSummary(for item: RecentMeetingItem) {
generateLocalSummary(
transcriptURL: item.transcriptURL,
title: item.title,
hasExistingSummary: item.summaryPreview != nil
)
}

private func generateLocalSummary(
transcriptURL: URL,
title: String,
hasExistingSummary: Bool
) {
guard localMeetingSummariesEnabled else { return }
guard homeLocalSummaryTasks[item.id] == nil else { return }
let summaryID = transcriptURL.path
guard homeLocalSummaryTasks[summaryID] == nil else { return }
if let unavailableReason = localMeetingSummaryUnavailableReason {
homeDeleteFailure = HomeDeleteFailure(
title: "Could not summarize meeting",
Expand All @@ -840,38 +863,38 @@ struct TranscriptedSettingsView: View {
message: "\(provider.title) meeting summary started",
context: [
"provider": provider.rawValue,
"has_existing_summary": item.summaryPreview == nil ? "false" : "true",
"has_existing_summary": hasExistingSummary ? "true" : "false",
"setup_ready": selectedLocalSummaryProviderIsReady ? "true" : "false",
"setup_profile": selectedLocalSummaryProviderProfileName,
]
)
clearHomeLocalSummaryNotice()
homeLocalSummaryJobIDs.insert(item.id)
homeLocalSummaryJobIDs.insert(summaryID)

let task = Task.detached(priority: .utility) {
switch provider {
case .gemmaMLX:
return try await LocalMeetingSummarizer().summarize(
transcriptURL: item.transcriptURL,
title: item.title
transcriptURL: transcriptURL,
title: title
)
case .appleFoundation:
return try await AppleFoundationMeetingSummarizer().summarize(
transcriptURL: item.transcriptURL,
title: item.title
transcriptURL: transcriptURL,
title: title
)
}
}
let taskToken = UUID()
homeLocalSummaryTasks[item.id] = task
homeLocalSummaryTaskTokens[item.id] = taskToken
homeLocalSummaryTasks[summaryID] = task
homeLocalSummaryTaskTokens[summaryID] = taskToken

Task { @MainActor in
defer {
if homeLocalSummaryTaskTokens[item.id] == taskToken {
homeLocalSummaryJobIDs.remove(item.id)
homeLocalSummaryTasks[item.id] = nil
homeLocalSummaryTaskTokens[item.id] = nil
if homeLocalSummaryTaskTokens[summaryID] == taskToken {
homeLocalSummaryJobIDs.remove(summaryID)
homeLocalSummaryTasks[summaryID] = nil
homeLocalSummaryTaskTokens[summaryID] = nil
}
}

Expand All @@ -880,6 +903,7 @@ struct TranscriptedSettingsView: View {
guard localMeetingSummariesEnabled else { return }
presentHomeLocalSummaryNotice(HomeLocalSummaryNotice(
transcriptURL: result.transcriptURL,
meetingTitle: title,
chunkCount: result.chunkCount
))
recordLocalSummaryEvent(
Expand Down Expand Up @@ -912,9 +936,13 @@ struct TranscriptedSettingsView: View {
"error": error.localizedDescription,
]
)
homeDeleteFailure = HomeDeleteFailure(
title: "Could not summarize meeting",
message: error.localizedDescription
NSSound.beep()
presentHomeLocalSummaryNotice(
HomeLocalSummaryNotice(
transcriptURL: transcriptURL,
meetingTitle: title,
failureMessage: error.localizedDescription
)
)
}
}
Expand Down Expand Up @@ -3070,6 +3098,10 @@ struct TranscriptedSettingsView: View {
private func presentHomeLocalSummaryNotice(_ notice: HomeLocalSummaryNotice) {
homeLocalSummaryNotice = notice
homeLocalSummaryNoticeDismissTask?.cancel()
guard notice.shouldAutoDismiss else {
homeLocalSummaryNoticeDismissTask = nil
return
}
homeLocalSummaryNoticeDismissTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: HomeLocalSummaryNoticeDismissalPolicy.autoDismissDelayNanoseconds)
guard !Task.isCancelled,
Expand Down
29 changes: 29 additions & 0 deletions Tests/HomeMeetingSummaryBetaPresentationPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,22 @@ func testHomeMeetingSummaryBetaPresentationPolicy() {
runSuite("HomeLocalSummaryNoticeDismissalPolicy clears only the scheduled notice") {
let firstNotice = HomeLocalSummaryNotice(
transcriptURL: URL(fileURLWithPath: "/tmp/first.md"),
meetingTitle: "First",
chunkCount: 1
)
let newerNotice = HomeLocalSummaryNotice(
transcriptURL: URL(fileURLWithPath: "/tmp/newer.md"),
meetingTitle: "Newer",
chunkCount: 2
)

assertEqual(firstNotice.status, "Saved", "completed summary notices should read as saved, not ongoing")
assertEqual(
firstNotice.actionTitle,
"Open enhanced transcript",
"saved summaries should point to the enhanced transcript"
)
assertTrue(firstNotice.shouldAutoDismiss, "saved summaries should clear themselves after a short scan window")
assertTrue(
HomeLocalSummaryNoticeDismissalPolicy.autoDismissDelayNanoseconds >= 4_000_000_000,
"success notice should stay visible long enough to scan"
Expand All @@ -103,6 +111,27 @@ func testHomeMeetingSummaryBetaPresentationPolicy() {
"an old dismissal timer should not clear a newer summary notice"
)
}

runSuite("HomeLocalSummaryNotice keeps failed summaries visible with retry copy") {
let notice = HomeLocalSummaryNotice(
transcriptURL: URL(fileURLWithPath: "/tmp/failed.md"),
meetingTitle: "Failed Setup",
failureMessage: "model load failed:\nmissing model cache"
)

assertEqual(notice.title, "AI summary failed", "failure notice should be explicit")
assertEqual(notice.status, "Needs retry", "failure notice should not look complete")
assertEqual(notice.actionTitle, "Retry summary", "failure notice should offer retry")
assertFalse(notice.shouldAutoDismiss, "failure notice should persist until the user acts")
assertTrue(
notice.detail.contains("model load failed: missing model cache"),
"failure detail should collapse noisy line breaks into readable copy"
)
assertTrue(
notice.detail.contains("Nothing was changed"),
"failure detail should reassure the user that the transcript was not rewritten"
)
}
}

private func sampleHomeMeetingSummaryBetaItem(
Expand Down
Loading