import Foundation import SwiftUI import Combine @MainActor final class AppState: ObservableObject { static let shared = AppState() // Island state @Published var mode: IslandMode = .hidden @Published var view: IslandView = .overview { didSet { // Any way into the chat (drop → "Задать вопрос", shortcuts…) lands in a brov-chat tab. if view == .prompt, tabs.first(where: { $0.id == activeTabId })?.kind != .chat { if let chat = tabs.first(where: { $0.kind == .chat }) { if let cur = activeTabId, tabs.first(where: { $0.id == cur })?.kind == .chat { ChatTabs.shared.saveHistory(chatHistory, for: cur) } activeTabId = chat.id chatHistory = ChatTabs.shared.history(for: chat.id) } else if tabs.count < Self.maxTabs { let n = (1...Self.maxTabs).first { k in !tabs.contains { $0.kind == .chat && $0.number == k } } ?? 1 let t = BroTab(kind: .chat, number: n) tabs.append(t) activeTabId = t.id chatHistory = [] } } } } // Tasks @Published var tasks: [AgentTask] = [] @Published var focusId: String? = nil // Bot state override @Published var stateOverride: BotState? = nil // Real notch dimensions (set by IslandWindowController on launch) var notchWidth: CGFloat = IslandConst.notchWidth var notchHeight: CGFloat = IslandConst.notchHeight var hasNotch = true // Last app active before NotchBuddy (for window context capture) var lastExternalApp: NSRunningApplication? = nil // Bot drag-attach state (hides original bot while ghost follows cursor) @Published var isDraggingBot: Bool = false // Desktop Mochi: true while Mochi lives on the desktop instead of the notch @Published var mochiOnDesktop: Bool = false // Mouse tracking var mousePosition: CGPoint = .zero var lastMouseMove: Date = .now var lastActivity: Date = .now var isPresent: Bool = true // Pinned (alerts that stay open, never auto-close) var isPinned: Bool = false // Keyboard navigation — index of the selected item within the current card's list (nil = none) @Published var cardSelection: Int? = nil // Number of navigable items in the card currently on screen (0 = no list) @Published var cardItemCount: Int = 0 // Upload progress (0-1) — set to 1.0 only at completion; animation is time-based @Published var uploadProgress: Double = 0 // Upload animation timing (non-published — TimelineViews read these directly) var uploadStartTime: Date? var uploadDuration: Double = 2.4 // File drag-over state (mailbox morph glow + mouth spring) @Published var fileDragOver: Bool = false // Sound enabled — persisted @Published var soundEnabled: Bool = true { didSet { UserDefaults.standard.set(soundEnabled, forKey: "soundEnabled") } } // Mochi outfit selection — persisted @Published var mochiOutfitSelection: Outfit = .auto { didSet { Outfit.stored = mochiOutfitSelection } } // Transient: outfit preview while hovering in wardrobe (overrides resolvedOutfit in BotCanvasView) var wardrobePreviewOutfit: Outfit? = nil // Per-day seasonal cache — avoids recomputing Easter and date math on every frame private var _seasonalCache: (dayOfYear: Int, year: Int, outfit: Outfit)? var resolvedOutfit: Outfit { if let preview = wardrobePreviewOutfit { return preview } guard mochiOutfitSelection == .auto else { return mochiOutfitSelection } let cal = Calendar.current let now = Date() let day = cal.ordinality(of: .day, in: .year, for: now) ?? 0 let year = cal.component(.year, from: now) if let c = _seasonalCache, c.dayOfYear == day && c.year == year { return c.outfit } let outfit = Outfit.seasonal(for: now, calendar: cal) _seasonalCache = (dayOfYear: day, year: year, outfit: outfit) return outfit } // Claude model used by the chat and the search — persisted static let defaultClaudeModel = "claude-sonnet-4-6" @Published var claudeModel: String = AppState.defaultClaudeModel { didSet { UserDefaults.standard.set(claudeModel, forKey: "claudeModel") } } // In-chat provider + model — picked via the model selector in the prompt view @Published var chatProvider: ChatProvider = .claudeCode { didSet { UserDefaults.standard.set(chatProvider.rawValue, forKey: "chatProvider") } } // Claude Code CLI chat (BroV): model alias ("" = CLI default), folder it runs in, // optional path to the binary, and the permission mode passed to `claude`. @Published var claudeCodeModel: String = "" { didSet { UserDefaults.standard.set(claudeCodeModel, forKey: "claudeCodeModel") } } @Published var chatWorkingDirectory: String = "~" { didSet { UserDefaults.standard.set(chatWorkingDirectory, forKey: "chatWorkingDirectory") } } @Published var claudeBinaryPath: String = "" { didSet { UserDefaults.standard.set(claudeBinaryPath, forKey: "claudeBinaryPath") } } @Published var claudePermissionMode: String = "default" { didSet { UserDefaults.standard.set(claudePermissionMode, forKey: "claudePermissionMode") } } @Published var googleChatModel: String = ChatProvider.google.defaultModel { didSet { UserDefaults.standard.set(googleChatModel, forKey: "googleChatModel") } } @Published var openAIChatModel: String = ChatProvider.openai.defaultModel { didSet { UserDefaults.standard.set(openAIChatModel, forKey: "openAIChatModel") } } @Published var ollamaChatModel: String = ChatProvider.ollama.defaultModel { didSet { UserDefaults.standard.set(ollamaChatModel, forKey: "ollamaChatModel") } } @Published var lmstudioChatModel: String = ChatProvider.lmstudio.defaultModel { didSet { UserDefaults.standard.set(lmstudioChatModel, forKey: "lmstudioChatModel") } } @Published var ollamaServerURL: String = "" { didSet { UserDefaults.standard.set(ollamaServerURL, forKey: "ollamaServerURL") } } @Published var lmstudioServerURL: String = "" { didSet { UserDefaults.standard.set(lmstudioServerURL, forKey: "lmstudioServerURL") } } // The always-on workspace pill (default: VS Code). Persisted. @Published var mainPillId: String = PillCatalog.defaultMainPillId { didSet { UserDefaults.standard.set(mainPillId, forKey: "mainPill") } } // Dynamically fetched model lists for the in-chat picker (keyed by provider) @Published var fetchedProviderModels: [ChatProvider: [(id: String, label: String)]] = [:] @Published var providerModelFetchError: [ChatProvider: String] = [:] @Published var loadingProviderModels: Set = [] /// Fetches models for `provider` if not already loaded or loading. /// Sets `providerModelFetchError` if the key is absent or the request fails. func fetchModelsIfNeeded(for provider: ChatProvider) { guard !loadingProviderModels.contains(provider), fetchedProviderModels[provider] == nil else { return } if provider == .claudeCode { fetchedProviderModels[provider] = ClaudeCodeCLI.models return } // Local providers: fetch from server URL (no API key needed) if provider.isLocal { let baseURL = provider == .ollama ? ollamaServerURL : lmstudioServerURL let normalised = LocalChat.normaliseURL(baseURL) guard !normalised.isEmpty else { providerModelFetchError[provider] = provider == .ollama ? "Сначала подключите Ollama в Настройки → Чат." : "Сначала подключите LM Studio в Настройки → Чат." return } loadingProviderModels.insert(provider) providerModelFetchError.removeValue(forKey: provider) Task { let result = await LocalChat.fetchModelsResult(baseURL: normalised) loadingProviderModels.remove(provider) switch result { case .success(let models) where models.isEmpty: providerModelFetchError[provider] = provider == .ollama ? "Моделей пока нет. Скачайте модель в Ollama." : "Моделей пока нет. Скачайте модель в LM Studio." case .success(let models): fetchedProviderModels[provider] = models let current = provider == .ollama ? ollamaChatModel : lmstudioChatModel if !models.contains(where: { $0.id == current }) { let first = models.first!.id if provider == .ollama { ollamaChatModel = first } else { lmstudioChatModel = first } } case .failure: providerModelFetchError[provider] = "Нет связи с \(normalised). Сервер запущен?" } } return } // Remote providers: require API key guard let apiKey = KeychainStore.shared.get(provider.keychainKey), !apiKey.isEmpty else { providerModelFetchError[provider] = "Нет API-ключа — добавьте его в Настройки → Чат." return } loadingProviderModels.insert(provider) providerModelFetchError.removeValue(forKey: provider) Task { let models: [(id: String, label: String)] switch provider { case .claudeCode: models = ClaudeCodeCLI.models case .anthropic: models = await ClaudeService.fetchModels(apiKey: apiKey) case .google: models = await ClaudeService.fetchGoogleModels(apiKey: apiKey) case .openai: models = await ClaudeService.fetchOpenAIModels(apiKey: apiKey) case .ollama, .lmstudio: models = [] // handled above } loadingProviderModels.remove(provider) if models.isEmpty { providerModelFetchError[provider] = "Не удалось загрузить модели. Проверьте API-ключ." } else { fetchedProviderModels[provider] = models switch provider { case .anthropic: if !models.contains(where: { $0.id == claudeModel }) { claudeModel = models.first(where: { $0.id.contains("sonnet") })?.id ?? models.first!.id } case .google: if !models.contains(where: { $0.id == googleChatModel }) { googleChatModel = models.first(where: { $0.id.contains("flash") })?.id ?? models.first!.id } case .openai: if !models.contains(where: { $0.id == openAIChatModel }) { openAIChatModel = models.first(where: { $0.id.contains("mini") })?.id ?? models.first!.id } case .ollama, .lmstudio, .claudeCode: break } } } } /// The model currently active for chat (provider-aware). var activeChatModel: String { switch chatProvider { case .claudeCode: return claudeCodeModel case .anthropic: return claudeModel case .google: return googleChatModel case .openai: return openAIChatModel case .ollama: return ollamaChatModel case .lmstudio: return lmstudioChatModel } } // Sound volume (0–0.2) — persisted, synced to SoundEngine @Published var soundVolume: Double = 0.12 { didSet { UserDefaults.standard.set(soundVolume, forKey: "soundVolume") SoundEngine.shared.volume = Float(soundVolume) } } // Context for prompt (window attach / file) @Published var promptContext: PromptContext? = nil // Dropped file (set during upload flow) @Published var droppedFile: DroppedFile? = nil /// Original URLs of the last drop (all of them), for "На полку". var lastDroppedURLs: [URL] = [] /// Puts the last dropped files on the shelf and goes back home. func shelveLastDrop() { FileShelf.shared.add(lastDroppedURLs) SoundEngine.shared.play("pop") NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.proud) view = .overview } // Short note message (shown in NoteView) @Published var noteMessage: String? = nil // Auto-close delay — persisted @Published var autoCloseInterval: TimeInterval = 15 { didSet { UserDefaults.standard.set(autoCloseInterval, forKey: "autoCloseInterval") } } // Absence interval — persisted var absenceInterval: TimeInterval = 3 * 60 { didSet { UserDefaults.standard.set(absenceInterval, forKey: "absenceInterval") } } // Greeting threshold — how long hidden before greeting on reappear (default 2 min) var greetThresholdSeconds: TimeInterval = 120 { didSet { UserDefaults.standard.set(greetThresholdSeconds, forKey: "greetThreshold") } } // Hotkey to show island (e.g. ⌘⇧N) @Published var hotkeyEnabled: Bool = false { didSet { UserDefaults.standard.set(hotkeyEnabled, forKey: "hotkeyEnabled") } } var hotkeyFlags: UInt = NSEvent.ModifierFlags([.command, .shift]).rawValue { didSet { UserDefaults.standard.set(Int(hotkeyFlags), forKey: "hotkeyFlags") } } var hotkeyCode: UInt16 = 45 { // 'n' didSet { UserDefaults.standard.set(Int(hotkeyCode), forKey: "hotkeyCode") } } // Vercel project filter — empty = watch all projects @Published var vercelProjectFilter: Set = [] { didSet { if let data = try? JSONEncoder().encode(Array(vercelProjectFilter)) { UserDefaults.standard.set(data, forKey: "vercelProjectFilter") } } } // n8n workflow filter — empty = watch all workflows @Published var n8nWorkflowFilter: Set = [] { didSet { if let data = try? JSONEncoder().encode(Array(n8nWorkflowFilter)) { UserDefaults.standard.set(data, forKey: "n8nWorkflowFilter") } } } // Active integration pills (main workspace pill excluded). Max 4. // BroV: no service pills by default (add them back in Settings → Pills). @Published var activeIntegrations: Set = [] { didSet { if let data = try? JSONEncoder().encode(Array(activeIntegrations)) { UserDefaults.standard.set(data, forKey: "activeIntegrations") } // Clear stale GitHub data when the integration is disabled if !activeIntegrations.contains("integration_github") && oldValue.contains("integration_github") { githubPulse = nil githubActivity = nil } } } // Pending API result @Published var searchResult: SearchResult? = nil // Vercel deployments (populated by VercelPoller) @Published var vercelDeployments: [VercelDeployment] = [] // Resend emails (populated by ResendPoller) @Published var resendEmails: [ResendEmail] = [] @Published var resendTotal: Int? = nil // GitHub stats + pulse + activity (populated by GithubPoller) @Published var githubStats: GitHubStats? = nil @Published var githubPulse: GitHubPulse? = nil @Published var githubActivity: GitHubActivity? = nil // Stripe (populated by StripePoller) @Published var stripePayments: [StripePayment] = [] @Published var stripeBalance: Int = 0 // raw balance in cents @Published var stripeDisplayBalance: Int = 0 // animated balance target @Published var stripeCurrency: String = "eur" @Published var stripeLoaded: Bool = false // true after first successful poll @Published var stripeError: String? = nil // last API error (nil = ok) // Cal.com (populated by CalcomPoller) @Published var calcomBookings: [CalcomBooking] = [] @Published var calcomLoaded: Bool = false @Published var calcomError: String? = nil // Notion (populated by NotionPoller) @Published var notionPages: [NotionPage] = [] @Published var notionLoaded: Bool = false @Published var notionError: String? = nil // n8n — the last executions, newest first (for the iPhone; the notch shows only the latest) @Published var n8nRuns: [N8nRun] = [] // Chat conversation history @Published var chatHistory: [ChatMessage] = [] // Pending approval request from Claude Code hook @Published var pendingApproval: ApprovalInfo? = nil // Pending AskUserQuestion from Claude Code hook @Published var pendingQuestion: AskQuestion? = nil // Per-pill flat list of FileDiffs, in order of reception. // Not @Published — steps[] changes already trigger redraws. var sessionDiffs: [String: [FileDiff]] = [:] private var sessionDiffTimers: [String: DispatchWorkItem] = [:] // Monotonically increasing — never reset, not even in clearSessionDiffs. private var nextDiffId: Int = 0 @discardableResult func appendSessionDiff(_ diff: FileDiff, for pillId: String) -> Int { var d = diff d.id = nextDiffId nextDiffId += 1 if sessionDiffs[pillId] == nil { sessionDiffs[pillId] = [] } sessionDiffs[pillId]!.append(d) // Keep at most 50 diffs per pill; drop oldest first while sessionDiffs[pillId]!.count > 50 { sessionDiffs[pillId]!.removeFirst() } resetSessionDiffTimer(for: pillId) return d.id } func clearSessionDiffs(for pillId: String) { sessionDiffTimers[pillId]?.cancel() sessionDiffTimers.removeValue(forKey: pillId) sessionDiffs.removeValue(forKey: pillId) // nextDiffId intentionally NOT reset — ids remain unique across sessions } private func resetSessionDiffTimer(for pillId: String) { sessionDiffTimers[pillId]?.cancel() let work = DispatchWorkItem { [weak self] in DispatchQueue.main.async { self?.clearSessionDiffs(for: pillId) } } sessionDiffTimers[pillId] = work DispatchQueue.global().asyncAfter(deadline: .now() + 3600, execute: work) } #if !APPSTORE @Published var musicPlaying: Bool = false @Published var musicAutomationDenied: Bool = false #endif // Claude plan gauge (from statusline hook) // MARK: Chat panel size (BroV) // Detents: 0 = fits the messages (as before), 1 = ⅓ screen, 2 = ½ screen, 3 = full height. @Published var chatDetent: Int = 0 /// Live height while the user drags the handle; nil otherwise. @Published var chatDragHeight: CGFloat? = nil @Published var chatWidth: CGFloat = 720 { didSet { UserDefaults.standard.set(Double(chatWidth), forKey: "chatWidth") } } static let chatWidthOptions: [CGFloat] = [640, 720, 820, 960] var screenHeight: CGFloat { (IslandWindowController.notchScreen() ?? NSScreen.main)?.frame.height ?? 900 } func chatDetentHeight(_ d: Int) -> CGFloat { let auto = view == .terminal ? 380 : min(300, 240 + CGFloat(chatHistory.count) * 40) switch d { case 1: return max(320, screenHeight / 3) case 2: return max(340, screenHeight / 2) case 3: return screenHeight - 6 default: return auto } } /// Current chat island height (drag, else detent). var chatIslandHeight: CGFloat { chatDragHeight ?? chatDetentHeight(chatDetent) } // MARK: Tabs (BroV): brov-chat dialogs and term.macOS terminals static let maxTabs = 6 @Published var tabs: [BroTab] = [BroTab(kind: .chat, number: 1)] @Published var activeTabId: UUID? = nil func openTab(_ kind: TabKind) { guard tabs.count < Self.maxTabs else { return } let used = Set(tabs.filter { $0.kind == kind }.map(\.number)) let n = (1...Self.maxTabs).first { !used.contains($0) } ?? tabs.count + 1 let tab = BroTab(kind: kind, number: n) tabs.append(tab) SoundEngine.shared.play("pop") activateTab(tab.id) } func activateTab(_ id: UUID) { guard let tab = tabs.first(where: { $0.id == id }) else { return } if let cur = activeTabId, cur != id, tabs.first(where: { $0.id == cur })?.kind == .chat { ChatTabs.shared.saveHistory(chatHistory, for: cur) } if activeTabId != id { activeTabId = id if tab.kind == .chat { chatHistory = ChatTabs.shared.history(for: id) } } view = tab.kind == .chat ? .prompt : .terminal } func closeTab(_ id: UUID) { guard let i = tabs.firstIndex(where: { $0.id == id }) else { return } let tab = tabs.remove(at: i) if tab.kind == .chat { ChatTabs.shared.close(id) } else { TerminalStore.shared.close(id) } SoundEngine.shared.play("close") guard activeTabId == id else { return } activeTabId = nil if tab.kind == .chat { chatHistory = [] } if let next = tabs.indices.contains(i) ? tabs[i] : tabs.last { activateTab(next.id) } else { view = .overview } } // MARK: Agent health (BroV) @Published var crashedSessions: [CrashedSession] = [] @Published var claudeNeedsLogin = false @Published var codexNeedsLogin = false /// ChatGPT (Codex) weekly plan window, read from ~/.codex/sessions by CodexUsageMonitor. @Published var codexWeekly: PlanWindow? = nil @Published var claudePlanUsage: PlanUsage? = nil { didSet { if let u = claudePlanUsage, let data = try? JSONEncoder().encode(u) { UserDefaults.standard.set(data, forKey: "claudePlanUsage") } } } // Plan gauge: show pill in notch header — persisted #if !APPSTORE @Published var showPlanInNotch: Bool = false { didSet { UserDefaults.standard.set(showPlanInNotch, forKey: "showPlanInNotch") } } // Cached relay-installed state — updated at launch, after install/uninstall, on Settings open @Published var planRelayInstalled: Bool = false // Transient — reset when island closes or view changes @Published var showingPlanDetail: Bool = false func refreshPlanRelayState() { planRelayInstalled = HookServer.statusLineInstalled() } #endif // MARK: - Init (loads persisted settings) private init() { activeTabId = tabs.first?.id let ud = UserDefaults.standard if let v = ud.object(forKey: "soundEnabled") as? Bool { soundEnabled = v } if let v = ud.object(forKey: "soundVolume") as? Double { soundVolume = v } mochiOutfitSelection = Outfit.stored if let v = ud.string(forKey: "claudeModel"), !v.trimmingCharacters(in: .whitespaces).isEmpty { claudeModel = v } if let v = ud.string(forKey: "chatProvider"), let p = ChatProvider(rawValue: v) { chatProvider = p } if let v = ud.string(forKey: "claudeCodeModel") { claudeCodeModel = v } if let v = ud.object(forKey: "chatWidth") as? Double, v >= 560 { chatWidth = CGFloat(v) } if let v = ud.string(forKey: "chatWorkingDirectory"), !v.isEmpty { chatWorkingDirectory = v } if let v = ud.string(forKey: "claudeBinaryPath") { claudeBinaryPath = v } if let v = ud.string(forKey: "claudePermissionMode"), !v.isEmpty { claudePermissionMode = v } if let v = ud.string(forKey: "googleChatModel"), !v.isEmpty { googleChatModel = v } if let v = ud.string(forKey: "openAIChatModel"), !v.isEmpty { openAIChatModel = v } if let v = ud.string(forKey: "ollamaChatModel"), !v.isEmpty { ollamaChatModel = v } if let v = ud.string(forKey: "lmstudioChatModel"), !v.isEmpty { lmstudioChatModel = v } if let v = ud.string(forKey: "ollamaServerURL"), !v.isEmpty { ollamaServerURL = v } if let v = ud.string(forKey: "lmstudioServerURL"), !v.isEmpty { lmstudioServerURL = v } // Migrate old 60s default → 15s if let v = ud.object(forKey: "autoCloseInterval") as? Double { autoCloseInterval = (v == 60) ? 15 : v } if let v = ud.object(forKey: "absenceInterval") as? Double { absenceInterval = v } if let v = ud.object(forKey: "greetThreshold") as? Double { greetThresholdSeconds = v } if let v = ud.object(forKey: "hotkeyEnabled") as? Bool { hotkeyEnabled = v } if let v = ud.object(forKey: "hotkeyFlags") as? Int { hotkeyFlags = UInt(v) } if let v = ud.object(forKey: "hotkeyCode") as? Int { hotkeyCode = UInt16(v) } if let d = ud.data(forKey: "vercelProjectFilter"), let a = try? JSONDecoder().decode([String].self, from: d) { vercelProjectFilter = Set(a) } if let d = ud.data(forKey: "n8nWorkflowFilter"), let a = try? JSONDecoder().decode([String].self, from: d) { n8nWorkflowFilter = Set(a) } // BroV home redesign: drop the four default service pills once. if !ud.bool(forKey: "brovServicePillsCleared") { ud.removeObject(forKey: "activeIntegrations") ud.set(true, forKey: "brovServicePillsCleared") } if let d = ud.data(forKey: "activeIntegrations"), let a = try? JSONDecoder().decode([String].self, from: d) { activeIntegrations = Set(a) } if let v = ud.string(forKey: "mainPill"), !v.isEmpty, PillCatalog.available.contains(where: { $0.id == v && $0.category == .workspace && !$0.comingSoon }) { mainPillId = v } if let d = ud.data(forKey: "claudePlanUsage"), let u = try? JSONDecoder().decode(PlanUsage.self, from: d) { claudePlanUsage = u } #if !APPSTORE if let v = ud.object(forKey: "showPlanInNotch") as? Bool { showPlanInNotch = v } planRelayInstalled = HookServer.statusLineInstalled() #endif // Sync SoundEngine volume on launch SoundEngine.shared.volume = Float(soundVolume) // Always load integration pills loadIntegrationTasks() } // MARK: - Computed var focusTask: AgentTask? { tasks.first { $0.id == focusId } ?? tasks.first } var effectiveState: BotState { stateOverride ?? focusTask?.state ?? .idle } // MARK: - Task management func addTask(_ task: AgentTask) { guard !tasks.contains(where: { $0.id == task.id }) else { return } tasks.append(task) if focusId == nil { focusId = task.id } syncMode() syncView() } func removeTask(id: String) { // mainPillId: always reset, never remove (the active workspace tool) // activeIntegrations: also reset (user declared it active, keep it as idle) let isProtected = id == mainPillId let isActiveDecl = PillCatalog.definition(for: id) != nil && activeIntegrations.contains(id) if isProtected || isActiveDecl { if let idx = tasks.firstIndex(where: { $0.id == id }) { let catalogName = PillCatalog.definition(for: id)?.name tasks[idx].state = .idle tasks[idx].steps = [] tasks[idx].stepIndex = 0 tasks[idx].pillBadge = nil if let n = catalogName { tasks[idx].name = n } } return } // Undeclared or declared-but-not-active: remove tasks.removeAll { $0.id == id } if focusId == id { focusId = mainPillId } syncMode() syncView() } func updateTask(id: String, state: BotState) { guard let idx = tasks.firstIndex(where: { $0.id == id }) else { return } tasks[idx].state = state } func setFocus(_ id: String) { guard let idx = tasks.firstIndex(where: { $0.id == id }) else { return } focusId = id tasks[idx].pillBadge = nil // clear badge when user brings task to focus } func setPillBadge(_ badge: PillBadge, for id: String) { guard let idx = tasks.firstIndex(where: { $0.id == id }) else { return } tasks[idx].pillBadge = badge } /// Called on main thread after each GitHub pulse poll. Fires badge + sound based on events. func handleGitHubEvents(_ events: [GitHubEvent]) { guard !events.isEmpty else { return } // Priority: error > question (reviewRequested) > finish (ciPassed) var level = 0 // 0 = none, 1 = finish, 2 = question, 3 = error var badge: PillBadge? var sound: String? for event in events { switch event { case .ciFailed, .mainFailed: if level < 3 { level = 3; badge = .error; sound = "error" } case .reviewRequested: if level < 2 { level = 2; badge = .finished; sound = "question" } case .ciPassed: if level < 1 { level = 1; badge = .finished; sound = "finish" } } } // Only set badge when the GitHub pill is not currently in focus if let b = badge, focusId != "integration_github" { setPillBadge(b, for: "integration_github") } if let s = sound { SoundEngine.shared.play(s) } } func syncMode() { // If no tasks and not expanded/peek, go hidden if tasks.isEmpty && mode == .compact { mode = .hidden } else if !tasks.isEmpty && mode == .hidden && isPresent { mode = .compact } } func syncView() { guard mode == .expanded else { return } if view == .empty && !tasks.isEmpty { view = .overview } else if view == .overview && tasks.isEmpty { view = .empty } } /// Load catalog pills into tasks, respecting activeIntegrations. Safe to call multiple times. func loadIntegrationTasks() { let catalog = PillCatalog.available // Sanitize: remove saved IDs not in catalog let catalogIds = Set(catalog.map { $0.id }) activeIntegrations = activeIntegrations.filter { catalogIds.contains($0) } // Validate mainPillId: must be a non-comingSoon workspace pill in the catalog if !PillCatalog.available.contains(where: { $0.id == mainPillId && $0.category == .workspace && !$0.comingSoon }) { mainPillId = PillCatalog.defaultMainPillId } // mainPillId must never be in activeIntegrations (migration + invariant) activeIntegrations.remove(mainPillId) for def in catalog { // mainPillId always loads; activeIntegrations load let shouldLoad = def.id == mainPillId || activeIntegrations.contains(def.id) let loaded = tasks.contains(where: { $0.id == def.id }) if shouldLoad && !loaded { let task = AgentTask(id: def.id, name: def.name, color: def.color, state: .idle, steps: [], source: def.source, isIntegration: true) tasks.append(task) } if !shouldLoad && loaded { tasks.removeAll { $0.id == def.id } } } sortTasksByCatalog() if focusId == nil { focusId = mainPillId } syncMode() } /// Toggle a catalog pill on/off. /// mainPillId: never toggleable (change via the Main picker first). /// Max 4 non-main pills active at once. func toggleIntegration(_ id: String) { guard id != mainPillId else { return } guard PillCatalog.available.contains(where: { $0.id == id }) else { return } if activeIntegrations.contains(id) { activeIntegrations.remove(id) tasks.removeAll { $0.id == id } if focusId == id { focusId = mainPillId } } else { guard activeIntegrations.count < 4 else { return } activeIntegrations.insert(id) if let def = PillCatalog.available.first(where: { $0.id == id }), !tasks.contains(where: { $0.id == id }) { let task = AgentTask(id: def.id, name: def.name, color: def.color, state: .idle, steps: [], source: def.source, isIntegration: true) tasks.append(task) sortTasksByCatalog() } } syncMode() } /// Sort tasks so catalog pills are in catalog order, undeclared pills sit right after /// integration_claude (matching HookServer insertion behaviour), and the rest follows. private func sortTasksByCatalog() { let order = PillCatalog.available.enumerated() .reduce(into: [String: Int]()) { $0[$1.element.id] = $1.offset } let catalogPills = tasks.filter { order[$0.id] != nil } let undeclaredPills = tasks.filter { order[$0.id] == nil } let sortedCatalog = catalogPills.sorted { (order[$0.id] ?? 0) < (order[$1.id] ?? 0) } if let claudeIdx = sortedCatalog.firstIndex(where: { $0.id == "integration_claude" }) { var result: [AgentTask] = Array(sortedCatalog[...claudeIdx]) result.append(contentsOf: undeclaredPills) if claudeIdx + 1 < sortedCatalog.count { result.append(contentsOf: sortedCatalog[(claudeIdx + 1)...]) } tasks = result } else { tasks = undeclaredPills + sortedCatalog } } } // MARK: - Supporting types enum PromptContext { case window(appName: String, title: String, url: String?) case file(name: String, fileURL: URL?) } struct DroppedFile { var url: URL var name: String } struct SearchResult { var title: String var items: [ResultItem] var note: String? } struct ResultItem { var label: String var detail: String var url: String? } // MARK: - Vercel struct VercelDeployment: Identifiable { let id: String let projectName: String let url: String let state: String // "READY", "ERROR", "CANCELED" let createdAt: Date let commitMessage: String? let branch: String? var isSuccess: Bool { state == "READY" } var statusLabel: String { isSuccess ? "Готово" : (state == "CANCELED" ? "Отменено" : "Ошибка") } var timeAgo: String { let diff = Date().timeIntervalSince(createdAt) if diff < 60 { return "только что" } if diff < 3600 { return "\(Int(diff/60)) мин" } if diff < 86400 { return "\(Int(diff/3600)) ч" } return "\(Int(diff/86400)) д" } } // MARK: - Resend struct ResendEmail: Identifiable { let id: String let to: [String] let subject: String let createdAt: Date let lastEvent: String // "delivered", "bounced", "complained", "opened", etc. var recipientShort: String { guard let first = to.first else { return "?" } return first.components(separatedBy: "@").first ?? first } var timeAgo: String { let diff = Date().timeIntervalSince(createdAt) if diff < 60 { return "только что" } if diff < 3600 { return "\(Int(diff/60)) мин" } if diff < 86400 { return "\(Int(diff/3600)) ч" } return "\(Int(diff/86400)) д" } var isDelivered: Bool { lastEvent == "delivered" } } // MARK: - GitHub struct GitHubStats { let totalRepos: Int let totalStars: Int } // MARK: - Stripe struct StripePayment: Identifiable, Equatable { let id: String let amount: Int // in cents/smallest unit let currency: String let description: String? let createdAt: Date let status: String // "succeeded", "pending", "failed" var amountFormatted: String { String(format: "%.2f", Double(amount) / 100.0) } var isSuccess: Bool { status == "succeeded" } var timeAgo: String { let diff = Date().timeIntervalSince(createdAt) if diff < 60 { return "только что" } if diff < 3600 { return "\(Int(diff/60)) мин" } if diff < 86400 { return "\(Int(diff/3600)) ч" } return "\(Int(diff/86400)) д" } } // MARK: - Cal.com struct CalcomBooking: Identifiable, Equatable { let id: Int let title: String let startTime: Date let endTime: Date let status: String let attendeeName: String? let attendeeEmail: String? let attendeeNotes: String? var isActive: Bool { status == "ACCEPTED" || status == "PENDING" } var timeLabel: String { let f = DateFormatter(); f.dateFormat = "HH:mm"; return f.string(from: startTime) } var dayKey: String { let c = Calendar.current.dateComponents([.year, .month, .day], from: startTime) return "\(c.year!)-\(String(format: "%02d", c.month!))-\(String(format: "%02d", c.day!))" } } // MARK: - Notion struct N8nRun: Equatable { let workflow: String let detail: String? let success: Bool let date: Date } struct NotionPage: Identifiable { let id: String let title: String let emoji: String? let lastEditedAt: Date let url: String var timeAgo: String { let diff = Date().timeIntervalSince(lastEditedAt) if diff < 60 { return "сейчас" } if diff < 3600 { return "\(Int(diff/60)) мин" } if diff < 86400 { return "\(Int(diff/3600)) ч" } return "\(Int(diff/86400)) д" } } // MARK: - Chat enum ChatRole { case user, assistant } struct ChatMessage: Identifiable, Equatable { let id = UUID() let role: ChatRole var content: String // var for streaming updates }