diff --git a/Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift b/Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift index c5dc0abe7..c04146986 100644 --- a/Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift +++ b/Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift @@ -20,7 +20,12 @@ extension ClaudeProvider { static func activityCardModelConfiguration() -> ( model: String, reasoningEffort: String? ) { - (model: "claude-sonnet", reasoningEffort: "low") + // Mirrors `transcriptionModelConfiguration` — we always pass + // the user's selected alias to the CLI rather than a hard-coded + // model name. The Settings → Providers tab is what writes this + // preference; the catalog at `ChatCLIModelCatalog` powers the + // picker with the live display names. + (model: ClaudeModelPreference.load().primary.rawValue, reasoningEffort: "low") } func generateActivityCards( diff --git a/Dayflow/Dayflow/Core/AI/ClaudeProvider+Transcription.swift b/Dayflow/Dayflow/Core/AI/ClaudeProvider+Transcription.swift index dc70866e8..efd81cf13 100644 --- a/Dayflow/Dayflow/Core/AI/ClaudeProvider+Transcription.swift +++ b/Dayflow/Dayflow/Core/AI/ClaudeProvider+Transcription.swift @@ -6,7 +6,16 @@ extension ClaudeProvider { static func transcriptionModelConfiguration() -> ( model: String, reasoningEffort: String? ) { - (model: "claude-sonnet", reasoningEffort: "low") + // The Claude CLI's `--model` flag only accepts an alias + // (`sonnet`, `opus`, `fable`, `haiku`, …) or a full model name + // like `claude-fable-5`. Anything else returns "It may not exist + // or you may not have access to it" → exit 1. We persist the + // *alias* in `ClaudeModelPreference` because aliases track the + // latest release of each family automatically — Sonnet today + // becomes Sonnet 5.5 tomorrow without us bumping a stored + // version. The user picks the alias from the Settings → Providers + // tab; we send that exact string to the CLI. + (model: ClaudeModelPreference.load().primary.rawValue, reasoningEffort: "low") } func transcribeScreenshots( diff --git a/Dayflow/Dayflow/Core/AI/LLMService.swift b/Dayflow/Dayflow/Core/AI/LLMService.swift index 00e274c38..5166cc287 100644 --- a/Dayflow/Dayflow/Core/AI/LLMService.swift +++ b/Dayflow/Dayflow/Core/AI/LLMService.swift @@ -158,6 +158,47 @@ final class LLMService: LLMServicing { providerID.providerLabel } + /// Returns the model id the provider should be stamped with on + /// generated cards. Mirrors how `providerLabel` is sourced from the + /// `LLMProviderID`, but goes one level deeper to read the actual model + /// the user has configured (Gemini primary preference, Ollama model id + /// in `UserDefaults`, ChatGPT/Claude CLI model id, etc.). Returns + /// `nil` for providers that don't expose a model concept (Dayflow Pro) + /// or where the user hasn't picked one yet, so the UI badge can fall + /// back to a provider-only label. + private func providerModelId(for providerID: LLMProviderID) -> String? { + switch providerID { + case .gemini: + // `GeminiModelPreference` always carries a primary; reading the + // raw value gives us the user-facing id without touching the + // provider's internal fallback chain. + return GeminiModelPreference.load().primary.rawValue + case .local: + let trimmed = + UserDefaults.standard.string(forKey: "llmLocalModelId")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + case .openAICompatible: + let trimmed = + OpenAICompatiblePreferences.load()?.modelID + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + case .chatGPT: + // Read the user's pick from `CodexModelPreference` (the + // Settings → Providers tab writes this). We pass the raw + // alias through to the picker badge so the user sees the + // model id they selected, not a stale display name. + return CodexModelPreference.load().primary.rawValue + case .claude: + // Read the user's pick from `ClaudeModelPreference` (the + // Settings → Providers tab writes this). The CLI accepts + // these aliases natively — `sonnet` → latest Sonnet release. + return ClaudeModelPreference.load().primary.rawValue + case .dayflow: + return nil + } + } + private func noProviderError() -> NSError { NSError( domain: "LLMService", @@ -838,6 +879,13 @@ final class LLMService: LLMServicing { // Note: card generation log is not persisted per-batch yet // Replace old cards with new ones in the time range + // `activeContext.id` reflects the provider that actually + // produced these cards (primary or fallback), and + // `providerModelId(for:)` reads the user-configured model. We + // stamp both onto every card so the UI can render a + // "Provider · Model" badge without re-running the analysis. + let activeProviderId = activeContext.id.providerLabel + let activeModelId = providerModelId(for: activeContext.id) let (insertedCardIds, deletedVideoPaths) = StorageManager.shared .replaceTimelineCardsInRange( from: windowStartTime, @@ -853,7 +901,9 @@ final class LLMService: LLMServicing { detailedSummary: card.detailedSummary, distractions: card.distractions, appSites: card.appSites, - isBackupGenerated: isBackupGenerated ? true : nil + isBackupGenerated: isBackupGenerated ? true : nil, + providerId: activeProviderId, + modelId: activeModelId ) }, batchId: batchId @@ -937,11 +987,18 @@ final class LLMService: LLMServicing { let batchStartDate = Date(timeIntervalSince1970: TimeInterval(batchStartTs)) let batchEndDate = Date(timeIntervalSince1970: TimeInterval(batchEndTs)) + // Stamp the error card with whichever provider the user has + // configured. We don't have an `activeContext` here because the + // failure could have happened during initialization, so fall + // back to the primary provider's identity — that's the one the + // user is going to want to retry against anyway. let errorCard = createErrorCard( batchId: batchId, batchStartTime: batchStartDate, batchEndTime: batchEndDate, - error: error + error: error, + providerId: primaryProviderID.providerLabel, + modelId: providerModelId(for: primaryProviderID) ) // Replace any existing cards in this time range with the error card @@ -978,7 +1035,8 @@ final class LLMService: LLMServicing { } private func createErrorCard( - batchId: Int64, batchStartTime: Date, batchEndTime: Date, error: Error + batchId: Int64, batchStartTime: Date, batchEndTime: Date, error: Error, + providerId: String?, modelId: String? ) -> TimelineCardShell { let formatter = DateFormatter() formatter.dateFormat = "h:mm a" @@ -1006,7 +1064,9 @@ final class LLMService: LLMServicing { detailedSummary: "Error details: \(error.localizedDescription)\n\nThis recording batch (ID: \(batchId)) failed during AI processing. The original video files are preserved and can be reprocessed by retrying from Settings. Common causes include network issues, API rate limits, or temporary service outages.", distractions: nil, - appSites: nil + appSites: nil, + providerId: providerId, + modelId: modelId ) } diff --git a/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift b/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift index 4a3816b2d..a24c3cac7 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift @@ -3,6 +3,52 @@ import GRDB import Sentry extension StorageManager { + /// Parsed view of a `timeline_cards.metadata` JSON column. Centralized + /// here so every reader (`fetchTimelineCards(forBatch:)`, + /// `fetchTimelineCards(forDay:)`, `fetchTimelineCardsByTimeRange`, + /// etc.) pulls the same field set — important because earlier rows may + /// carry either the new envelope or a legacy bare `[Distraction]` + /// array. + fileprivate struct ParsedTimelineMetadata { + let distractions: [Distraction]? + let appSites: AppSites? + let isBackupGenerated: Bool? + let providerId: String? + let modelId: String? + } + + fileprivate static func parseMetadata( + _ metadataString: String?, using decoder: JSONDecoder + ) -> ParsedTimelineMetadata { + guard + let metadataString, + let jsonData = metadataString.data(using: .utf8) + else { + return ParsedTimelineMetadata( + distractions: nil, appSites: nil, isBackupGenerated: nil, + providerId: nil, modelId: nil) + } + if let meta = try? decoder.decode(TimelineMetadata.self, from: jsonData) { + return ParsedTimelineMetadata( + distractions: meta.distractions, + appSites: meta.appSites, + isBackupGenerated: meta.isBackupGenerated, + providerId: meta.providerId, + modelId: meta.modelId) + } + // Legacy format: the column was a bare [Distraction] array before + // the metadata envelope existed. Keep the distractions, leave + // everything else nil so the UI can render the card correctly. + if let legacy = try? decoder.decode([Distraction].self, from: jsonData) { + return ParsedTimelineMetadata( + distractions: legacy, appSites: nil, isBackupGenerated: nil, + providerId: nil, modelId: nil) + } + return ParsedTimelineMetadata( + distractions: nil, appSites: nil, isBackupGenerated: nil, + providerId: nil, modelId: nil) + } + func saveTimelineCardShell(batchId: Int64, card: TimelineCardShell) -> Int64? { let encoder = JSONEncoder() var lastId: Int64? = nil @@ -84,7 +130,9 @@ extension StorageManager { distractions: card.distractions, appSites: card.appSites, isBackupGenerated: card.isBackupGenerated, - idle: card.idleMetadata + idle: card.idleMetadata, + providerId: card.providerId, + modelId: card.modelId ) let metadataString: String? = (try? encoder.encode(meta)).flatMap { String(data: $0, encoding: .utf8) @@ -248,7 +296,13 @@ extension StorageManager { distractions: nil, appSites: AppSites(primary: "dayflow.so", secondary: nil), isBackupGenerated: nil, - idle: nil + idle: nil, + // Onboarding cards are static — they're written by the app to + // give the user a sample card on first launch, not produced by + // any LLM. Leaving provider/model nil keeps the UI badge hidden + // so the user doesn't see a misleading "Powered by …" label. + providerId: nil, + modelId: nil ) let metadataString: String? = (try? encoder.encode(meta)).flatMap { String(data: $0, encoding: .utf8) @@ -334,20 +388,7 @@ extension StorageManager { ORDER BY start ASC """, arguments: [batchId] ).map { row in - var distractions: [Distraction]? = nil - var appSites: AppSites? = nil - var isBackupGenerated: Bool? = nil - if let metadataString: String = row["metadata"], - let jsonData = metadataString.data(using: .utf8) - { - if let meta = try? decoder.decode(TimelineMetadata.self, from: jsonData) { - distractions = meta.distractions - appSites = meta.appSites - isBackupGenerated = meta.isBackupGenerated - } else if let legacy = try? decoder.decode([Distraction].self, from: jsonData) { - distractions = legacy - } - } + let meta = Self.parseMetadata(row["metadata"], using: decoder) return TimelineCard( recordId: row["id"], batchId: batchId, @@ -359,11 +400,13 @@ extension StorageManager { summary: row["summary"], detailedSummary: row["detailed_summary"], day: row["day"], - distractions: distractions, + distractions: meta.distractions, videoSummaryURL: row["video_summary_url"], otherVideoSummaryURLs: nil, - appSites: appSites, - isBackupGenerated: isBackupGenerated + appSites: meta.appSites, + isBackupGenerated: meta.isBackupGenerated, + providerId: meta.providerId, + modelId: meta.modelId ) } }) ?? [] @@ -447,20 +490,7 @@ extension StorageManager { ) .map { row in // Decode metadata JSON (supports object or legacy array) - var distractions: [Distraction]? = nil - var appSites: AppSites? = nil - var isBackupGenerated: Bool? = nil - if let metadataString: String = row["metadata"], - let jsonData = metadataString.data(using: .utf8) - { - if let meta = try? decoder.decode(TimelineMetadata.self, from: jsonData) { - distractions = meta.distractions - appSites = meta.appSites - isBackupGenerated = meta.isBackupGenerated - } else if let legacy = try? decoder.decode([Distraction].self, from: jsonData) { - distractions = legacy - } - } + let meta = Self.parseMetadata(row["metadata"], using: decoder) // Create TimelineCard instance using renamed columns return TimelineCard( @@ -474,11 +504,13 @@ extension StorageManager { summary: row["summary"], detailedSummary: row["detailed_summary"], day: row["day"], - distractions: distractions, + distractions: meta.distractions, videoSummaryURL: row["video_summary_url"], otherVideoSummaryURLs: nil, - appSites: appSites, - isBackupGenerated: isBackupGenerated + appSites: meta.appSites, + isBackupGenerated: meta.isBackupGenerated, + providerId: meta.providerId, + modelId: meta.modelId ) } } @@ -508,20 +540,7 @@ extension StorageManager { ) .map { row in // Decode metadata JSON (supports object or legacy array) - var distractions: [Distraction]? = nil - var appSites: AppSites? = nil - var isBackupGenerated: Bool? = nil - if let metadataString: String = row["metadata"], - let jsonData = metadataString.data(using: .utf8) - { - if let meta = try? decoder.decode(TimelineMetadata.self, from: jsonData) { - distractions = meta.distractions - appSites = meta.appSites - isBackupGenerated = meta.isBackupGenerated - } else if let legacy = try? decoder.decode([Distraction].self, from: jsonData) { - distractions = legacy - } - } + let meta = Self.parseMetadata(row["metadata"], using: decoder) // Create TimelineCard instance using renamed columns return TimelineCard( @@ -535,11 +554,13 @@ extension StorageManager { summary: row["summary"], detailedSummary: row["detailed_summary"], day: row["day"], - distractions: distractions, + distractions: meta.distractions, videoSummaryURL: row["video_summary_url"], otherVideoSummaryURLs: nil, - appSites: appSites, - isBackupGenerated: isBackupGenerated + appSites: meta.appSites, + isBackupGenerated: meta.isBackupGenerated, + providerId: meta.providerId, + modelId: meta.modelId ) } } @@ -875,12 +896,16 @@ extension StorageManager { // Insert new cards for card in newCards { - // Encode metadata object with distractions and appSites + // Encode metadata object with distractions, appSites, and the + // provider/model info so the UI can render a "powered by" badge + // on each card without re-deriving it. let meta = TimelineMetadata( distractions: card.distractions, appSites: card.appSites, isBackupGenerated: card.isBackupGenerated, - idle: card.idleMetadata + idle: card.idleMetadata, + providerId: card.providerId, + modelId: card.modelId ) let metadataString: String? = (try? encoder.encode(meta)).flatMap { String(data: $0, encoding: .utf8) diff --git a/Dayflow/Dayflow/Core/Recording/StorageModels.swift b/Dayflow/Dayflow/Core/Recording/StorageModels.swift index 6a8b6deac..37b2735bf 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageModels.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageModels.swift @@ -115,6 +115,15 @@ struct TimelineCard: Codable, Sendable, Identifiable { let otherVideoSummaryURLs: [String]? // For merged cards, subsequent video URLs let appSites: AppSites? let isBackupGenerated: Bool? + /// Stable provider id of the model that produced this card + /// (e.g. "gemini", "ollama", "chatgpt", "claude", "dayflow", + /// "openai_compatible"). Older saved cards don't carry this — leave + /// `nil` so the UI can hide the badge instead of showing a stale value. + let providerId: String? + /// Model id chosen by the user (e.g. "gemini-3.5-flash", + /// "MiniMax-M3", "gpt-5.6-luna", "claude-sonnet-4.5"). Optional for + /// the same reason as `providerId`. + let modelId: String? init( id: UUID = UUID(), @@ -132,7 +141,9 @@ struct TimelineCard: Codable, Sendable, Identifiable { videoSummaryURL: String?, otherVideoSummaryURLs: [String]?, appSites: AppSites?, - isBackupGenerated: Bool? = nil + isBackupGenerated: Bool? = nil, + providerId: String? = nil, + modelId: String? = nil ) { self.id = id self.recordId = recordId @@ -150,6 +161,8 @@ struct TimelineCard: Codable, Sendable, Identifiable { self.otherVideoSummaryURLs = otherVideoSummaryURLs self.appSites = appSites self.isBackupGenerated = isBackupGenerated + self.providerId = providerId + self.modelId = modelId } } @@ -234,6 +247,14 @@ struct TimelineCardShell: Sendable { let appSites: AppSites? let isBackupGenerated: Bool? let idleMetadata: IdleCardMetadata? + /// Stable provider id of the model that produced this card + /// (e.g. "gemini", "ollama", "chatgpt", "claude", "dayflow"). + /// Optional because older shells (e.g. error / idle paths) don't know + /// who produced them; the UI simply omits the badge when nil. + let providerId: String? + /// Model id of the producing model (e.g. "gemini-3.5-flash"). + /// Optional for the same reason as `providerId`. + let modelId: String? // No videoSummaryURL here, as it's added later // No batchId here, as it's passed as a separate parameter to the save function @@ -248,7 +269,9 @@ struct TimelineCardShell: Sendable { distractions: [Distraction]?, appSites: AppSites?, isBackupGenerated: Bool? = nil, - idleMetadata: IdleCardMetadata? = nil + idleMetadata: IdleCardMetadata? = nil, + providerId: String? = nil, + modelId: String? = nil ) { self.startTimestamp = startTimestamp self.endTimestamp = endTimestamp @@ -261,6 +284,8 @@ struct TimelineCardShell: Sendable { self.appSites = appSites self.isBackupGenerated = isBackupGenerated self.idleMetadata = idleMetadata + self.providerId = providerId + self.modelId = modelId } } @@ -285,6 +310,14 @@ struct TimelineMetadata: Codable { let appSites: AppSites? let isBackupGenerated: Bool? let idle: IdleCardMetadata? + /// Stable provider id (e.g. "gemini", "ollama", "chatgpt", "claude", + /// "dayflow", "openai_compatible"). Optional because the column has + /// carried this JSON since before this field existed, so any row + /// written earlier than the migration will simply omit it. + let providerId: String? + /// Model id chosen by the user (e.g. "gemini-3.5-flash", + /// "gpt-5.6-luna", "claude-sonnet"). Optional for the same reason. + let modelId: String? } struct AnalysisBatchDebugEntry: Sendable { diff --git a/Dayflow/Dayflow/Views/UI/CanvasActivityCard.swift b/Dayflow/Dayflow/Views/UI/CanvasActivityCard.swift index 87db20c8e..a68828ed6 100644 --- a/Dayflow/Dayflow/Views/UI/CanvasActivityCard.swift +++ b/Dayflow/Dayflow/Views/UI/CanvasActivityCard.swift @@ -19,6 +19,11 @@ struct CanvasActivityCard: View { let isSelected: Bool let isSystemCategory: Bool let isBackupGenerated: Bool + /// Compact "Provider · Model" string shown in the card footer so the + /// user can see at a glance which model produced this card (e.g. + /// "Gemini · 3.5 Flash", "Claude · Sonnet"). Optional because older + /// saved cards don't carry the metadata; when nil the badge is hidden. + let providerBadge: String? let onTap: () -> Void // Raw values for pattern matching (may contain paths) let faviconPrimaryRaw: String? @@ -146,19 +151,29 @@ struct CanvasActivityCard: View { Spacer() - HStack(spacing: 6) { - if isBackupGenerated { - backupIndicator - } + VStack(alignment: .trailing, spacing: 2) { + HStack(spacing: 6) { + if isBackupGenerated { + backupIndicator + } - Text(time) - .font( - Font.custom("Figtree", size: secondaryFontSize) - .weight(.medium) - ) - .foregroundColor(style.time) - .lineLimit(1) - .truncationMode(.tail) + Text(time) + .font( + Font.custom("Figtree", size: secondaryFontSize) + .weight(.medium) + ) + .foregroundColor(style.time) + .lineLimit(1) + .truncationMode(.tail) + } + if let badge = providerBadge, !badge.isEmpty { + Text(badge) + .font(.custom("Figtree", size: 9)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .help("Produced by \(badge)") + } } } } diff --git a/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift b/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift index 868f5d5b3..3e07565ca 100644 --- a/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift +++ b/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift @@ -109,6 +109,7 @@ struct CanvasTimelineDataView: View { // between triggers and keeps the body's inline closures tiny (fixes a // Swift type-checker timeout that appeared when each closure inlined its // own copy of this calculation). + private func nowCenteredTargetHourIndex() -> Int { let currentHour = Calendar.current.component(.hour, from: Date()) let hoursSince4AM = currentHour >= 4 ? currentHour - 4 : (24 - 4) + currentHour @@ -331,6 +332,7 @@ struct CanvasTimelineDataView: View { isSystemCategory: item.categoryName.trimmingCharacters(in: .whitespacesAndNewlines) .caseInsensitiveCompare("System") == .orderedSame, isBackupGenerated: item.activity.isBackupGenerated == true, + providerBadge: item.activity.providerBadge, onTap: { if selectedCardId == item.id { clearSelection() diff --git a/Dayflow/Dayflow/Views/UI/MainView/ActivityCard.swift b/Dayflow/Dayflow/Views/UI/MainView/ActivityCard.swift index 69a613140..75634558b 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/ActivityCard.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/ActivityCard.swift @@ -206,6 +206,35 @@ struct ActivityCard: View { ) } + // "Produced by …" badge — mirrors the badge shown on the + // timeline card. Surfaces the provider/model that + // generated this card so users can verify which AI + // actually wrote the summary. Hidden when the metadata + // is missing (older cards) or the card is a system + // "Processing failed" error card (provider info is + // surfaced through the retry flow instead). + if !isFailedCard(activity), let providerBadge = activity.providerBadge { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(Color(red: 0.45, green: 0.45, blue: 0.45)) + Text(providerBadge) + .font(Font.custom("Figtree", size: 11)) + .foregroundColor(Color(red: 0.4, green: 0.4, blue: 0.4)) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(red: 0.96, green: 0.96, blue: 0.95).opacity(0.9)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .inset(by: 0.25) + .stroke(Color(red: 0.88, green: 0.88, blue: 0.88), lineWidth: 0.5) + ) + .help("Produced by \(providerBadge)") + } + if !isFailedCard(activity) { Button(action: { withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) { diff --git a/Dayflow/Dayflow/Views/UI/MainView/Layout.swift b/Dayflow/Dayflow/Views/UI/MainView/Layout.swift index e61f5b071..1879254ef 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/Layout.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/Layout.swift @@ -222,6 +222,11 @@ extension MainView { } updateCardsToReviewCount() loadWeeklyTrackedMinutes() + // Re-evaluate the screen-recording permission notice whenever the user + // lands on the timeline. The notice is a session-dismissible toast and + // won't re-show once `didDismissScreenRecordingPermissionNoticeThisSession` + // is true, so this is safe to call on every timeline entry. + showScreenRecordingNoticeIfNeeded() } else { showTimelineReview = false } @@ -315,7 +320,16 @@ extension MainView { showScreenRecordingPermissionNotice = false return } - guard AppState.shared.getSavedPreference() == true || appState.isRecording else { return } + + // Show the notice once onboarding is finished and the user has lost + // (or never granted) screen-recording access. The previous guard also + // required `getSavedPreference() == true || appState.isRecording`, but + // `isRecording` is forced off the moment the recorder detects the + // permission is missing, and `getSavedPreference()` is `nil` for users + // who have never explicitly toggled it — so the notice never surfaced + // for exactly the case we need to alert on. + let didOnboard = UserDefaults.standard.bool(forKey: "didOnboard") + guard didOnboard else { return } withAnimation(.spring(response: 0.28, dampingFraction: 0.9)) { showScreenRecordingPermissionNotice = true diff --git a/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift b/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift index ae9215c8a..129f59a9d 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift @@ -175,7 +175,9 @@ enum TimelineActivityLoader { videoSummaryURL: card.videoSummaryURL, screenshot: nil, appSites: card.appSites, - isBackupGenerated: card.isBackupGenerated + isBackupGenerated: card.isBackupGenerated, + providerId: card.providerId, + modelId: card.modelId ) ) } diff --git a/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift b/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift index 6154718aa..a5ab0583f 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift @@ -136,7 +136,9 @@ private struct WeekTimelineHoverPrototypeHarness: View { videoSummaryURL: nil, screenshot: nil, appSites: spec.favicon.map { AppSites(primary: $0, secondary: nil) }, - isBackupGenerated: false + isBackupGenerated: false, + providerId: nil, + modelId: nil ) let yPos = CGFloat(spec.startMinutes) * ppm + 1 diff --git a/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift b/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift index 457c6c2ea..40091d8db 100644 --- a/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift +++ b/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift @@ -10,6 +10,87 @@ import SwiftUI /// Represents an activity in the timeline view struct TimelineActivity: Identifiable { + + /// Compact "Provider · Model" string the UI can render directly next + /// to a card (timeline list or detail panel). Returns `nil` when both + /// the provider id and model id are missing — i.e. the card was saved + /// before this metadata existed — so callers can simply hide the + /// badge instead of inventing a placeholder. + /// + /// Provider ids stored in `metadata` are stable enum raw values + /// (`gemini`, `ollama`, `chatgpt`, `claude`, `dayflow`, + /// `openai_compatible`) so we map them to human-friendly labels here + /// rather than in storage. Model ids are passed through verbatim — + /// those are the exact strings the user picked in Settings and want to + /// see confirmed. For Claude/Codex the user picks an *alias* (e.g. + /// `sonnet`, `opus`, `gpt-5.6-luna`); we map the alias to a friendly + /// display name when we recognise it, and fall back to the raw + /// string otherwise so a new alias we haven't catalogued yet still + /// surfaces something useful. + var providerBadge: String? { + let rawProvider = (providerId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let rawModel = (modelId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if rawProvider.isEmpty && rawModel.isEmpty { return nil } + + let providerLabel: String + switch rawProvider { + case "gemini": + providerLabel = "Gemini" + case "local", "ollama": + providerLabel = "Local" + case "chatgpt": + providerLabel = "ChatGPT" + case "claude": + providerLabel = "Claude" + case "openai_compatible": + providerLabel = "OpenAI" + case "dayflow": + providerLabel = "Dayflow Pro" + case "chatgpt_claude": + // Legacy shared id used before ChatGPT and Claude were split. + // Disambiguate from the model name so users still see the + // specific tool. + let lowered = rawModel.lowercased() + if lowered.contains("claude") { + providerLabel = "Claude" + } else if lowered.contains("gpt") || lowered.contains("codex") { + providerLabel = "ChatGPT" + } else { + providerLabel = "AI" + } + case "": + providerLabel = "AI" + default: + providerLabel = rawProvider + } + + if rawModel.isEmpty { return providerLabel } + let displayModel = Self.prettyModelName(providerId: rawProvider, alias: rawModel) + return "\(providerLabel) · \(displayModel)" + } + + /// Maps a known Chat CLI alias to a friendlier display name for the + /// card badge. Falls back to the raw alias when we don't recognise + /// it — that keeps the badge useful when a new alias lands before + /// Dayflow is updated. + private static func prettyModelName(providerId: String, alias: String) -> String { + let trimmed = alias.trimmingCharacters(in: .whitespacesAndNewlines) + switch providerId { + case "claude": + if let parsed = ClaudeModel(rawValue: trimmed) { + return parsed.displayName + } + return trimmed + case "chatgpt": + if let parsed = CodexModel(rawValue: trimmed) { + return parsed.displayName + } + return trimmed + default: + return trimmed + } + } + let id: String let recordId: Int64? let batchId: Int64? // Tracks source batch for retry functionality @@ -25,6 +106,14 @@ struct TimelineActivity: Identifiable { let screenshot: NSImage? let appSites: AppSites? let isBackupGenerated: Bool? + /// Which Dayflow provider produced this activity (e.g. "gemini", + /// "ollama", "chatgpt", "claude", "dayflow", "openai_compatible"). + /// Optional because older saved cards don't carry the metadata. + let providerId: String? + /// Which model the provider used (e.g. "gemini-3.5-flash", + /// "gpt-5.6-luna", "claude-sonnet-4.5"). Optional for the same + /// reason as `providerId`. + let modelId: String? static func stableId( recordId: Int64?, batchId: Int64?, startTime: Date, endTime: Date, title: String, @@ -64,7 +153,9 @@ struct TimelineActivity: Identifiable { videoSummaryURL: videoSummaryURL, screenshot: screenshot, appSites: appSites, - isBackupGenerated: isBackupGenerated + isBackupGenerated: isBackupGenerated, + providerId: providerId, + modelId: modelId ) } @@ -84,7 +175,9 @@ struct TimelineActivity: Identifiable { videoSummaryURL: videoSummaryURL, screenshot: screenshot, appSites: appSites, - isBackupGenerated: isBackupGenerated + isBackupGenerated: isBackupGenerated, + providerId: providerId, + modelId: modelId ) } @@ -104,7 +197,9 @@ struct TimelineActivity: Identifiable { videoSummaryURL: newVideoSummaryURL, screenshot: screenshot, appSites: appSites, - isBackupGenerated: isBackupGenerated + isBackupGenerated: isBackupGenerated, + providerId: providerId, + modelId: modelId ) } } diff --git a/Dayflow/Dayflow/Views/UI/TimelineReviewCard.swift b/Dayflow/Dayflow/Views/UI/TimelineReviewCard.swift index dd10aeac6..a0765bbc9 100644 --- a/Dayflow/Dayflow/Views/UI/TimelineReviewCard.swift +++ b/Dayflow/Dayflow/Views/UI/TimelineReviewCard.swift @@ -106,6 +106,26 @@ struct TimelineReviewCard: View { HStack(alignment: .center) { TimelineReviewCategoryPill(name: activity.category, color: categoryColor) + + // Subtle "produced by" chip next to the category pill so + // users in the review flow also know which provider/model + // wrote the summary. Hidden when the metadata is missing. + if let providerBadge = activity.providerBadge { + HStack(spacing: 4) { + Image(systemName: "sparkles") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(Color(hex: "707070")) + Text(providerBadge) + .font(.custom("Figtree", size: 11).weight(.medium)) + .foregroundColor(Color(hex: "707070")) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color(hex: "F4F0ED")) + .cornerRadius(10) + } + Spacer() TimelineReviewTimeRangePill(timeRange: timeRangeText) } diff --git a/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift b/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift index c05356e91..5cb9752b0 100644 --- a/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift +++ b/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift @@ -235,7 +235,9 @@ func makeTimelineActivities(from cards: [TimelineCard], for date: Date) videoSummaryURL: card.videoSummaryURL, screenshot: nil, appSites: card.appSites, - isBackupGenerated: card.isBackupGenerated + isBackupGenerated: card.isBackupGenerated, + providerId: card.providerId, + modelId: card.modelId )) } diff --git a/Dayflow/DayflowTests/CodexClaudeProviderTests.swift b/Dayflow/DayflowTests/CodexClaudeProviderTests.swift index 90031a7c5..4dfd9227c 100644 --- a/Dayflow/DayflowTests/CodexClaudeProviderTests.swift +++ b/Dayflow/DayflowTests/CodexClaudeProviderTests.swift @@ -41,17 +41,29 @@ final class CodexClaudeProviderTests: XCTestCase { ) } - func testClaudeActivityCardsUseSonnetSlugAtLowEffort() { + func testClaudeActivityCardsUseSonnetAliasAtLowEffort() { let configuration = ClaudeProvider.activityCardModelConfiguration() - XCTAssertEqual(configuration.model, "claude-sonnet") + // Default for the Claude picker is the `sonnet` alias (which the + // Claude CLI resolves to the current Sonnet release on the + // user's account). The previous hard-coded value was + // `claude-sonnet`, which the CLI rejects with "It may not + // exist or you may not have access to it" — see the comment + // on `ClaudeProvider.activityCardModelConfiguration`. + XCTAssertEqual(configuration.model, "sonnet") XCTAssertEqual(configuration.reasoningEffort, "low") } - func testChatGPTActivityCardsUseGPT56SolAtLowEffort() { + func testChatGPTActivityCardsUseUserPickedModelAtLowEffort() { let configuration = CodexProvider.activityCardModelConfiguration() - XCTAssertEqual(configuration.model, "gpt-5.6-sol") + // The picker drives both `transcriptionModelConfiguration` and + // `activityCardModelConfiguration` — they read the same + // `CodexModelPreference`. The default is `gpt-5.6-luna` (the + // transcription-tuned variant). The previous hard-coded value + // was `gpt-5.6-sol`; that alias is still selectable from the + // picker, just no longer the default. + XCTAssertEqual(configuration.model, "gpt-5.6-luna") XCTAssertEqual(configuration.reasoningEffort, "low") } @@ -109,10 +121,10 @@ final class CodexClaudeProviderTests: XCTestCase { ) } - func testClaudeTranscriptionUsesSonnetSlugAtLowEffort() { + func testClaudeTranscriptionUsesSonnetAliasAtLowEffort() { let configuration = ClaudeProvider.transcriptionModelConfiguration() - XCTAssertEqual(configuration.model, "claude-sonnet") + XCTAssertEqual(configuration.model, "sonnet") XCTAssertEqual(configuration.reasoningEffort, "low") } diff --git a/Dayflow/DayflowTests/TimelineActivityLoaderTests.swift b/Dayflow/DayflowTests/TimelineActivityLoaderTests.swift index 77d09f8a4..bc100bead 100644 --- a/Dayflow/DayflowTests/TimelineActivityLoaderTests.swift +++ b/Dayflow/DayflowTests/TimelineActivityLoaderTests.swift @@ -74,7 +74,9 @@ final class TimelineActivityLoaderTests: XCTestCase { videoSummaryURL: nil, screenshot: nil, appSites: nil, - isBackupGenerated: false + isBackupGenerated: false, + providerId: nil, + modelId: nil ) }