diff --git a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift index 87698b77747..041b6ec7795 100644 --- a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift +++ b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift @@ -1198,12 +1198,13 @@ final class DesktopAutomationActionRegistry { register( name: "capture_main_window_png", - summary: "Write PNG of the frontmost Omi window (in-process capture)", - params: ["path"] + summary: "Write PNG of the frontmost Omi window (in-process capture); chrome=1 includes titlebar/toolbar", + params: ["path", "chrome"] ) { params in guard let path = params["path"], !path.isEmpty else { return ["error": "missing 'path'"] } + let includeChrome = params["chrome"] == "1" return await MainActor.run { () -> [String: String] in guard let window = NSApp.windows.first(where: { @@ -1213,11 +1214,12 @@ final class DesktopAutomationActionRegistry { else { return ["error": "no_visible_window"] } - let bounds = contentView.bounds - guard let rep = contentView.bitmapImageRepForCachingDisplay(in: bounds) else { + let captureView = includeChrome ? (contentView.superview ?? contentView) : contentView + let bounds = captureView.bounds + guard let rep = captureView.bitmapImageRepForCachingDisplay(in: bounds) else { return ["error": "bitmap_rep_failed"] } - contentView.cacheDisplay(in: bounds, to: rep) + captureView.cacheDisplay(in: bounds, to: rep) guard let data = rep.representation(using: .png, properties: [:]) else { return ["error": "png_encode_failed"] } @@ -1230,6 +1232,40 @@ final class DesktopAutomationActionRegistry { } } + register( + name: "dump_window_chrome", + summary: "Dump the main window's titlebar/toolbar view hierarchy (class, frame, text)", + params: [] + ) { _ in + await MainActor.run { () -> [String: String] in + guard + let window = NSApp.windows.first(where: { + $0.isVisible && $0.title.range(of: "omi", options: .caseInsensitive) != nil + }), + let frameView = window.contentView?.superview + else { + return ["error": "no_visible_window"] + } + var lines: [String] = [] + func walk(_ view: NSView, depth: Int) { + // Skip the content subtree — only chrome is interesting here. + if view === window.contentView { return } + let indent = String(repeating: " ", count: depth) + var line = "\(indent)\(type(of: view)) frame=\(NSStringFromRect(view.frame))" + if let text = view as? NSTextField { + line += " text=\"\(text.stringValue)\"" + } + if view.isHidden { line += " hidden" } + lines.append(line) + for sub in view.subviews { + walk(sub, depth: depth + 1) + } + } + walk(frameView, depth: 0) + return ["chrome": lines.joined(separator: "\n")] + } + } + register( name: "capture_floating_bar_png", summary: "Write PNG of the floating control bar window (in-process capture)", diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift new file mode 100644 index 00000000000..7cbff43c031 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift @@ -0,0 +1,196 @@ +import AppKit +import SwiftUI + +/// Status of a capture-style feature as shown in the toolbar controls. +enum HomeStatusState { + case active + case inactive + case blocked + + var indicator: Color { + switch self { + case .active: + return HomePalette.green + case .inactive: + return HomePalette.faint + case .blocked: + return Color(red: 1.0, green: 0.24, blue: 0.30) + } + } + + var text: String { + switch self { + case .active: + return "On" + case .inactive: + return "Off" + case .blocked: + return "Blocked" + } + } + + var isActive: Bool { + if case .active = self { return true } + return false + } + + var isBlocked: Bool { + if case .blocked = self { return true } + return false + } +} + +/// Shared state + actions behind the Capture and Listening Home controls. +/// One instance per `InAppStatusControls`; the underlying truth lives in +/// `ProactiveAssistantsPlugin` / `AssistantSettings` / `AppState`, so +/// instances stay in sync via the monitoring notifications. +/// +/// Extracted from `DashboardPage` so control surfaces can share the toggle +/// logic without duplicating permission and monitoring state handling. +@MainActor +final class CaptureListeningController: ObservableObject { + @Published private(set) var isCaptureMonitoring = false + @Published private(set) var isTogglingCapture = false + @Published private(set) var isTogglingListening = false + + private let appState: AppState + private var observers: [NSObjectProtocol] = [] + + init(appState: AppState) { + self.appState = appState + syncCaptureState() + + let center = NotificationCenter.default + let syncNames: [Notification.Name] = [ + .assistantMonitoringStateDidChange, + .screenCapturePermissionLost, + .screenCaptureKitBroken, + ] + for name in syncNames { + observers.append( + center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor in self?.syncCaptureState() } + }) + } + } + + deinit { + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + } + + // MARK: - State + + var isCaptureLive: Bool { + isCaptureMonitoring || ProactiveAssistantsPlugin.shared.isMonitoring + } + + var captureStatus: HomeStatusState { + if appState.isScreenCaptureKitBroken || appState.isScreenRecordingStale + || !appState.hasScreenRecordingPermission + { + return .blocked + } + return isCaptureLive ? .active : .inactive + } + + var listeningCaptureMode: AssistantSettings.SystemAudioCaptureMode { + AssistantSettings.shared.systemAudioCaptureMode + } + + var listeningModeTitle: String { + switch listeningCaptureMode { + case .always: + return "Always" + case .onlyDuringMeetings: + return appState.isAwaitingMeeting ? "Meetings only" : "In meeting" + case .never: + return "Mic only" + } + } + + // MARK: - Actions + + func toggleListening() { + let enabled = !appState.isTranscribing + if enabled && !appState.hasMicrophonePermission { + appState.requestMicrophonePermission() + return + } + + isTogglingListening = true + AssistantSettings.shared.transcriptionEnabled = enabled + AnalyticsManager.shared.settingToggled(setting: "transcription", enabled: enabled) + NotificationCenter.default.post( + name: .toggleTranscriptionRequested, + object: nil, + userInfo: ["enabled": enabled] + ) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.isTogglingListening = false + } + } + + func toggleListeningMode() { + let nextMode: AssistantSettings.SystemAudioCaptureMode = + listeningCaptureMode == .onlyDuringMeetings ? .always : .onlyDuringMeetings + AssistantSettings.shared.systemAudioCaptureMode = nextMode + AnalyticsManager.shared.settingToggled( + setting: "meetings_only_listening", + enabled: nextMode == .onlyDuringMeetings + ) + objectWillChange.send() + } + + func toggleCapture() { + syncCaptureState() + let enabled = !isCaptureLive + isTogglingCapture = true + + if enabled { + ProactiveAssistantsPlugin.shared.refreshScreenRecordingPermission() + guard ProactiveAssistantsPlugin.shared.hasScreenRecordingPermission else { + setScreenAnalysisEnabled(false) + isCaptureMonitoring = false + isTogglingCapture = false + ScreenCaptureService.requestScreenRecordingAccessAndOpenSettings() + return + } + } + + setScreenAnalysisEnabled(enabled) + AnalyticsManager.shared.settingToggled(setting: "monitoring", enabled: enabled) + + if enabled { + ProactiveAssistantsPlugin.shared.startMonitoring { [weak self] success, _ in + DispatchQueue.main.async { + guard let self else { return } + self.isTogglingCapture = false + self.isCaptureMonitoring = ProactiveAssistantsPlugin.shared.isMonitoring + if !success { + self.setScreenAnalysisEnabled(false) + self.isCaptureMonitoring = false + } + } + } + } else { + ProactiveAssistantsPlugin.shared.stopMonitoring() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.isTogglingCapture = false + self?.isCaptureMonitoring = false + } + } + } + + func syncCaptureState() { + ProactiveAssistantsPlugin.shared.refreshScreenRecordingPermission() + isCaptureMonitoring = ProactiveAssistantsPlugin.shared.isMonitoring + objectWillChange.send() + } + + private func setScreenAnalysisEnabled(_ enabled: Bool) { + AssistantSettings.shared.screenAnalysisEnabled = enabled + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatInputView.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatInputView.swift index e7b39084ee1..d479dd4d585 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatInputView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatInputView.swift @@ -57,15 +57,19 @@ struct ChatInputView: View { attachments: currentAttachments, onRemove: { id in onAttachmentRemoved?(id) } ) + .padding(.top, 8) + .padding(.horizontal, 4) } - HStack(alignment: .center, spacing: 8) { + // One surface: paperclip, editor, and send all live inside the + // container — no field-inside-a-panel double chrome. + HStack(alignment: .bottom, spacing: 4) { if attachmentsEnabled { Button(action: pickFiles) { Image(systemName: "paperclip") - .scaledFont(size: 18, weight: .medium) + .scaledFont(size: 16, weight: .medium) .foregroundColor(OmiColors.textTertiary) - .frame(width: 32, height: 32) + .frame(width: 32, height: 40) } .buttonStyle(.plain) .help("Attach files") @@ -110,8 +114,6 @@ struct ChatInputView: View { } .frame(maxHeight: 200) .clipped() - .background(OmiColors.backgroundTertiary) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) // Floating Ask/Act toggle (top-right, inside the input area) if askModeEnabled { @@ -121,17 +123,20 @@ struct ChatInputView: View { } } - // Send/Stop button — inline to the right of the input + // Send/Stop button — inside the container, trailing edge. + // Upstream dropped follow-up-while-streaming, so Stop shows + // for the whole sending state. if isSending { if isStopping { ProgressView() .controlSize(.small) - .frame(width: 24, height: 24) + .frame(width: 28, height: 40) } else { Button(action: { onStop?() }) { Image(systemName: "stop.circle.fill") .scaledFont(size: 24) .foregroundColor(.red.opacity(0.8)) + .frame(width: 28, height: 40) } .buttonStyle(.plain) } @@ -139,15 +144,24 @@ struct ChatInputView: View { Button(action: handleSubmit) { Image(systemName: "arrow.up.circle.fill") .scaledFont(size: 24) - .foregroundColor(canSend ? OmiColors.purplePrimary : OmiColors.textQuaternary) + .foregroundColor(canSend ? OmiColors.textPrimary : OmiColors.textQuaternary) + .frame(width: 28, height: 40) } .buttonStyle(.plain) .disabled(!canSend) } } } - .padding(12) - .omiPanel(fill: OmiColors.backgroundSecondary, radius: 22, stroke: dropStrokeColor, shadowOpacity: 0.1, shadowRadius: 12, shadowY: 6) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(OmiColors.backgroundSecondary) + ) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(dropStrokeColor, lineWidth: 1) + ) .fixedSize(horizontal: false, vertical: true) .if(attachmentsEnabled) { view in view.onDrop(of: [UTType.fileURL], isTargeted: $isDropTargeted, perform: handleDrop) @@ -185,7 +199,7 @@ struct ChatInputView: View { } private var dropStrokeColor: Color { - isDropTargeted ? OmiColors.purplePrimary.opacity(0.6) : OmiColors.border.opacity(0.2) + isDropTargeted ? OmiColors.border : OmiColors.border.opacity(0.25) } private func handleSubmit() { @@ -421,10 +435,10 @@ struct ChatModeToggle: View { Button(action: { mode = targetMode }) { Text(label) .scaledFont(size: 12, weight: .medium) - .foregroundColor(mode == targetMode ? .white : OmiColors.textTertiary) + .foregroundColor(mode == targetMode ? .black : OmiColors.textTertiary) .padding(.horizontal, 10) .padding(.vertical, 5) - .background(mode == targetMode ? OmiColors.userBubble : Color.clear) + .background(mode == targetMode ? OmiColors.textPrimary : Color.clear) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } .buttonStyle(.plain) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiHeaderControls.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiHeaderControls.swift new file mode 100644 index 00000000000..6fa10011a85 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiHeaderControls.swift @@ -0,0 +1,166 @@ +import OmiTheme +import SwiftUI + +/// Shared metrics for list-page header rows (Conversations, Tasks, +/// Memories): one control height, one shape family (capsule / circle), one +/// fill, and one active treatment — replacing the per-page mix of radius-8 +/// squares, 42pt white squares, and assorted chip heights. +enum OmiHeader { + static let controlHeight: CGFloat = 36 + static let controlSpacing: CGFloat = 8 + static let rowHorizontalPadding: CGFloat = 24 + static let rowTopPadding: CGFloat = 14 + static let rowBottomPadding: CGFloat = 12 + + static let fill = OmiColors.backgroundSecondary + static let stroke = OmiColors.border.opacity(0.18) + static let activeFill = OmiColors.backgroundRaised + static let activeStroke = OmiColors.border.opacity(0.6) +} + +extension View { + /// Standard capsule surface for a header control. Content supplies its + /// own glyphs/text; this fixes the height, padding, fill, and stroke so + /// every control in a header row reads as one family. + func omiHeaderControl(isActive: Bool = false) -> some View { + padding(.horizontal, 14) + .frame(height: OmiHeader.controlHeight) + .background( + Capsule(style: .continuous) + .fill(isActive ? OmiHeader.activeFill : OmiHeader.fill) + ) + .overlay( + Capsule(style: .continuous) + .stroke(isActive ? OmiHeader.activeStroke : OmiHeader.stroke, lineWidth: 1) + ) + } +} + +/// Capsule search field shared by every list page header. +struct OmiSearchField: View { + let placeholder: String + @Binding var text: String + var isBusy: Bool = false + /// Custom clear behavior (e.g. cancel an in-flight search); defaults to + /// emptying the bound text. + var onClear: (() -> Void)? = nil + + var body: some View { + HStack(spacing: 8) { + if isBusy { + ProgressView() + .scaleEffect(0.6) + .frame(width: 14, height: 14) + } else { + Image(systemName: "magnifyingglass") + .scaledFont(size: 13) + .foregroundColor(OmiColors.textTertiary) + } + + TextField(placeholder, text: $text) + .textFieldStyle(.plain) + .scaledFont(size: 13) + .foregroundColor(OmiColors.textPrimary) + + if !text.isEmpty { + Button { + if let onClear { + onClear() + } else { + text = "" + } + } label: { + Image(systemName: "xmark.circle.fill") + .scaledFont(size: 13) + .foregroundColor(OmiColors.textTertiary) + } + .buttonStyle(.plain) + } + } + .omiHeaderControl() + } +} + +/// Circular icon button for header rows. All header actions render at the +/// same quiet weight — native macOS header rows don't shout any single +/// action with an accent fill. +struct OmiHeaderIconButton: View { + let systemImage: String + var isActive: Bool = false + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + Image(systemName: systemImage) + .scaledFont(size: 13, weight: .medium) + .foregroundColor( + isActive || isHovering ? OmiColors.textPrimary : OmiColors.textSecondary + ) + .frame(width: OmiHeader.controlHeight, height: OmiHeader.controlHeight) + .background(Circle().fill(isActive ? OmiHeader.activeFill : OmiHeader.fill)) + .overlay( + Circle().stroke( + isActive ? OmiHeader.activeStroke : OmiHeader.stroke, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + } +} + +/// Label content for a capsule chip: optional glyph + title + optional +/// chevron. Usable directly as a `Menu` label; `OmiHeaderChip` wraps it in a +/// button. +struct OmiHeaderChipLabel: View { + var systemImage: String? = nil + let title: String + var isActive: Bool = false + var showsChevron: Bool = false + /// Semantic tint for the glyph in the active state (e.g. amber star); + /// chrome stays neutral. + var activeGlyphTint: Color? = nil + + var body: some View { + HStack(spacing: 6) { + if let systemImage { + Image(systemName: systemImage) + .scaledFont(size: 12) + .foregroundColor(isActive ? (activeGlyphTint ?? OmiColors.textPrimary) : OmiColors.textSecondary) + } + Text(title) + .scaledFont(size: 13, weight: .medium) + .foregroundColor(isActive ? OmiColors.textPrimary : OmiColors.textSecondary) + if showsChevron { + Image(systemName: "chevron.down") + .scaledFont(size: 10) + .foregroundColor(OmiColors.textTertiary) + } + } + .omiHeaderControl(isActive: isActive) + } +} + +/// Capsule chip button for header filter/action rows. +struct OmiHeaderChip: View { + var systemImage: String? = nil + let title: String + var isActive: Bool = false + var showsChevron: Bool = false + var activeGlyphTint: Color? = nil + let action: () -> Void + + var body: some View { + Button(action: action) { + OmiHeaderChipLabel( + systemImage: systemImage, + title: title, + isActive: isActive, + showsChevron: showsChevron, + activeGlyphTint: activeGlyphTint + ) + } + .buttonStyle(.plain) + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift new file mode 100644 index 00000000000..6050676397c --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift @@ -0,0 +1,142 @@ +import SwiftUI + +/// Capture/Listening status controls shown inside the Home surface. +/// State reads through symbol tint and subtle capsule fill; details live in tooltips. +struct InAppStatusControls: View { + let appState: AppState + + @ObservedObject private var appStateObserved: AppState + @StateObject private var controls: CaptureListeningController + + init(appState: AppState) { + self.appState = appState + self.appStateObserved = appState + _controls = StateObject(wrappedValue: CaptureListeningController(appState: appState)) + } + + var body: some View { + HStack(spacing: 10) { + InAppStatusButton( + systemImage: "inset.filled.rectangle.badge.record", + title: "Capture", + state: captureDotState, + action: controls.toggleCapture, + helpText: captureHelp + ) + .disabled(controls.isTogglingCapture) + + InAppStatusButton( + systemImage: appStateObserved.isTranscribing ? "waveform" : "mic", + title: "Listening", + state: appStateObserved.isTranscribing ? .active : .off, + action: controls.toggleListening, + helpText: appStateObserved.isTranscribing + ? "Listening is on — click to stop (\(controls.listeningModeTitle))" + : "Listening is off — click to start" + ) + .disabled(controls.isTogglingListening) + .contextMenu { + Button("Audio mode: \(controls.listeningModeTitle) — switch") { + controls.toggleListeningMode() + } + } + + InAppStatusButton( + systemImage: "gearshape", + title: "Settings", + state: .off, + action: { navigate(to: .settings) }, + helpText: "Settings" + ) + } + .onAppear { controls.syncCaptureState() } + } + + private var captureDotState: InAppStatusButton.DotState { + switch controls.captureStatus { + case .active: return .active + case .blocked: return .attention + case .inactive: return .off + } + } + + private var captureHelp: String { + switch controls.captureStatus { + case .active: return "Screen capture is on — click to turn off" + case .inactive: return "Screen capture is off — click to turn on" + case .blocked: return "Screen capture is blocked — click to fix permissions" + } + } + + private func navigate(to item: SidebarNavItem) { + NotificationCenter.default.post( + name: .navigateToSidebarItem, + object: nil, + userInfo: ["rawValue": item.rawValue] + ) + } + +} + +/// A labeled Home toggle whose state must be readable at a glance: +/// running = green-tinted capsule fill with a green glyph, needs attention = +/// amber-tinted fill with a warning badge, off = plain dimmed glyph. +struct InAppStatusButton: View { + enum DotState { + case active + case attention + case off + } + + let systemImage: String + let title: String + let state: DotState + let action: () -> Void + let helpText: String + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 5) { + Image(systemName: systemImage) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(glyphColor) + .overlay(alignment: .topTrailing) { + if state == .attention { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 7)) + .foregroundStyle(.yellow) + .offset(x: 6, y: -4) + } + } + Text(title) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(state == .off ? Color.secondary : Color.primary) + } + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Capsule(style: .continuous).fill(backgroundFill)) + .contentShape(Capsule(style: .continuous)) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + .help(helpText) + } + + private var glyphColor: Color { + switch state { + case .active: return HomePalette.green + case .attention: return Color.primary + case .off: return Color.secondary + } + } + + private var backgroundFill: Color { + switch state { + case .active: return HomePalette.green.opacity(0.16) + case .attention: return Color.yellow.opacity(0.12) + case .off: return isHovering ? Color.white.opacity(0.07) : Color.clear + } + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 99494d3feab..f215e4fdcb5 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -115,7 +115,7 @@ struct DesktopHomeView: View { log("DesktopHomeView: Onboarding just completed — navigating to Dashboard") selectedIndex = SidebarNavItem.dashboard.rawValue } - } + } mainContent .opacity(viewModelContainer.isInitialLoadComplete ? 1 : 0) .overlay { @@ -875,25 +875,29 @@ struct DesktopHomeView: View { .clipped() } - // Main content area with rounded container + // Main content area. New design: one continuous near-black sheet. + // Legacy design keeps its floating rounded card. ZStack { - // Content container background - RoundedRectangle(cornerRadius: OmiChrome.windowRadius, style: .continuous) - .fill( - LinearGradient( - colors: [ - OmiColors.backgroundSecondary.opacity(0.96), - OmiColors.backgroundPrimary.opacity(0.96), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing + if useLegacyHomeDesign { + RoundedRectangle(cornerRadius: OmiChrome.windowRadius, style: .continuous) + .fill( + LinearGradient( + colors: [ + OmiColors.backgroundSecondary.opacity(0.96), + OmiColors.backgroundPrimary.opacity(0.96), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) ) - ) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.windowRadius, style: .continuous) - .stroke(OmiColors.border.opacity(0.22), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.22), radius: 26, x: 0, y: 14) + .overlay( + RoundedRectangle(cornerRadius: OmiChrome.windowRadius, style: .continuous) + .stroke(OmiColors.border.opacity(0.22), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.22), radius: 26, x: 0, y: 14) + } else { + HomePalette.paper + } // Page content - switch recreates views on tab change // Extracted into a separate struct so that pages like TasksPage @@ -919,9 +923,12 @@ struct DesktopHomeView: View { selectedTabIndex: $selectedIndex ) } - .clipShape(RoundedRectangle(cornerRadius: OmiChrome.windowRadius, style: .continuous)) + .clipShape( + RoundedRectangle( + cornerRadius: useLegacyHomeDesign ? OmiChrome.windowRadius : 0, style: .continuous) + ) } - .padding(14) + .padding(useLegacyHomeDesign ? 14 : 0) } .overlay { // Goal completion celebration overlay @@ -1093,18 +1100,24 @@ private struct PageContentView: View { appProvider: viewModelContainer.appProvider, chatProvider: viewModelContainer.chatProvider, memoriesViewModel: viewModelContainer.memoriesViewModel, + homeStatus: viewModelContainer.homeStatusStore, selectedIndex: $selectedTabIndex) case 1: - ConversationsPageHost(appState: appState) + ConversationsPageHost( + appState: appState, searchModel: viewModelContainer.conversationsSearchModel) case 2: ChatPage( appProvider: viewModelContainer.appProvider, chatProvider: viewModelContainer.chatProvider ) case 3: - MemoriesPage(viewModel: viewModelContainer.memoriesViewModel) + MemoriesPage( + viewModel: viewModelContainer.memoriesViewModel, + graphViewModel: viewModelContainer.memoryGraphViewModel, + appState: appState) case 4: TasksPage( viewModel: viewModelContainer.tasksViewModel, + appState: appState, chatCoordinator: viewModelContainer.taskChatCoordinator, chatProvider: viewModelContainer.chatProvider) case 5: @@ -1133,6 +1146,7 @@ private struct PageContentView: View { appProvider: viewModelContainer.appProvider, chatProvider: viewModelContainer.chatProvider, memoriesViewModel: viewModelContainer.memoriesViewModel, + homeStatus: viewModelContainer.homeStatusStore, selectedIndex: $selectedTabIndex) } } @@ -1143,10 +1157,13 @@ private struct PageContentView: View { /// so tapping a row navigates to the detail view. private struct ConversationsPageHost: View { let appState: AppState + let searchModel: ConversationsSearchModel @State private var selectedConversation: ServerConversation? = nil var body: some View { - ConversationsPage(appState: appState, selectedConversation: $selectedConversation) + ConversationsPage( + appState: appState, selectedConversation: $selectedConversation, + searchModel: searchModel) } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift index 63ba838274d..b3a94965506 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift @@ -750,10 +750,13 @@ final class ImportConnectorStatusStore: ObservableObject { private let manualConnectorIDs: Set = ["chatgpt", "claude"] private let onboardingChatGPTImportedMemoriesKey = "onboardingChatGPTImportedMemoriesCount" private let onboardingClaudeImportedMemoriesKey = "onboardingClaudeImportedMemoriesCount" + private var loadedUserID: String? + private var refreshGeneration = 0 init(defaults: UserDefaults = .standard) { self.defaults = defaults - load() + metricsByID = Self.emptyMetrics() + reloadForCurrentUserIfNeeded() } func snapshot(for connector: ImportConnector) -> Snapshot { @@ -782,78 +785,132 @@ final class ImportConnectorStatusStore: ObservableObject { availabilityText: String? = nil, syncedAt: Date = Date() ) { + reloadForCurrentUserIfNeeded() + let userID = loadedUserID var metrics = metricsByID[connectorID] ?? ConnectorMetrics() if let sourceCount { metrics.sourceCount = max(sourceCount, 0) - defaults.set(metrics.sourceCount, forKey: sourceCountKeyPrefix + connectorID) + if let key = scopedKey(sourceCountKeyPrefix, connectorID: connectorID, userID: userID) { + defaults.set(metrics.sourceCount, forKey: key) + } } if let memoryCount { metrics.memoryCount = max(memoryCount, 0) - defaults.set(metrics.memoryCount, forKey: memoryCountKeyPrefix + connectorID) + if let key = scopedKey(memoryCountKeyPrefix, connectorID: connectorID, userID: userID) { + defaults.set(metrics.memoryCount, forKey: key) + } } metrics.lastSyncedAt = syncedAt - defaults.set(syncedAt.timeIntervalSince1970, forKey: lastSyncedAtKeyPrefix + connectorID) + if let key = scopedKey(lastSyncedAtKeyPrefix, connectorID: connectorID, userID: userID) { + defaults.set(syncedAt.timeIntervalSince1970, forKey: key) + } metrics.lastDeltaCount = lastDeltaCount - defaults.set(lastDeltaCount != nil, forKey: hasLastDeltaKeyPrefix + connectorID) - if let lastDeltaCount { - defaults.set(lastDeltaCount, forKey: lastDeltaCountKeyPrefix + connectorID) - } else { - defaults.removeObject(forKey: lastDeltaCountKeyPrefix + connectorID) + if let key = scopedKey(hasLastDeltaKeyPrefix, connectorID: connectorID, userID: userID) { + defaults.set(lastDeltaCount != nil, forKey: key) + } + if let key = scopedKey(lastDeltaCountKeyPrefix, connectorID: connectorID, userID: userID) { + if let lastDeltaCount { + defaults.set(lastDeltaCount, forKey: key) + } else { + defaults.removeObject(forKey: key) + } } if let availabilityText { metrics.availabilityText = availabilityText - defaults.set(availabilityText, forKey: availabilityTextKeyPrefix + connectorID) + if let key = scopedKey(availabilityTextKeyPrefix, connectorID: connectorID, userID: userID) { + defaults.set(availabilityText, forKey: key) + } } metricsByID[connectorID] = metrics } private func clearStoredMetrics(for connectorID: String) { - defaults.removeObject(forKey: sourceCountKeyPrefix + connectorID) - defaults.removeObject(forKey: memoryCountKeyPrefix + connectorID) - defaults.removeObject(forKey: lastSyncedAtKeyPrefix + connectorID) - defaults.removeObject(forKey: lastDeltaCountKeyPrefix + connectorID) - defaults.removeObject(forKey: hasLastDeltaKeyPrefix + connectorID) - defaults.removeObject(forKey: availabilityTextKeyPrefix + connectorID) + guard let userID = loadedUserID else { + metricsByID[connectorID] = ConnectorMetrics() + return + } + for prefix in [ + sourceCountKeyPrefix, + memoryCountKeyPrefix, + lastSyncedAtKeyPrefix, + lastDeltaCountKeyPrefix, + hasLastDeltaKeyPrefix, + availabilityTextKeyPrefix, + ] { + if let key = scopedKey(prefix, connectorID: connectorID, userID: userID) { + defaults.removeObject(forKey: key) + } + } metricsByID[connectorID] = ConnectorMetrics() } func refresh() async { - await refreshLocalFilesMetrics() - await refreshAppleNotesMetrics() + reloadForCurrentUserIfNeeded() + let generation = refreshGeneration + let userID = loadedUserID + await refreshLocalFilesMetrics(generation: generation, userID: userID) + await refreshAppleNotesMetrics(generation: generation, userID: userID) + } + + func resetSessionState() { + refreshGeneration += 1 + loadedUserID = nil + metricsByID = Self.emptyMetrics() + } + + private func reloadForCurrentUserIfNeeded() { + let userID = currentUserID() + guard loadedUserID != userID else { return } + loadedUserID = userID + metricsByID = Self.emptyMetrics() + guard let userID else { return } + load(userID: userID) } - private func load() { + private func load(userID: String) { for connector in ImportConnector.all { var metrics = ConnectorMetrics() - if defaults.object(forKey: sourceCountKeyPrefix + connector.id) != nil { - metrics.sourceCount = defaults.integer(forKey: sourceCountKeyPrefix + connector.id) + if let key = scopedKey(sourceCountKeyPrefix, connectorID: connector.id, userID: userID), + defaults.object(forKey: key) != nil + { + metrics.sourceCount = defaults.integer(forKey: key) } - if defaults.object(forKey: memoryCountKeyPrefix + connector.id) != nil { - metrics.memoryCount = defaults.integer(forKey: memoryCountKeyPrefix + connector.id) + if let key = scopedKey(memoryCountKeyPrefix, connectorID: connector.id, userID: userID), + defaults.object(forKey: key) != nil + { + metrics.memoryCount = defaults.integer(forKey: key) } - if defaults.object(forKey: lastSyncedAtKeyPrefix + connector.id) != nil { - let timestamp = defaults.double(forKey: lastSyncedAtKeyPrefix + connector.id) + if let key = scopedKey(lastSyncedAtKeyPrefix, connectorID: connector.id, userID: userID), + defaults.object(forKey: key) != nil + { + let timestamp = defaults.double(forKey: key) if timestamp > 0 { metrics.lastSyncedAt = Date(timeIntervalSince1970: timestamp) } } - if defaults.bool(forKey: hasLastDeltaKeyPrefix + connector.id) { - metrics.lastDeltaCount = defaults.integer(forKey: lastDeltaCountKeyPrefix + connector.id) + if let key = scopedKey(hasLastDeltaKeyPrefix, connectorID: connector.id, userID: userID), + defaults.bool(forKey: key), + let deltaKey = scopedKey(lastDeltaCountKeyPrefix, connectorID: connector.id, userID: userID) + { + metrics.lastDeltaCount = defaults.integer(forKey: deltaKey) + } + if let key = scopedKey(availabilityTextKeyPrefix, connectorID: connector.id, userID: userID) { + metrics.availabilityText = defaults.string(forKey: key) } - metrics.availabilityText = defaults.string(forKey: availabilityTextKeyPrefix + connector.id) metricsByID[connector.id] = metrics } - hydrateLegacyManualImports() + hydrateLegacyManualImports(userID: userID) // A remembered path is not enough to call Apple Notes connected. The // status becomes connected only after the reader proves the store is readable. } - private func hydrateLegacyManualImports() { - let legacyChatGPTCount = defaults.integer(forKey: onboardingChatGPTImportedMemoriesKey) + private func hydrateLegacyManualImports(userID: String) { + let legacyChatGPTCount = defaults.integer( + forKey: scopedManualImportKey(onboardingChatGPTImportedMemoriesKey, userID: userID)) if legacyChatGPTCount > 0, metricsByID["chatgpt"]?.memoryCount == nil { var metrics = metricsByID["chatgpt"] ?? ConnectorMetrics() metrics.memoryCount = legacyChatGPTCount @@ -861,7 +918,8 @@ final class ImportConnectorStatusStore: ObservableObject { metricsByID["chatgpt"] = metrics } - let legacyClaudeCount = defaults.integer(forKey: onboardingClaudeImportedMemoriesKey) + let legacyClaudeCount = defaults.integer( + forKey: scopedManualImportKey(onboardingClaudeImportedMemoriesKey, userID: userID)) if legacyClaudeCount > 0, metricsByID["claude"]?.memoryCount == nil { var metrics = metricsByID["claude"] ?? ConnectorMetrics() metrics.memoryCount = legacyClaudeCount @@ -870,7 +928,7 @@ final class ImportConnectorStatusStore: ObservableObject { } } - private func refreshLocalFilesMetrics() async { + private func refreshLocalFilesMetrics(generation: Int, userID: String?) async { guard let dbQueue = await RewindDatabase.shared.getDatabaseQueue() else { return } do { @@ -889,19 +947,28 @@ final class ImportConnectorStatusStore: ObservableObject { return (count, lastIndexedAt) } + guard canApplyRefresh(generation: generation, userID: userID) else { return } var metrics = metricsByID["local-files"] ?? ConnectorMetrics() metrics.sourceCount = result.count - defaults.set(result.count, forKey: sourceCountKeyPrefix + "local-files") + if let key = scopedKey(sourceCountKeyPrefix, connectorID: "local-files", userID: userID) { + defaults.set(result.count, forKey: key) + } if metrics.lastSyncedAt == nil, let lastIndexedAt = result.lastIndexedAt { metrics.lastSyncedAt = lastIndexedAt - defaults.set(lastIndexedAt.timeIntervalSince1970, forKey: lastSyncedAtKeyPrefix + "local-files") + if let key = scopedKey(lastSyncedAtKeyPrefix, connectorID: "local-files", userID: userID) { + defaults.set(lastIndexedAt.timeIntervalSince1970, forKey: key) + } } if metrics.lastSyncedAt != nil || result.count > 0 { metrics.availabilityText = "On-device index" - defaults.set("On-device index", forKey: availabilityTextKeyPrefix + "local-files") + if let key = scopedKey(availabilityTextKeyPrefix, connectorID: "local-files", userID: userID) { + defaults.set("On-device index", forKey: key) + } } else { metrics.availabilityText = nil - defaults.removeObject(forKey: availabilityTextKeyPrefix + "local-files") + if let key = scopedKey(availabilityTextKeyPrefix, connectorID: "local-files", userID: userID) { + defaults.removeObject(forKey: key) + } } metricsByID["local-files"] = metrics } catch { @@ -909,20 +976,27 @@ final class ImportConnectorStatusStore: ObservableObject { } } - private func refreshAppleNotesMetrics() async { + private func refreshAppleNotesMetrics(generation: Int, userID: String?) async { let status = await AppleNotesReaderService.shared.connectionStatus(maxResults: 250) + guard canApplyRefresh(generation: generation, userID: userID) else { return } switch status { case .connected(let noteCount, _): var metrics = metricsByID["apple-notes"] ?? ConnectorMetrics() metrics.sourceCount = noteCount - defaults.set(noteCount, forKey: sourceCountKeyPrefix + "apple-notes") + if let key = scopedKey(sourceCountKeyPrefix, connectorID: "apple-notes", userID: userID) { + defaults.set(noteCount, forKey: key) + } if metrics.lastSyncedAt == nil { let syncedAt = Date() metrics.lastSyncedAt = syncedAt - defaults.set(syncedAt.timeIntervalSince1970, forKey: lastSyncedAtKeyPrefix + "apple-notes") + if let key = scopedKey(lastSyncedAtKeyPrefix, connectorID: "apple-notes", userID: userID) { + defaults.set(syncedAt.timeIntervalSince1970, forKey: key) + } } metrics.availabilityText = "Private notes accessible" - defaults.set("Private notes accessible", forKey: availabilityTextKeyPrefix + "apple-notes") + if let key = scopedKey(availabilityTextKeyPrefix, connectorID: "apple-notes", userID: userID) { + defaults.set("Private notes accessible", forKey: key) + } metricsByID["apple-notes"] = metrics case .needsAccess(_, let reasonCode), .error(_, let reasonCode): log("ImportConnectorStatusStore: Apple Notes refresh unavailable code=\(reasonCode)") @@ -930,6 +1004,28 @@ final class ImportConnectorStatusStore: ObservableObject { } } + private func canApplyRefresh(generation: Int, userID: String?) -> Bool { + generation == refreshGeneration && userID == loadedUserID + } + + private func scopedKey(_ prefix: String, connectorID: String, userID: String?) -> String? { + guard let userID, !userID.isEmpty else { return nil } + return prefix + userID + "." + connectorID + } + + private func scopedManualImportKey(_ key: String, userID: String) -> String { + key + "." + userID + } + + private func currentUserID() -> String? { + guard let userID = defaults.string(forKey: .authUserId), !userID.isEmpty else { return nil } + return userID + } + + private static func emptyMetrics() -> [String: ConnectorMetrics] { + Dictionary(uniqueKeysWithValues: ImportConnector.all.map { ($0.id, ConnectorMetrics()) }) + } + private func isConnected(connector: ImportConnector, metrics: ConnectorMetrics) -> Bool { if metrics.lastSyncedAt != nil { return true diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift index 4440bbca154..d47f9c2d9ea 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift @@ -2,33 +2,13 @@ import Combine import SwiftUI import OmiTheme -// MARK: - Search Debouncer - -/// Debounces search queries to avoid excessive API calls -class SearchDebouncer: ObservableObject { - /// The input query (set immediately when user types) - @Published var inputQuery: String = "" - /// The debounced query (updated 250ms after user stops typing) - @Published var debouncedQuery: String = "" - private var cancellables = Set() - - init() { - // Observe input and debounce to output - $inputQuery - .debounce(for: .milliseconds(250), scheduler: DispatchQueue.main) - .removeDuplicates() - .sink { [weak self] value in - self?.debouncedQuery = value - } - .store(in: &cancellables) - } -} - // MARK: - Conversations Page struct ConversationsPage: View { @ObservedObject var appState: AppState @Binding var selectedConversation: ServerConversation? + /// Persistent search state — survives page recreation on tab switches. + @ObservedObject var searchModel: ConversationsSearchModel /// When true, renders without internal ScrollViews (for embedding in an outer ScrollView) var embedded: Bool = false @@ -36,12 +16,6 @@ struct ConversationsPage: View { // Compact view mode - persisted preference @AppStorage("conversationsCompactView") private var isCompactView = true - // Search state - @State private var searchQuery: String = "" - @State private var searchResults: [ServerConversation] = [] - @State private var isSearching: Bool = false - @State private var searchError: String? = nil - @StateObject private var searchDebouncer = SearchDebouncer() // Date picker state @State private var showDatePicker: Bool = false @@ -169,7 +143,7 @@ struct ConversationsPage: View { return } - if let conversation = searchResults.first(where: { $0.id == conversationId }) { + if let conversation = searchModel.results.first(where: { $0.id == conversationId }) { present(conversation) return } @@ -188,47 +162,14 @@ struct ConversationsPage: View { // MARK: - Main View with Recording Header + List private var mainConversationsView: some View { - VStack(spacing: 0) { - // Conversations header - HStack { - Text("Conversations") - .scaledFont(size: 18, weight: .semibold) - .foregroundColor(OmiColors.textPrimary) - - Spacer() - - quickNoteButton - - if !appState.isTranscribing { - startRecordingButton - } - } - .padding(.horizontal, 24) - .padding(.top, 18) - .padding(.bottom, 12) - - // Conversation list - conversationListSection - } + // Title lives in the page chrome; status controls live on Home. + conversationListSection } private var quickNoteButton: some View { - Button { + OmiHeaderChip(systemImage: "note.text", title: "Quick Note") { NotificationCenter.default.post(name: .navigateToRewindNotes, object: nil) - } label: { - HStack(spacing: 5) { - Image(systemName: "note.text") - .scaledFont(size: 12) - Text("Quick Note") - .scaledFont(size: 13, weight: .medium) - } - .foregroundColor(OmiColors.textSecondary) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .omiControlSurface( - fill: OmiColors.backgroundSecondary, radius: 18, stroke: OmiColors.border.opacity(0.18)) } - .buttonStyle(.plain) } // MARK: - Conversation List Section @@ -236,51 +177,22 @@ struct ConversationsPage: View { private var conversationListSection: some View { VStack(spacing: 0) { // Section header with search bar and filters - HStack(spacing: 8) { - // Search bar - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .scaledFont(size: 13) - .foregroundColor(OmiColors.textTertiary) - - TextField("Search conversations...", text: $searchQuery) - .textFieldStyle(.plain) - .scaledFont(size: 13) - .foregroundColor(OmiColors.textPrimary) - .onChange(of: searchQuery) { _, newValue in - // Feed input to debouncer - searchDebouncer.inputQuery = newValue - } - .onChange(of: searchDebouncer.debouncedQuery) { _, newValue in - // Debounced value changed - perform search - performSearch(query: newValue) - } - - if !searchQuery.isEmpty { - Button(action: { - searchQuery = "" - searchDebouncer.inputQuery = "" - searchResults = [] - searchError = nil - }) { - Image(systemName: "xmark.circle.fill") - .scaledFont(size: 13) - .foregroundColor(OmiColors.textTertiary) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 11) - .frame(minHeight: 46) - .omiControlSurface( - fill: OmiColors.backgroundSecondary, radius: 18, stroke: OmiColors.border.opacity(0.18)) + HStack(spacing: OmiHeader.controlSpacing) { + OmiSearchField( + placeholder: "Search conversations...", + text: $searchModel.query, + isBusy: searchModel.isSearching, + onClear: { searchModel.clear() } + ) // Filter buttons filterButtonsRow + + quickNoteButton } - .padding(.horizontal, 24) - .padding(.vertical, 12) + .padding(.horizontal, OmiHeader.rowHorizontalPadding) + .padding(.top, OmiHeader.rowTopPadding) + .padding(.bottom, OmiHeader.rowBottomPadding) // Folder tabs strip FolderTabsStrip( @@ -293,7 +205,7 @@ struct ConversationsPage: View { .padding(.bottom, 12) // List - show search results or regular conversations - if !searchQuery.isEmpty { + if !searchModel.query.isEmpty { // Search results view searchResultsView } else { @@ -344,7 +256,7 @@ struct ConversationsPage: View { private var searchResultsView: some View { Group { - if isSearching { + if searchModel.isSearching { VStack(spacing: 12) { ProgressView() Text("Searching...") @@ -352,7 +264,7 @@ struct ConversationsPage: View { .foregroundColor(OmiColors.textTertiary) } .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = searchError { + } else if let error = searchModel.searchError { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") .scaledFont(size: 32) @@ -364,7 +276,7 @@ struct ConversationsPage: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() - } else if searchResults.isEmpty { + } else if searchModel.results.isEmpty { VStack(spacing: 12) { Image(systemName: "magnifyingglass") .scaledFont(size: 32) @@ -394,7 +306,7 @@ struct ConversationsPage: View { @ViewBuilder private var searchResultsContent: some View { let content = LazyVStack(spacing: 8) { - ForEach(searchResults) { conversation in + ForEach(searchModel.results) { conversation in ConversationRowView( conversation: conversation, onTap: { @@ -433,38 +345,6 @@ struct ConversationsPage: View { // MARK: - Search - private func performSearch(query: String) { - guard !query.isEmpty else { - searchResults = [] - searchError = nil - return - } - - isSearching = true - searchError = nil - log("Search: Starting search for '\(query)'") - AnalyticsManager.shared.searchQueryEntered(query: query) - - Task { - do { - let result = try await APIClient.shared.searchConversations( - query: query, - page: 1, - perPage: 50, - includeDiscarded: false - ) - log("Search: Found \(result.items.count) results") - searchResults = result.items - isSearching = false - } catch { - logError("Search: Failed", error: error) - searchError = error.localizedDescription - searchResults = [] - isSearching = false - } - } - } - // MARK: - Filter Buttons private var filterButtonsRow: some View { @@ -485,20 +365,14 @@ struct ConversationsPage: View { } else { Image(systemName: appState.showStarredOnly ? "star.fill" : "star") .scaledFont(size: 12) + .foregroundColor(appState.showStarredOnly ? OmiColors.amber : OmiColors.textSecondary) } Text("Starred") - .scaledFont(size: 12, weight: .medium) + .scaledFont(size: 13, weight: .medium) + .foregroundColor( + appState.showStarredOnly ? OmiColors.textPrimary : OmiColors.textSecondary) } - .foregroundColor(appState.showStarredOnly ? OmiColors.amber : OmiColors.textSecondary) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .omiControlSurface( - fill: appState.showStarredOnly - ? OmiColors.amber.opacity(0.16) : OmiColors.backgroundSecondary, - radius: 16, - stroke: appState.showStarredOnly - ? OmiColors.amber.opacity(0.36) : OmiColors.border.opacity(0.14) - ) + .omiHeaderControl(isActive: appState.showStarredOnly) } .buttonStyle(.plain) .disabled(isFilteringStarred) @@ -518,7 +392,7 @@ struct ConversationsPage: View { } if let date = appState.selectedDateFilter { Text(formatFilterDate(date)) - .scaledFont(size: 12, weight: .medium) + .scaledFont(size: 13, weight: .medium) // Clear button Button(action: { Task { @@ -533,19 +407,13 @@ struct ConversationsPage: View { .buttonStyle(.plain) } else { Text("Date") - .scaledFont(size: 12, weight: .medium) + .scaledFont(size: 13, weight: .medium) } } - .foregroundColor(appState.selectedDateFilter != nil ? .black : OmiColors.textSecondary) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .omiControlSurface( - fill: appState.selectedDateFilter != nil - ? OmiColors.textPrimary : OmiColors.backgroundSecondary, - radius: 16, - stroke: appState.selectedDateFilter != nil - ? OmiColors.border.opacity(0.28) : OmiColors.border.opacity(0.14) + .foregroundColor( + appState.selectedDateFilter != nil ? OmiColors.textPrimary : OmiColors.textSecondary ) + .omiHeaderControl(isActive: appState.selectedDateFilter != nil) } .buttonStyle(.plain) .disabled(isFilteringDate) @@ -734,25 +602,6 @@ struct ConversationsPage: View { // MARK: - Buttons - private var startRecordingButton: some View { - Button(action: { - appState.startTranscription() - }) { - HStack(spacing: 6) { - Image(systemName: "mic.fill") - .scaledFont(size: 12) - Text("Start Recording") - .scaledFont(size: 13, weight: .medium) - } - .foregroundColor(.black) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .omiControlSurface( - fill: OmiColors.textPrimary, radius: 18, stroke: OmiColors.border.opacity(0.2)) - } - .buttonStyle(.plain) - } - } // MARK: - Transcript Notes Divider @@ -794,7 +643,9 @@ private struct TranscriptNotesDivider: View { #if canImport(PreviewsMacros) #Preview { - ConversationsPage(appState: AppState(), selectedConversation: .constant(nil)) + ConversationsPage( + appState: AppState(), selectedConversation: .constant(nil), + searchModel: ConversationsSearchModel()) .frame(width: 600, height: 800) .background(OmiColors.backgroundSecondary) } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index a59951c3cae..1b2c1bb4e81 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -218,8 +218,8 @@ struct DashboardPage: View { @ObservedObject var appProvider: AppProvider @ObservedObject var chatProvider: ChatProvider @ObservedObject var memoriesViewModel: MemoriesViewModel + @ObservedObject var homeStatus: HomeStatusStore @ObservedObject private var deviceProvider = DeviceProvider.shared - @StateObject private var importConnectorStatusStore = ImportConnectorStatusStore() @Binding var selectedIndex: Int @State private var citedConversation: ServerConversation? = nil @State private var selectedCatalogApp: OmiApp? @@ -231,21 +231,6 @@ struct DashboardPage: View { @State private var appsPopupInitialSection: AppsCatalogInitialSection = .imports @State private var appsPopupPresentationID = UUID() @State private var isLoadingCitation = false - @State private var screenshotCount: Int? - // True totals for the "What omi knows" tiles. Without these the tiles showed - // only the loaded page (~50 conversations, ~100 memories), badly undercounting. - @State private var conversationCount: Int? - @State private var memoryCount: Int? - @State private var taskCount: Int? - // Wearable used on this account (any friend/omi-sourced conversation). - // Seeded from UserDefaults so the badge is instant on later launches. - @State private var accountHasOmiDeviceConversations = UserDefaults.standard.bool( - forKey: DashboardPage.omiDeviceHistoryDefaultsKey) - @State private var memoryExportStatuses: [MemoryExportDestination: MemoryExportStatus] = [:] - @State private var lastHomeStatusRefreshAt = Date.distantPast - @State private var isCaptureMonitoring = false - @State private var isTogglingCapture = false - @State private var isTogglingListening = false @AppStorage("dashboardWidgetsCollapsed") private var widgetsCollapsed = false @AppStorage("screenAnalysisEnabled") private var screenAnalysisEnabled = true @AppStorage("transcriptionEnabled") private var transcriptionEnabled = true @@ -260,38 +245,6 @@ struct DashboardPage: View { return appProvider.chatApps.first { $0.id == appId } } - private var captureStatus: HomeStatusState { - if appState.isScreenCaptureKitBroken || appState.isScreenRecordingStale || !appState.hasScreenRecordingPermission { - return .blocked - } - - if isCaptureLive { - return .active - } - - return .inactive - } - - private var isCaptureLive: Bool { - isCaptureMonitoring || ProactiveAssistantsPlugin.shared.isMonitoring - } - - private var listeningCaptureMode: AssistantSettings.SystemAudioCaptureMode { - AssistantSettings.SystemAudioCaptureMode(rawValue: systemAudioCaptureModeRaw) ?? .onlyDuringMeetings - } - - private var listeningModeTitle: String { - switch listeningCaptureMode { - case .always: - return "Always" - case .onlyDuringMeetings: - return appState.isAwaitingMeeting ? "Meetings only" : "In meeting" - case .never: - return "Mic only" - } - } - - private static let omiDeviceHistoryDefaultsKey = "home-omi-device-account-history" private static let homeStageMaxWidth: CGFloat = 1120 private static let homeStageHorizontalPadding: CGFloat = 34 private static let homeAskBarMaxWidth: CGFloat = 640 @@ -344,23 +297,23 @@ struct DashboardPage: View { private var hasOmiDeviceHistory: Bool { deviceProvider.connectedDevice != nil || deviceProvider.pairedDevice != nil - || accountHasOmiDeviceConversations + || homeStatus.accountHasOmiDeviceConversations } /// Real persisted import-connector state (UserDefaults-backed via ImportConnectorStatusStore). private func isImportConnectorConnected(_ connectorID: String) -> Bool { guard let connector = ImportConnector.all.first(where: { $0.id == connectorID }) else { return false } - return importConnectorStatusStore.snapshot(for: connector).isConnected + return homeStatus.importConnectorStatusStore.snapshot(for: connector).isConnected } private func isMCPDestinationConnected(_ destination: MemoryExportDestination) -> Bool { switch destination { case .claude, .claudeCode: - return [.claude, .claudeCode].contains { memoryExportStatuses[$0]?.hasConnection == true } + return [.claude, .claudeCode].contains { homeStatus.memoryExportStatuses[$0]?.hasConnection == true } case .chatgpt, .codex: - return [.chatgpt, .codex].contains { memoryExportStatuses[$0]?.hasConnection == true } + return [.chatgpt, .codex].contains { homeStatus.memoryExportStatuses[$0]?.hasConnection == true } default: - return memoryExportStatuses[destination]?.hasConnection == true + return homeStatus.memoryExportStatuses[destination]?.hasConnection == true } } @@ -394,7 +347,7 @@ struct DashboardPage: View { ImportConnectorSheet( connector: connector, appState: appState, - statusStore: importConnectorStatusStore, + statusStore: homeStatus.importConnectorStatusStore, onDismiss: { selectedImportConnector = nil } @@ -404,7 +357,7 @@ struct DashboardPage: View { .dismissableSheet(item: legacySelectedExportDestination) { destination in ConnectDestinationSheet( destination: destination, - statuses: $memoryExportStatuses, + statuses: $homeStatus.memoryExportStatuses, onDismiss: { selectedExportDestination = nil } @@ -431,24 +384,13 @@ struct DashboardPage: View { if PostOnboardingPromptSuggestions.shouldShowPopup && !postOnboardingSuggestions.isEmpty { NotificationCenter.default.post(name: .showTryAskingPopup, object: nil) } - syncCaptureState() reportHomeAutomationMode() - Task { await refreshHomeStatusData(force: true) } + Task { await homeStatus.refresh(force: false) } } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in viewModel.refreshGoals() appState.checkAllPermissions() - syncCaptureState() - Task { await refreshHomeStatusData(force: false) } - } - .onReceive(NotificationCenter.default.publisher(for: .assistantMonitoringStateDidChange)) { _ in - syncCaptureState() - } - .onReceive(NotificationCenter.default.publisher(for: .screenCapturePermissionLost)) { _ in - syncCaptureState() - } - .onReceive(NotificationCenter.default.publisher(for: .screenCaptureKitBroken)) { _ in - syncCaptureState() + Task { await homeStatus.refresh(force: false) } } // Clicking into the ask bar reveals the inline chat; the same is true // when focus lands there via keyboard (Tab / Full Keyboard Access). @@ -574,6 +516,11 @@ struct DashboardPage: View { ZStack(alignment: .topTrailing) { HomeCanvasBackground() + InAppStatusControls(appState: appState) + .padding(.top, 16) + .padding(.trailing, 20) + .zIndex(2) + // Clicking anywhere outside the chat / connect panel collapses // back to the hub (panels and the ask bar consume their own // clicks above this catcher). @@ -593,11 +540,6 @@ struct DashboardPage: View { // Full Keyboard Access. .accessibilityHidden(isHomeModalPresented) - homeHeader - .padding(.horizontal, Self.homeStageHorizontalPadding) - .padding(.top, 26) - .accessibilityHidden(isHomeModalPresented) - appsPopupOverlay( contentWidth: proxy.size.width, panelWidth: panelWidth, @@ -761,29 +703,27 @@ struct DashboardPage: View { .padding(.horizontal, 8) .padding(.vertical, 6) } - // Barely-there card so the chat reads as a bounded surface while still - // dissolving into the ambient Home canvas. + // Barely-there card so the chat reads as a bounded surface — making it + // obvious the canvas around it is clickable (and closes the chat). + // The stroke is a soft gradient that dissolves toward the bottom. .background( RoundedRectangle(cornerRadius: 26, style: .continuous) - .fill( + .fill(Color.white.opacity(0.012)) + ) + .overlay( + RoundedRectangle(cornerRadius: 26, style: .continuous) + .stroke( LinearGradient( colors: [ - Color.white.opacity(0.018), - HomePalette.stageGlow.opacity(0.014), - Color.white.opacity(0.006), + HomePalette.hairline.opacity(0.45), + HomePalette.hairline.opacity(0.10), ], startPoint: .top, endPoint: .bottom - ) + ), + lineWidth: 1 ) ) - .overlay( - RoundedRectangle(cornerRadius: 26, style: .continuous) - .stroke(HomePalette.stageGlow.opacity(0.10), lineWidth: 1) - .blur(radius: 2.5) - .opacity(0.65) - ) - .shadow(color: HomePalette.stageGlow.opacity(0.055), radius: 28, y: 8) .frame(maxWidth: Self.homeStagePanelMaxWidth) .padding(.horizontal, Self.homeStageHorizontalPadding) } @@ -897,7 +837,7 @@ struct DashboardPage: View { private func reportHomeAutomationMode() { guard DesktopAutomationLaunchOptions.isEnabled else { return } let modeLabel = useLegacyHomeDesign ? nil : homeMode.automationLabel - DesktopAutomationStateStore.shared.updateLiveFields { snapshot in + _ = DesktopAutomationStateStore.shared.updateLiveFields { snapshot in snapshot.homeMode = modeLabel snapshot.updatedAt = ISO8601DateFormatter().string(from: Date()) } @@ -1105,7 +1045,7 @@ struct DashboardPage: View { ImportConnectorSheet( connector: connector, appState: appState, - statusStore: importConnectorStatusStore, + statusStore: homeStatus.importConnectorStatusStore, onDismiss: { dismissHomeConnectSheet() } @@ -1113,7 +1053,7 @@ struct DashboardPage: View { } else if let destination = selectedExportDestination { ConnectDestinationSheet( destination: destination, - statuses: $memoryExportStatuses, + statuses: $homeStatus.memoryExportStatuses, onDismiss: { dismissHomeConnectSheet() } @@ -1134,38 +1074,6 @@ struct DashboardPage: View { ) } - private var homeHeader: some View { - HStack { - Spacer() - HStack(spacing: 10) { - HomeStatusButton( - title: "Capture", - systemImage: "viewfinder", - status: captureStatus, - isToggling: isTogglingCapture, - action: toggleCapture - ) - - HomeListeningStatusButton( - title: "Listening", - systemImage: appState.isTranscribing ? "waveform.circle.fill" : "mic.circle", - status: appState.isTranscribing ? .active : .inactive, - modeTitle: listeningModeTitle, - isMeetingsOnly: listeningCaptureMode == .onlyDuringMeetings, - isToggling: isTogglingListening, - action: toggleListening, - modeAction: toggleListeningMode - ) - - HomeSettingsMenuButton( - onRefer: openReferFriend, - onDiscord: openDiscord, - onSettings: { navigate(to: .settings) } - ) - } - } - .frame(height: 36) - } private var sourceColumnHeader: some View { VStack(alignment: .leading, spacing: 4) { @@ -1238,22 +1146,22 @@ struct DashboardPage: View { } private var conversationMetricValue: String { - formattedCount(conversationCount ?? appState.totalConversationsCount ?? appState.conversations.count) + formattedCount(homeStatus.conversationCount ?? appState.totalConversationsCount ?? appState.conversations.count) } private var taskMetricValue: String { - formattedCount(taskCount ?? incompleteTaskCount) + formattedCount(homeStatus.taskCount ?? incompleteTaskCount) } private var memoryMetricValue: String { - let count = memoryCount ?? (memoriesViewModel.totalMemoriesCount > 0 + let count = homeStatus.memoryCount ?? (memoriesViewModel.totalMemoriesCount > 0 ? memoriesViewModel.totalMemoriesCount : memoriesViewModel.memories.count) return formattedCount(count) } private var screenshotMetricValue: String { - screenshotCount.map(formattedCount) ?? "—" + homeStatus.screenshotCount.map(formattedCount) ?? "—" } private func navigate(to item: SidebarNavItem) { @@ -1330,173 +1238,12 @@ struct DashboardPage: View { selectedExportDestination = nil } - private func openReferFriend() { - if let url = URL(string: "https://affiliate.omi.me") { - NSWorkspace.shared.open(url) - } - } - - private func openDiscord() { - if let url = URL(string: "https://discord.com/invite/8MP3b9ymvx") { - NSWorkspace.shared.open(url) - } - } - private func openOmiDeviceWebsite() { if let url = URL(string: "https://www.omi.me") { NSWorkspace.shared.open(url) } } - private func toggleListening() { - let enabled = !appState.isTranscribing - if enabled && !appState.hasMicrophonePermission { - appState.requestMicrophonePermission() - return - } - - isTogglingListening = true - transcriptionEnabled = enabled - AssistantSettings.shared.transcriptionEnabled = enabled - AnalyticsManager.shared.settingToggled(setting: "transcription", enabled: enabled) - NotificationCenter.default.post( - name: .toggleTranscriptionRequested, - object: nil, - userInfo: ["enabled": enabled] - ) - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isTogglingListening = false - } - } - - private func toggleListeningMode() { - let nextMode: AssistantSettings.SystemAudioCaptureMode = - listeningCaptureMode == .onlyDuringMeetings ? .always : .onlyDuringMeetings - systemAudioCaptureModeRaw = nextMode.rawValue - AssistantSettings.shared.systemAudioCaptureMode = nextMode - AnalyticsManager.shared.settingToggled( - setting: "meetings_only_listening", - enabled: nextMode == .onlyDuringMeetings - ) - } - - private func toggleCapture() { - syncCaptureState() - let enabled = !isCaptureLive - isTogglingCapture = true - - if enabled { - ProactiveAssistantsPlugin.shared.refreshScreenRecordingPermission() - guard ProactiveAssistantsPlugin.shared.hasScreenRecordingPermission else { - screenAnalysisEnabled = false - isCaptureMonitoring = false - isTogglingCapture = false - ScreenCaptureService.requestScreenRecordingAccessAndOpenSettings() - return - } - } - - screenAnalysisEnabled = enabled - AssistantSettings.shared.screenAnalysisEnabled = enabled - AnalyticsManager.shared.settingToggled(setting: "monitoring", enabled: enabled) - - if enabled { - ProactiveAssistantsPlugin.shared.startMonitoring { success, _ in - DispatchQueue.main.async { - isTogglingCapture = false - isCaptureMonitoring = ProactiveAssistantsPlugin.shared.isMonitoring - if !success { - screenAnalysisEnabled = false - AssistantSettings.shared.screenAnalysisEnabled = false - isCaptureMonitoring = false - } - } - } - } else { - ProactiveAssistantsPlugin.shared.stopMonitoring() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isTogglingCapture = false - isCaptureMonitoring = false - } - } - } - - private func syncCaptureState() { - ProactiveAssistantsPlugin.shared.refreshScreenRecordingPermission() - screenAnalysisEnabled = AssistantSettings.shared.screenAnalysisEnabled - isCaptureMonitoring = ProactiveAssistantsPlugin.shared.isMonitoring - } - - private func loadScreenshotCount() async { - let stats = await RewindIndexer.shared.getStats() - await MainActor.run { - screenshotCount = stats?.total - } - } - - /// Refreshes Home-only status tiles and connector rows. Forced loads run - /// when the Home view is recreated; activation-triggered loads share the - /// app-wide cooldown so Cmd-Tab bursts do not rescan configs or hit count - /// endpoints repeatedly while Home is still mounted. - private func refreshHomeStatusData(force: Bool) async { - let shouldRefresh = await MainActor.run { - let now = Date() - if !force, - !PollingConfig.shouldAllowActivationRefresh( - now: now, - lastRefresh: lastHomeStatusRefreshAt - ) { - return false - } - lastHomeStatusRefreshAt = now - return true - } - guard shouldRefresh else { return } - - async let importConnectorStatuses: Void = importConnectorStatusStore.refresh() - async let screenshots: Void = loadScreenshotCount() - async let knowledgeCounts: Void = loadKnowledgeCounts() - async let exportStatuses: Void = loadMemoryExportStatuses() - _ = await (importConnectorStatuses, screenshots, knowledgeCounts, exportStatuses) - } - - private func loadMemoryExportStatuses() async { - let statuses = await MemoryExportService.shared.allStatuses() - await MainActor.run { - memoryExportStatuses = statuses - } - } - - /// Load the true totals behind the "What omi knows" tiles. Conversations come - /// from the server count endpoint (not stored locally); memories and tasks are - /// counted from the synced local DB — the same totals the detail pages show. - private func loadKnowledgeCounts() async { - async let convos = try? APIClient.shared.getConversationsCount(includeDiscarded: false) - async let mems = try? MemoryStorage.shared.getLocalMemoriesCount() - // Open tasks only (matches the "Tasks" label and the old tile's intent — - // the old value just under-counted, capping each bucket at a 7-day window). - async let tasks = try? ActionItemStorage.shared.getLocalActionItemsCount(completed: false) - let shouldLoadDeviceHistory = await MainActor.run { !accountHasOmiDeviceConversations } - async let deviceHistory = shouldLoadDeviceHistory ? loadOmiDeviceHistory() : nil - let (c, m, t, d) = await (convos, mems, tasks, deviceHistory) - await MainActor.run { - if let c { conversationCount = c } - if let m { memoryCount = m } - if let t { taskCount = t } - // Sticky: device history never un-happens; keep the badge across - // launches and network failures once observed. - if d == true { - accountHasOmiDeviceConversations = true - UserDefaults.standard.set(true, forKey: Self.omiDeviceHistoryDefaultsKey) - } - } - } - - private func loadOmiDeviceHistory() async -> Bool? { - try? await APIClient.shared.hasOmiDeviceConversations() - } - private func formattedCount(_ count: Int) -> String { count.formatted() } @@ -1745,7 +1492,9 @@ struct DashboardPage: View { // MARK: - Home Components -private enum HomePalette { +/// Home design palette — internal so shared components (toolbar status +/// controls, capture state) keep rendering with the exact Home look. +enum HomePalette { static let paper = Color(red: 0.018, green: 0.019, blue: 0.021) static let panel = Color(red: 0.045, green: 0.046, blue: 0.052) static let tile = Color(red: 0.078, green: 0.078, blue: 0.088) @@ -3493,231 +3242,6 @@ private struct HomeSectionHeader: View { } } -private enum HomeStatusState { - case active - case inactive - case blocked - - var indicator: Color { - switch self { - case .active: - return HomePalette.green - case .inactive: - return HomePalette.faint - case .blocked: - return Color(red: 1.0, green: 0.24, blue: 0.30) - } - } - - var text: String { - switch self { - case .active: - return "On" - case .inactive: - return "Off" - case .blocked: - return "Blocked" - } - } - - var isActive: Bool { - if case .active = self { return true } - return false - } - - var isBlocked: Bool { - if case .blocked = self { return true } - return false - } -} - -private struct HomeStatusButton: View { - let title: String - let systemImage: String - let status: HomeStatusState - let isToggling: Bool - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 8) { - ZStack { - if isToggling { - ProgressView() - .controlSize(.small) - .scaleEffect(0.55) - } else { - Image(systemName: systemImage) - .scaledFont(size: 13, weight: .semibold) - } - } - .frame(width: 18, height: 18) - - Text(title) - .scaledFont(size: 12, weight: .semibold) - .lineLimit(1) - } - .foregroundStyle(status.isActive ? HomePalette.ink : (status.isBlocked ? status.indicator : HomePalette.muted)) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(height: 34) - .background( - Capsule(style: .continuous) - .fill(statusFill) - ) - .overlay( - Capsule(style: .continuous) - .stroke(statusStroke, lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .disabled(isToggling) - .onHover { isHovering = $0 } - .help("\(title): \(status.text)") - .accessibilityLabel("\(title) \(status.text)") - } - - private var statusFill: Color { - if status.isActive { - return HomePalette.green.opacity(isHovering ? 0.20 : 0.12) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.16 : 0.10) - } - return isHovering ? HomePalette.tileHover : HomePalette.panel - } - - private var statusStroke: Color { - if status.isActive { - return HomePalette.green.opacity(0.38) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.54 : 0.38) - } - return HomePalette.hairline.opacity(isHovering ? 0.8 : 0.58) - } -} - -private struct HomeListeningStatusButton: View { - let title: String - let systemImage: String - let status: HomeStatusState - let modeTitle: String - let isMeetingsOnly: Bool - let isToggling: Bool - let action: () -> Void - let modeAction: () -> Void - - // Single pill-level hover flag so moving between the title and the mode - // toggle never flickers the revealed controls. - @State private var isHovering = false - - var body: some View { - HStack(spacing: 0) { - Button(action: action) { - HStack(spacing: 8) { - ZStack { - if isToggling { - ProgressView() - .controlSize(.small) - .scaleEffect(0.55) - } else { - Image(systemName: systemImage) - .scaledFont(size: 13, weight: .semibold) - } - } - .frame(width: 18, height: 18) - - VStack(alignment: .leading, spacing: 1) { - Text(title) - .scaledFont(size: 12, weight: .semibold) - .lineLimit(1) - - // Mode ("Always" / "In meeting" / …) is revealed only on - // hover to keep the resting pill clean. - if isHovering { - Text(modeTitle) - .scaledFont(size: 8, weight: .medium) - .foregroundStyle(status.isActive ? HomePalette.secondary : HomePalette.muted) - .lineLimit(1) - .transition(.opacity) - } - } - } - .padding(.leading, 12) - .padding(.trailing, 8) - .frame(height: 34) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(isToggling) - .help("Listening: \(status.text), \(modeTitle)") - .accessibilityLabel("Listening \(status.text), \(modeTitle)") - - // Divider + mode toggle are revealed only on hover to keep the - // resting pill compact. - if isHovering { - Rectangle() - .fill(HomePalette.hairline.opacity(0.65)) - .frame(width: 1, height: 18) - .transition(.opacity) - - Button(action: modeAction) { - Image(systemName: isMeetingsOnly ? "person.2.fill" : "person.fill") - .scaledFont(size: 11, weight: .semibold) - .foregroundStyle(modeIconColor) - .frame(width: 30, height: 34) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help(isMeetingsOnly ? "Switch to always listening" : "Switch to meetings only") - .accessibilityLabel(isMeetingsOnly ? "Switch Listening to Always" : "Switch Listening to Meetings Only") - .transition(.opacity) - } - } - .foregroundStyle(status.isActive ? HomePalette.ink : (status.isBlocked ? status.indicator : HomePalette.muted)) - .background( - Capsule(style: .continuous) - .fill(statusFill) - ) - .overlay( - Capsule(style: .continuous) - .stroke(statusStroke, lineWidth: 1) - ) - .contentShape(Capsule()) - .frame(height: 34) - .onHover { isHovering = $0 } - .animation(.easeInOut(duration: 0.14), value: isHovering) - } - - private var modeIconColor: Color { - status.isActive ? HomePalette.green : HomePalette.muted - } - - private var statusFill: Color { - if status.isActive { - return HomePalette.green.opacity(isHovering ? 0.20 : 0.12) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.16 : 0.10) - } - return isHovering ? HomePalette.tileHover : HomePalette.panel - } - - private var statusStroke: Color { - if status.isActive { - return HomePalette.green.opacity(0.38) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.54 : 0.38) - } - return HomePalette.hairline.opacity(isHovering ? 0.8 : 0.58) - } -} - private struct HomeIconActionButton: View { let title: String let systemImage: String @@ -3748,85 +3272,6 @@ private struct HomeIconActionButton: View { } } -private struct HomeSettingsMenuButton: View { - let onRefer: () -> Void - let onDiscord: () -> Void - let onSettings: () -> Void - - @State private var isHovering = false - @State private var isPresented = false - - var body: some View { - Button { - isPresented.toggle() - } label: { - ZStack { - Circle() - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile.opacity(0.86)) - .overlay( - Circle() - .stroke(HomePalette.hairline.opacity(isHovering ? 0.9 : 0.68), lineWidth: 1) - ) - - Image(systemName: "gearshape.fill") - .scaledFont(size: 14, weight: .semibold) - .foregroundStyle(isHovering ? HomePalette.ink : HomePalette.secondary) - } - .frame(width: 34, height: 34) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .popover(isPresented: $isPresented, arrowEdge: .top) { - VStack(alignment: .leading, spacing: 4) { - popoverButton(title: "Refer a Friend", systemImage: "gift.fill") { - isPresented = false - onRefer() - } - - popoverButton(title: "Discord", systemImage: "message.fill") { - isPresented = false - onDiscord() - } - - Divider() - .padding(.vertical, 3) - - popoverButton(title: "Settings", systemImage: "gearshape.fill") { - isPresented = false - onSettings() - } - } - .padding(8) - .frame(width: 190) - .background(HomePalette.panel) - } - .help("Settings") - .accessibilityLabel("Settings menu") - } - - private func popoverButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 10) { - Image(systemName: systemImage) - .scaledFont(size: 13, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - .frame(width: 18) - - Text(title) - .scaledFont(size: 13, weight: .medium) - .foregroundStyle(HomePalette.ink) - - Spacer(minLength: 0) - } - .padding(.horizontal, 9) - .padding(.vertical, 7) - .contentShape(.rect(cornerRadius: 8)) - } - .buttonStyle(.plain) - } -} - private struct HomeConnectorCard: View { let title: String let subtitle: String @@ -4112,7 +3557,7 @@ private struct HomeAIButton: View { appState: AppState(), appProvider: AppProvider(), chatProvider: ChatProvider(), - memoriesViewModel: MemoriesViewModel(), + memoriesViewModel: MemoriesViewModel(), homeStatus: HomeStatusStore(), selectedIndex: .constant(0) ) .frame(width: 800, height: 600) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift index 837b5a2543d..32bac408809 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift @@ -1356,6 +1356,8 @@ class MemoriesViewModel: ObservableObject { struct MemoriesPage: View { @ObservedObject var viewModel: MemoriesViewModel + let graphViewModel: MemoryGraphViewModel + let appState: AppState @State private var showCategoryFilter = false @State private var categorySearchText = "" @State private var pendingSelectedTags: Set = [] @@ -1495,36 +1497,12 @@ struct MemoriesPage: View { // MARK: - Header private var header: some View { - HStack(spacing: 12) { - // Search field - HStack(spacing: 10) { - if viewModel.isSearching || viewModel.isLoadingFiltered { - ProgressView() - .scaleEffect(0.7) - .frame(width: 14, height: 14) - } else { - Image(systemName: "magnifyingglass") - .foregroundColor(OmiColors.textTertiary) - } - - TextField("Search memories...", text: $viewModel.searchText) - .textFieldStyle(.plain) - .foregroundColor(OmiColors.textPrimary) - - if !viewModel.searchText.isEmpty { - Button { - viewModel.searchText = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundColor(OmiColors.textTertiary) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .frame(minHeight: 46) - .omiControlSurface(fill: OmiColors.backgroundTertiary, radius: 18) + HStack(spacing: OmiHeader.controlSpacing) { + OmiSearchField( + placeholder: "Search memories...", + text: $viewModel.searchText, + isBusy: viewModel.isSearching || viewModel.isLoadingFiltered + ) if viewModel.canonicalLifecycleExposed { // Layer filter dropdown. Default is product default access: Short-term + Long-term. @@ -1543,25 +1521,12 @@ struct MemoriesPage: View { .help(filter.description) } } label: { - HStack(spacing: 6) { - Image(systemName: viewModel.selectedLayerFilter == .archive ? "archivebox" : "clock.badge.checkmark") - .scaledFont(size: 12) - Text(viewModel.selectedLayerFilter.displayName) - .scaledFont(size: 13, weight: viewModel.selectedLayerFilter == .defaultAccess ? .regular : .medium) - Image(systemName: "chevron.down") - .scaledFont(size: 10) - } - .foregroundColor( - viewModel.selectedLayerFilter == .defaultAccess ? OmiColors.textSecondary : OmiColors.textPrimary - ) - .padding(.horizontal, 14) - .padding(.vertical, 12) - .frame(minHeight: 46) - .omiControlSurface( - fill: viewModel.selectedLayerFilter == .defaultAccess - ? OmiColors.backgroundTertiary : OmiColors.backgroundRaised, - radius: 18, - stroke: viewModel.selectedLayerFilter == .defaultAccess ? nil : OmiColors.border.opacity(0.6) + OmiHeaderChipLabel( + systemImage: viewModel.selectedLayerFilter == .archive + ? "archivebox" : "clock.badge.checkmark", + title: viewModel.selectedLayerFilter.displayName, + isActive: viewModel.selectedLayerFilter != .defaultAccess, + showsChevron: true ) } .menuStyle(.button) @@ -1569,96 +1534,47 @@ struct MemoriesPage: View { .help("Default shows Short-term + Long-term. Archive is explicit.") } - Button { + OmiHeaderChip( + systemImage: "desktopcomputer", + title: "This device", + isActive: viewModel.filterThisDeviceOnly + ) { viewModel.filterThisDeviceOnly.toggle() - } label: { - HStack(spacing: 6) { - Image(systemName: "desktopcomputer") - .scaledFont(size: 12) - Text("This device") - .scaledFont(size: 13, weight: viewModel.filterThisDeviceOnly ? .medium : .regular) - } - .foregroundColor( - viewModel.filterThisDeviceOnly ? OmiColors.textPrimary : OmiColors.textSecondary - ) - .padding(.horizontal, 14) - .padding(.vertical, 12) - .frame(minHeight: 46) - .omiControlSurface( - fill: viewModel.filterThisDeviceOnly - ? OmiColors.backgroundRaised : OmiColors.backgroundTertiary, - radius: 18, - stroke: viewModel.filterThisDeviceOnly ? OmiColors.border.opacity(0.6) : nil - ) } - .buttonStyle(.plain) .help("Show memories captured on this Mac") // Category filter dropdown - Button { + OmiHeaderChip( + systemImage: "line.3.horizontal.decrease", + title: categoryFilterLabel, + isActive: !viewModel.selectedTags.isEmpty, + showsChevron: true + ) { pendingSelectedTags = viewModel.selectedTags categorySearchText = "" showCategoryFilter = true - } label: { - HStack(spacing: 6) { - Image(systemName: "line.3.horizontal.decrease") - .scaledFont(size: 12) - Text(categoryFilterLabel) - .scaledFont(size: 13, weight: viewModel.selectedTags.isEmpty ? .regular : .medium) - Image(systemName: "chevron.down") - .scaledFont(size: 10) - } - .foregroundColor( - viewModel.selectedTags.isEmpty ? OmiColors.textSecondary : OmiColors.textPrimary - ) - .padding(.horizontal, 14) - .padding(.vertical, 12) - .frame(minHeight: 46) - .omiControlSurface( - fill: viewModel.selectedTags.isEmpty - ? OmiColors.backgroundTertiary : OmiColors.backgroundRaised, - radius: 18, - stroke: viewModel.selectedTags.isEmpty ? nil : OmiColors.border.opacity(0.6) - ) } - .buttonStyle(.plain) .popover(isPresented: $showCategoryFilter, arrowEdge: .bottom) { categoryFilterPopover } // Add Memory button (icon only) - Button { + OmiHeaderIconButton(systemImage: "plus") { viewModel.showingAddMemory = true - } label: { - Image(systemName: "plus") - .scaledFont(size: 14) - .foregroundColor(.black) - .frame(width: 42, height: 42) - .background(OmiColors.textPrimary) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } - .buttonStyle(.plain) .help("Add Memory") // Management menu - Button { + OmiHeaderIconButton(systemImage: "chevron.down") { showManagementMenu = true - } label: { - Image(systemName: "chevron.down") - .scaledFont(size: 12, weight: .medium) - .foregroundColor(.black) - .frame(width: 42, height: 42) - .background(OmiColors.textPrimary) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } - .buttonStyle(.plain) .popover(isPresented: $showManagementMenu, arrowEdge: .bottom) { managementMenuPopover } } - .padding(.horizontal, 28) - .padding(.top, 24) - .padding(.bottom, 20) + .padding(.horizontal, OmiHeader.rowHorizontalPadding) + .padding(.top, OmiHeader.rowTopPadding) + .padding(.bottom, OmiHeader.rowBottomPadding) .alert("Delete Default Memories?", isPresented: $viewModel.showingDeleteAllConfirmation) { Button("Cancel", role: .cancel) {} Button("Delete Default Memories", role: .destructive) { @@ -1954,7 +1870,7 @@ struct MemoriesPage: View { private var memoryList: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 14) { - MemoryGraphInlineCard() + MemoryGraphInlineCard(viewModel: graphViewModel) LazyVStack(spacing: 10) { ForEach(viewModel.filteredMemories) { memory in diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/ForceDirectedSimulation.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/ForceDirectedSimulation.swift index 4d75b15e905..190bea85425 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/ForceDirectedSimulation.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/ForceDirectedSimulation.swift @@ -68,6 +68,7 @@ class ForceDirectedSimulation { let maxSpeed: Float = 40 private var tickCount = 0 + private(set) var lastStepEnergy: Float = 0 private var stableFrameCount = 0 private let stableThreshold: Float = 0.2 private let stableFramesRequired = 10 @@ -269,11 +270,143 @@ class ForceDirectedSimulation { } /// Run multiple physics steps synchronously (for initial layout) - /// Bypasses tick counting for full-speed computation. + /// Bypasses tick counting for full-speed computation. `ticks` is a + /// budget, not a quota — the loop exits as soon as the layout settles + /// (total kinetic energy below threshold for a run of steps). + /// Bulk layout for the initial build. Same math as `runPhysicsStep`, + /// but batched over contiguous value arrays: in unoptimized dev builds + /// the per-property class access in the hot loop is dominated by ARC + /// retain/release traffic, and the array form sidesteps it entirely. + /// Exits early when total kinetic energy plateaus (no fixed threshold + /// to mis-tune — "stopped improving" is the criterion). func runSync(ticks: Int) { - for _ in 0.. 0 else { return } + + // Snapshot node state into contiguous buffers + var positions = [SIMD3](repeating: .zero, count: nodeCount) + var velocities = [SIMD3](repeating: .zero, count: nodeCount) + var forces = [SIMD3](repeating: .zero, count: nodeCount) + var fixed = [Bool](repeating: false, count: nodeCount) + var indexOf: [String: Int] = [:] + indexOf.reserveCapacity(nodeCount) + for (index, node) in nodes.enumerated() { + positions[index] = node.position + velocities[index] = node.velocity + fixed[index] = node.isFixed + indexOf[node.id] = index + } + let springs: [(source: Int, target: Int)] = edges.compactMap { edge in + guard edge.affectsPhysics, + let source = indexOf[edge.sourceId], + let target = indexOf[edge.targetId] else { return nil } + return (source, target) + } + + var previousEnergy = Float.greatestFiniteMagnitude + var plateauSteps = 0 + var executedSteps = 0 + + for step in 0.. 0 else { continue } + let force = (delta / dist) * ((dist - restLength) * attraction) + if !fixed[spring.source] { forces[spring.source] += force } + if !fixed[spring.target] { forces[spring.target] -= force } + } + + // 4. Center gravity + 5. integrate + var totalEnergy: Float = 0 + for i in 0.. maxSpeed { + velocities[i] = simd_normalize(velocities[i]) * maxSpeed + } + positions[i] += velocities[i] + totalEnergy += speed * speed + } + + // 6. Plateau detection: layout has stopped improving when energy + // change stays below 1% for a sustained run of steps. + let delta = abs(previousEnergy - totalEnergy) + if delta < previousEnergy * 0.01 { + plateauSteps += 1 + if plateauSteps >= 20 { + logPerf( + "MemoryGraph: layout converged after \(executedSteps)/\(ticks) ticks (energy \(totalEnergy))" + ) + break + } + } else { + plateauSteps = 0 + } + previousEnergy = totalEnergy + lastStepEnergy = totalEnergy + } + + if plateauSteps < 20 { + logPerf( + "MemoryGraph: layout budget exhausted (\(executedSteps) ticks, energy \(lastStepEnergy))" + ) + } + + // Write settled state back to the node objects + for (index, node) in nodes.enumerated() { + node.position = positions[index] + node.velocity = velocities[index] } + stableFrameCount = stableFramesRequired + } + + /// Snapshot of every node's settled position, for layout caching. + func layoutPositions() -> [String: SIMD3] { + var positions: [String: SIMD3] = [:] + for node in nodes { + positions[node.id] = node.position + } + return positions + } + + /// Restore a previously settled layout. Returns false (leaving positions + /// untouched for a fresh simulation) unless every node is covered. + func applyLayout(_ positions: [String: SIMD3]) -> Bool { + for node in nodes where !node.isFixed { + guard positions[node.id] != nil else { return false } + } + for node in nodes where !node.isFixed { + if let position = positions[node.id] { + node.position = position + node.velocity = .zero + } + } + stableFrameCount = stableFramesRequired + return true } /// One full physics step (no tick-skipping) @@ -330,6 +463,7 @@ class ForceDirectedSimulation { } // 5. Update velocities and positions + var totalEnergy: Float = 0 for node in nodes where !node.isFixed { node.velocity += node.force * dt node.velocity *= damping @@ -339,6 +473,16 @@ class ForceDirectedSimulation { node.velocity = simd_normalize(node.velocity) * maxSpeed } node.position += node.velocity + totalEnergy += speed * speed + } + + // 6. Stability tracking (shared with the animated tick path) so + // runSync can stop as soon as the layout has settled. + lastStepEnergy = totalEnergy + if totalEnergy < stableThreshold { + stableFrameCount += 1 + } else { + stableFrameCount = 0 } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift index 4991fba95dc..15df9adec5b 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift @@ -5,7 +5,7 @@ import OmiTheme // MARK: - Memory Graph Page struct MemoryGraphPage: View { - @StateObject private var viewModel = MemoryGraphViewModel() + @ObservedObject var viewModel: MemoryGraphViewModel @Environment(\.dismiss) private var dismiss var body: some View { @@ -70,7 +70,7 @@ struct MemoryGraphPage: View { } struct MemoryGraphInlineCard: View { - @StateObject private var viewModel = MemoryGraphViewModel() + @ObservedObject var viewModel: MemoryGraphViewModel var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -203,6 +203,14 @@ class MemoryGraphViewModel: ObservableObject { private var nodeSceneNodes: [String: SCNNode] = [:] private var edgeSceneNodes: [String: SCNNode] = [:] private var isAnimating = true + // Revisit guards: the VM is session-persistent (ViewModelContainer), so a + // page visit renders the existing scene instantly. Non-forced loads are + // TTL-throttled, single-flight, and skip the expensive re-simulation when + // the fetched graph is unchanged. + private var lastLoadedAt = Date.distantPast + private var isPreparing = false + private var hasRunEmptyBootstrap = false + private var loadedGraphSignature: Int? init() { setupCamera() @@ -243,9 +251,24 @@ class MemoryGraphViewModel: ObservableObject { // MARK: - Load Graph func prepareGraph() async { + guard !isPreparing else { return } + isPreparing = true + defer { isPreparing = false } + + // A rendered scene within the cooldown is served as-is — visiting the + // page must not refetch, re-run the force layout, or reset the camera. + if !isEmpty, + !PollingConfig.shouldAllowActivationRefresh(lastRefresh: lastLoadedAt) + { + return + } + await loadGraph() - if isEmpty { - await rebuildGraph() + if isEmpty && !hasRunEmptyBootstrap { + // First-session bootstrap for sparse accounts: ask the backend to build + // the graph, then poll for it. Run once per session — not per visit. + guard await rebuildGraph() else { return } + hasRunEmptyBootstrap = true for _ in 1...10 { try? await Task.sleep(nanoseconds: 3_000_000_000) await loadGraph() @@ -255,44 +278,142 @@ class MemoryGraphViewModel: ObservableObject { } func loadGraph() async { - isLoading = true - defer { isLoading = false } + // Only surface the spinner while there's no scene to show — freshness + // checks over a rendered graph stay invisible. + let showSpinner = isEmpty + if showSpinner { isLoading = true } + defer { if showSpinner { isLoading = false } } do { let response = try await fetchGraph() log("Knowledge graph: \(response.nodes.count) nodes, \(response.edges.count) edges") isEmpty = response.nodes.isEmpty + lastLoadedAt = Date() guard !isEmpty else { return } + // Same graph as last time → keep the settled scene. Re-simulating and + // recreating scene nodes for identical data is what made every page + // visit visibly "reload" the brain map. + let signature = Self.graphSignature(of: response) + if signature == loadedGraphSignature { + return + } + loadedGraphSignature = signature + // Populate simulation with user node at center let userName = AuthService.shared.displayName.isEmpty ? nil : AuthService.shared.givenName log("User name for center node: \(userName ?? "nil")") + let populateStart = CFAbsoluteTimeGetCurrent() simulation.populate(graphResponse: response, userNodeLabel: userName) log( "Simulation populated: \(simulation.nodes.count) nodes (including user), \(simulation.edges.count) edges" ) - - // Run initial layout off main thread for responsiveness - await Task.detached(priority: .userInitiated) { [simulation] in - simulation.runSync(ticks: 800) - }.value + logPerf( + "MemoryGraph: populate", duration: CFAbsoluteTimeGetCurrent() - populateStart) + + // A settled layout for this exact graph renders instantly — restore it + // and skip both the physics run and the visual settle animation. + let layoutStart = CFAbsoluteTimeGetCurrent() + let restoredLayout = + loadCachedLayout(signature: signature).map { simulation.applyLayout($0) } ?? false + if !restoredLayout { + // Run initial layout off main thread for responsiveness + await Task.detached(priority: .userInitiated) { [simulation] in + simulation.runSync(ticks: 800) + }.value + saveLayoutCache(signature: signature) + } + logPerf( + "MemoryGraph: layout (restored=\(restoredLayout))", + duration: CFAbsoluteTimeGetCurrent() - layoutStart) // Create scene nodes + let sceneStart = CFAbsoluteTimeGetCurrent() createSceneNodes() + logPerf("MemoryGraph: scene build", duration: CFAbsoluteTimeGetCurrent() - sceneStart) - // Brief animation to settle, then stop - isAnimating = true - Task { - try? await Task.sleep(nanoseconds: 3_000_000_000) // 3s of live physics - await MainActor.run { isAnimating = false } + if restoredLayout { + isAnimating = false + } else { + // Brief animation to settle, then stop + isAnimating = true + Task { + try? await Task.sleep(nanoseconds: 3_000_000_000) // 3s of live physics + await MainActor.run { isAnimating = false } + } } } catch { log("Failed to load knowledge graph: \(error.localizedDescription)") } } + private struct GraphLayoutCache: Codable { + let signature: Int + let positions: [String: [Float]] + } + + private static var layoutCacheURL: URL? { + guard + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first + else { return nil } + let dir = base.appendingPathComponent( + Bundle.main.bundleIdentifier ?? "me.omi", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("memory-graph-layout.json") + } + + private func loadCachedLayout(signature: Int) -> [String: SIMD3]? { + guard let url = Self.layoutCacheURL, + let data = try? Data(contentsOf: url), + let cache = try? JSONDecoder().decode(GraphLayoutCache.self, from: data), + cache.signature == signature + else { return nil } + var positions: [String: SIMD3] = [:] + positions.reserveCapacity(cache.positions.count) + for (id, values) in cache.positions where values.count == 3 { + positions[id] = SIMD3(values[0], values[1], values[2]) + } + return positions + } + + private func saveLayoutCache(signature: Int) { + guard let url = Self.layoutCacheURL else { return } + var positions: [String: [Float]] = [:] + for (id, position) in simulation.layoutPositions() { + positions[id] = [position.x, position.y, position.z] + } + let cache = GraphLayoutCache(signature: signature, positions: positions) + if let data = try? JSONEncoder().encode(cache) { + try? data.write(to: url, options: .atomic) + } + } + + /// Stable across launches — Swift's `Hasher` is per-process seeded, which + /// would silently invalidate the on-disk layout cache on every restart. + private static func graphSignature(of response: KnowledgeGraphResponse) -> Int { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 // FNV-1a + func combine(_ string: String) { + for byte in string.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01b3 + } + hash ^= 0xff + hash = hash &* 0x0000_0100_0000_01b3 + } + combine(String(response.nodes.count)) + combine(String(response.edges.count)) + for id in response.nodes.map(\.id).sorted() { + combine(id) + } + for id in response.edges.map(\.id).sorted() { + combine(id) + } + return Int(bitPattern: UInt(truncatingIfNeeded: hash)) + } + private func fetchGraph() async throws -> KnowledgeGraphResponse { var response = await KnowledgeGraphStorage.shared.loadGraph() if !response.nodes.isEmpty { @@ -325,7 +446,8 @@ class MemoryGraphViewModel: ObservableObject { // MARK: - Rebuild Graph - func rebuildGraph() async { + @discardableResult + func rebuildGraph() async -> Bool { isRebuilding = true defer { isRebuilding = false } @@ -337,8 +459,10 @@ class MemoryGraphViewModel: ObservableObject { // Reload the graph await loadGraph() + return true } catch { log("Failed to rebuild knowledge graph: \(error.localizedDescription)") + return false } } @@ -480,17 +604,35 @@ class MemoryGraphViewModel: ObservableObject { let billboardConstraint = SCNBillboardConstraint() billboardConstraint.freeAxes = [.X, .Y] + // Shared materials/geometry: nodes of the same type (and edges of the + // same type pair) are visually identical, so building one material per + // node/edge (~1,200 unique GPU objects for a mid-size graph) wasted both + // build time and draw-call batching. Cache by visual identity instead. + var edgeMaterialCache: [String: SCNMaterial] = [:] + var bodyMaterialCache: [String: SCNMaterial] = [:] + var glowMaterialCache: [String: SCNMaterial] = [:] + var sphereCache: [String: SCNSphere] = [:] + // Create edges first (behind nodes) for edge in simulation.edges { guard let source = simulation.nodeMap[edge.sourceId], let target = simulation.nodeMap[edge.targetId] else { continue } - let edgeColor = blendColors(source.nodeType.nsColor, target.nodeType.nsColor, alpha: 0.25) - let edgeMaterial = SCNMaterial() - edgeMaterial.diffuse.contents = edgeColor - edgeMaterial.emission.contents = edgeColor.withAlphaComponent(0.15) - edgeMaterial.lightingModel = .constant + let edgeKey = "\(source.nodeType.rawValue)|\(target.nodeType.rawValue)" + let edgeMaterial: SCNMaterial + if let cached = edgeMaterialCache[edgeKey] { + edgeMaterial = cached + } else { + let edgeColor = blendColors( + source.nodeType.nsColor, target.nodeType.nsColor, alpha: 0.25) + let material = SCNMaterial() + material.diffuse.contents = edgeColor + material.emission.contents = edgeColor.withAlphaComponent(0.15) + material.lightingModel = .constant + edgeMaterialCache[edgeKey] = material + edgeMaterial = material + } let edgeNode = createEdgeNode( from: source.position, to: target.position, material: edgeMaterial) @@ -506,34 +648,69 @@ class MemoryGraphViewModel: ObservableObject { containerNode.position = SCNVector3(node.position) containerNode.name = node.id + let materialKey = node.isFixed ? "fixed" : node.nodeType.rawValue + // Core sphere - let sphere = SCNSphere(radius: radius) - sphere.segmentCount = node.isFixed ? 24 : 16 - let mat = SCNMaterial() - if node.isFixed { - mat.diffuse.contents = NSColor.white - mat.emission.contents = NSColor.white.withAlphaComponent(0.8) + let bodyMaterial: SCNMaterial + if let cached = bodyMaterialCache[materialKey] { + bodyMaterial = cached } else { - mat.diffuse.contents = node.nodeType.nsColor - mat.emission.contents = node.nodeType.nsColor.withAlphaComponent(0.5) + let material = SCNMaterial() + if node.isFixed { + material.diffuse.contents = NSColor.white + material.emission.contents = NSColor.white.withAlphaComponent(0.8) + } else { + material.diffuse.contents = node.nodeType.nsColor + material.emission.contents = node.nodeType.nsColor.withAlphaComponent(0.5) + } + material.lightingModel = .constant + bodyMaterialCache[materialKey] = material + bodyMaterial = material + } + let segments = node.isFixed ? 24 : 16 + let sphereKey = "\(materialKey)|\(Int(radius * 10))|\(segments)" + let sphere: SCNSphere + if let cached = sphereCache[sphereKey] { + sphere = cached + } else { + let newSphere = SCNSphere(radius: radius) + newSphere.segmentCount = segments + newSphere.materials = [bodyMaterial] + sphereCache[sphereKey] = newSphere + sphere = newSphere } - mat.lightingModel = .constant - sphere.materials = [mat] let sphereNode = SCNNode(geometry: sphere) containerNode.addChildNode(sphereNode) - // Glow halo (larger semi-transparent sphere around node) + // Glow halo (larger semi-transparent sphere around node). The halo is + // an additive blur — 24 segments is visually identical to 48 at half + // the tessellation. let glowRadius = radius * 2.5 - let glowSphere = SCNSphere(radius: glowRadius) - glowSphere.segmentCount = 48 - let glowMat = SCNMaterial() - let glowColor = node.isFixed ? NSColor.white : node.nodeType.nsColor - glowMat.diffuse.contents = glowColor.withAlphaComponent(0.03) - glowMat.emission.contents = glowColor.withAlphaComponent(0.025) - glowMat.lightingModel = .constant - glowMat.isDoubleSided = true - glowMat.blendMode = .add - glowSphere.materials = [glowMat] + let glowMaterial: SCNMaterial + if let cached = glowMaterialCache[materialKey] { + glowMaterial = cached + } else { + let material = SCNMaterial() + let glowColor = node.isFixed ? NSColor.white : node.nodeType.nsColor + material.diffuse.contents = glowColor.withAlphaComponent(0.03) + material.emission.contents = glowColor.withAlphaComponent(0.025) + material.lightingModel = .constant + material.isDoubleSided = true + material.blendMode = .add + glowMaterialCache[materialKey] = material + glowMaterial = material + } + let glowKey = "glow|\(materialKey)|\(Int(glowRadius * 10))" + let glowSphere: SCNSphere + if let cached = sphereCache[glowKey] { + glowSphere = cached + } else { + let newSphere = SCNSphere(radius: glowRadius) + newSphere.segmentCount = 24 + newSphere.materials = [glowMaterial] + sphereCache[glowKey] = newSphere + glowSphere = newSphere + } let glowNode = SCNNode(geometry: glowSphere) containerNode.addChildNode(glowNode) @@ -554,9 +731,9 @@ class MemoryGraphViewModel: ObservableObject { private func createLabelNode(text: String, nodeRadius: CGFloat, isFixed: Bool) -> SCNNode { let truncated = text.count > 18 ? String(text.prefix(16)) + "..." : text let fontSize: CGFloat = isFixed ? 22 : 16 - let scnText = SCNText(string: truncated, extrusionDepth: 0.5) + let scnText = SCNText(string: truncated, extrusionDepth: 0) scnText.font = NSFont.systemFont(ofSize: fontSize, weight: isFixed ? .bold : .medium) - scnText.flatness = 0.3 + scnText.flatness = 0.6 scnText.alignmentMode = CATextLayerAlignmentMode.center.rawValue let textMat = SCNMaterial() @@ -749,7 +926,7 @@ extension SCNVector3 { #if canImport(PreviewsMacros) #Preview { - MemoryGraphPage() + MemoryGraphPage(viewModel: MemoryGraphViewModel()) .frame(width: 800, height: 600) } #endif diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/TaskDetailViews.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/TaskDetailViews.swift index c406ca528da..d5228b7af6c 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/TaskDetailViews.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/TaskDetailViews.swift @@ -20,7 +20,7 @@ struct TaskDetailButton: View { showDetail = true } label: { Image(systemName: "info.circle") - .scaledFont(size: 10) + .scaledFont(size: 12) .foregroundColor(showTooltip ? OmiColors.textSecondary : OmiColors.textTertiary) } .buttonStyle(.plain) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift index 19df5b172be..f17be045221 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift @@ -2513,6 +2513,7 @@ class TasksViewModel: ObservableObject { struct TasksPage: View { @ObservedObject var viewModel: TasksViewModel + let appState: AppState var chatProvider: ChatProvider? // Chat panel state @@ -2548,8 +2549,12 @@ struct TasksPage: View { @State private var isDraggingDivider = false @State private var dragStartWidth: Double = 0 - init(viewModel: TasksViewModel, chatCoordinator: TaskChatCoordinator, chatProvider: ChatProvider? = nil) { + init( + viewModel: TasksViewModel, appState: AppState, chatCoordinator: TaskChatCoordinator, + chatProvider: ChatProvider? = nil + ) { self.viewModel = viewModel + self.appState = appState self.chatCoordinator = chatCoordinator self.chatProvider = chatProvider } @@ -2868,58 +2873,22 @@ struct TasksPage: View { // MARK: - Header View private var headerView: some View { - HStack(spacing: 10) { - // Search field - HStack(spacing: 8) { - if viewModel.isSearching || viewModel.isLoadingFiltered { - ProgressView() - .scaleEffect(0.7) - .frame(width: 14, height: 14) - } else { - Image(systemName: "magnifyingglass") - .scaledFont(size: 14) - .foregroundColor(OmiColors.textTertiary) - } - - TextField("Search tasks...", text: $viewModel.searchText) - .textFieldStyle(.plain) - .foregroundColor(OmiColors.textPrimary) - - if !viewModel.searchText.isEmpty { - Button { - viewModel.searchText = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundColor(OmiColors.textTertiary) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(OmiColors.backgroundSecondary) - .cornerRadius(8) + HStack(spacing: OmiHeader.controlSpacing) { + OmiSearchField( + placeholder: "Search tasks...", + text: $viewModel.searchText, + isBusy: viewModel.isSearching || viewModel.isLoadingFiltered + ) // Saved filter view chips if !viewModel.savedFilterViews.isEmpty && !viewModel.isMultiSelectMode { ForEach(viewModel.savedFilterViews) { savedView in - let isActive = viewModel.isActiveSavedView(savedView) - Button { + OmiHeaderChip( + title: savedView.name, + isActive: viewModel.isActiveSavedView(savedView) + ) { viewModel.applySavedView(savedView) - } label: { - Text(savedView.name) - .scaledFont(size: 11, weight: .medium) - .foregroundColor(isActive ? .white : OmiColors.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(isActive ? OmiColors.backgroundTertiary : OmiColors.backgroundSecondary) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(isActive ? OmiColors.border : Color.clear, lineWidth: 1) - ) } - .buttonStyle(.plain) .contextMenu { Button(role: .destructive) { viewModel.deleteSavedView(savedView) @@ -2953,9 +2922,9 @@ struct TasksPage: View { taskSettingsButton } } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 12) + .padding(.horizontal, OmiHeader.rowHorizontalPadding) + .padding(.top, OmiHeader.rowTopPadding) + .padding(.bottom, OmiHeader.rowBottomPadding) .alert("Save Filter View", isPresented: $showSaveFilterAlert) { TextField("View name", text: $saveFilterName) Button("Save") { @@ -2974,36 +2943,18 @@ struct TasksPage: View { } private var saveFilterButton: some View { - Button { + OmiHeaderIconButton(systemImage: "bookmark") { saveFilterName = "" showSaveFilterAlert = true - } label: { - Image(systemName: "bookmark") - .scaledFont(size: 12) - .foregroundColor(OmiColors.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(OmiColors.backgroundSecondary) - .cornerRadius(8) } - .buttonStyle(.plain) .help("Save current filters as a view") } private var addTaskButton: some View { - Button { + OmiHeaderIconButton(systemImage: "plus") { viewModel.inlineCreateAfterTaskId = nil viewModel.isInlineCreating = true - } label: { - Image(systemName: "plus") - .scaledFont(size: 13) - .foregroundColor(OmiColors.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(OmiColors.backgroundSecondary) - .cornerRadius(8) } - .buttonStyle(.plain) .help("Add task (⌘N)") } @@ -3050,25 +3001,16 @@ struct TasksPage: View { } private var filterDropdownButton: some View { - Button { + OmiHeaderIconButton( + systemImage: "line.3.horizontal.decrease", + isActive: viewModel.hasActiveFilters + ) { pendingSelectedTags = viewModel.selectedTags pendingSelectedDynamicTags = viewModel.selectedDynamicTags filterSearchText = "" showFilterPopover = true - } label: { - Image(systemName: "line.3.horizontal.decrease") - .scaledFont(size: 12) - .foregroundColor(viewModel.hasActiveFilters ? OmiColors.textPrimary : OmiColors.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(OmiColors.backgroundSecondary) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(viewModel.hasActiveFilters ? OmiColors.border : Color.clear, lineWidth: 1) - ) } - .buttonStyle(.plain) + .help("Filter tasks") .popover(isPresented: $showFilterPopover, arrowEdge: .bottom) { filterPopover } @@ -3331,57 +3273,37 @@ struct TasksPage: View { .scaledFont(size: 13, weight: .medium) } .foregroundColor(.white) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color.red) - ) + .padding(.horizontal, 14) + .frame(height: OmiHeader.controlHeight) + .background(Capsule(style: .continuous).fill(Color.red)) } .buttonStyle(.plain) } private var cancelMultiSelectButton: some View { - Button { + OmiHeaderChip(title: "Cancel") { viewModel.toggleMultiSelectMode() - } label: { - Text("Cancel") - .scaledFont(size: 13, weight: .medium) - .foregroundColor(OmiColors.textSecondary) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(OmiColors.backgroundSecondary) - ) } - .buttonStyle(.plain) } private var taskSettingsButton: some View { - Button { + OmiHeaderIconButton(systemImage: "gearshape") { NotificationCenter.default.post( name: .navigateToTaskSettings, object: nil ) - } label: { - Image(systemName: "gearshape") - .scaledFont(size: 12) - .foregroundColor(OmiColors.textSecondary) - .padding(8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(OmiColors.backgroundSecondary) - ) } - .buttonStyle(.plain) .help("Task Settings") } private var chatToggleButton: some View { - Button { + OmiHeaderIconButton( + systemImage: showChatPanel + ? "bubble.left.and.bubble.right.fill" : "bubble.left.and.bubble.right", + isActive: showChatPanel + ) { if showChatPanel { closeChatPanel() } else if let selectedId = viewModel.keyboardSelectedTaskId, @@ -3395,17 +3317,7 @@ struct TasksPage: View { showChatPanel = true } } - } label: { - Image(systemName: showChatPanel ? "bubble.left.and.bubble.right.fill" : "bubble.left.and.bubble.right") - .scaledFont(size: 12) - .foregroundColor(showChatPanel ? OmiColors.purplePrimary : OmiColors.textSecondary) - .padding(8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(showChatPanel ? OmiColors.purplePrimary.opacity(0.15) : OmiColors.backgroundSecondary) - ) } - .buttonStyle(.plain) .help(showChatPanel ? "Close chat panel" : "Open task chat") } @@ -3855,6 +3767,18 @@ struct TaskCategorySection: View { Spacer() + // Every section shows its task count; Today additionally + // offers the clean-up action next to it. + Text("\(orderedTasks.count)") + .scaledFont(size: 12, weight: .medium) + .foregroundColor(OmiColors.textTertiary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background( + Capsule() + .fill(OmiColors.textTertiary.opacity(0.1)) + ) + if category == .today { Button { confirmClearTodayDeadlines() @@ -3867,16 +3791,6 @@ struct TaskCategorySection: View { .buttonStyle(.plain) .contentShape(Rectangle()) .help("Clean today's tasks") - } else { - Text("\(orderedTasks.count)") - .scaledFont(size: 12, weight: .medium) - .foregroundColor(OmiColors.textTertiary) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background( - Capsule() - .fill(OmiColors.textTertiary.opacity(0.1)) - ) } } @@ -4303,13 +4217,16 @@ struct TaskRow: View { } var body: some View { - HStack(alignment: .center, spacing: 0) { + HStack(alignment: .top, spacing: 0) { // Drag handle OUTSIDE swipeableContent so DragGesture doesn't intercept it if category != nil && !isMultiSelectMode && !isDeletedTask { Image(systemName: "line.3.horizontal") .scaledFont(size: 10) .foregroundColor(isHovering ? OmiColors.textTertiary : .clear) .frame(width: 16, height: 24) + // Match taskRowContent's vertical inset so the handle lines + // up with the checkbox in the top-aligned outer HStack. + .padding(.top, 6) .contentShape(Rectangle()) .onDrag { log("DRAG: onDrag started for task \(task.id) — \(task.description.prefix(40))") @@ -4338,11 +4255,11 @@ struct TaskRow: View { } .background( RoundedRectangle(cornerRadius: 8) - .fill(isActiveChatTask ? OmiColors.purplePrimary.opacity(0.08) : Color.clear) + .fill(isActiveChatTask ? OmiColors.backgroundRaised : Color.clear) ) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(isActiveChatTask ? OmiColors.purplePrimary.opacity(0.3) : Color.clear, lineWidth: 1) + .stroke(isActiveChatTask ? OmiColors.border.opacity(0.6) : Color.clear, lineWidth: 1) ) .overlay(alignment: .topTrailing) { if showShareCopiedToast { @@ -4527,7 +4444,9 @@ struct TaskRow: View { } private var taskRowContent: some View { - HStack(alignment: .center, spacing: 12) { + // Top-aligned so the checkbox sits on the first text line of wrapped + // rows instead of floating between lines. + HStack(alignment: .top, spacing: 12) { // Indent visual (vertical line for indented tasks) if indentLevel > 0 { HStack(spacing: 0) { @@ -4728,7 +4647,7 @@ struct TaskRow: View { Text("View chat") .scaledFont(size: 10, weight: .medium) } - .foregroundColor(OmiColors.purplePrimary) + .foregroundColor(OmiColors.textSecondary) } .buttonStyle(.plain) .help("View previous AI investigation") @@ -4754,9 +4673,6 @@ struct TaskRow: View { if let coordinator = chatCoordinator, TaskAgentSettings.shared.isChatEnabled { ChatSessionStatusIndicator(task: task, coordinator: coordinator, onOpenChat: onOpenChat) } - - // Task detail button (hover for preview, click for full detail) - TaskDetailButton(task: task, showDetail: $showTaskDetail) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -4859,6 +4775,13 @@ struct TaskRow: View { .help("Increase indent") } + // Task detail (hover for preview, click for full detail). + // Lives with the other row actions instead of costing every + // row a permanent badge line under the title. + TaskDetailButton(task: task, showDetail: $showTaskDetail) + .frame(width: 24, height: 24) + .help("Task details") + // Share link button Button { Task { await copyShareLink() } @@ -4908,7 +4831,7 @@ struct TaskRow: View { .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 8) - .fill(isKeyboardSelected ? OmiColors.purplePrimary.opacity(0.10) : (isHovering || isDragging ? OmiColors.backgroundTertiary : (isNewlyCreated ? OmiColors.purplePrimary.opacity(0.15) : Color.clear))) + .fill(isKeyboardSelected ? OmiColors.backgroundQuaternary.opacity(0.55) : (isHovering || isDragging ? OmiColors.backgroundRaised : (isNewlyCreated ? OmiColors.backgroundQuaternary.opacity(0.45) : Color.clear))) ) .overlay( RoundedRectangle(cornerRadius: 8) diff --git a/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift b/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift index a8a62b67930..9702918c7d0 100644 --- a/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift +++ b/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift @@ -125,10 +125,15 @@ final class OnboardingPagedIntroCoordinator: ObservableObject { ? AssistantSettings.shared.voiceLanguages : [] let defaults = UserDefaults.standard - chatGPTImportedMemoriesCount = defaults.integer(forKey: chatGPTImportedMemoriesKey) - claudeImportedMemoriesCount = defaults.integer(forKey: claudeImportedMemoriesKey) - chatGPTImportSummary = defaults.string(forKey: chatGPTImportSummaryKey) ?? "" - claudeImportSummary = defaults.string(forKey: claudeImportSummaryKey) ?? "" + let userID = Self.currentUserID() + chatGPTImportedMemoriesCount = defaults.integer( + forKey: Self.scopedDefaultsKey(chatGPTImportedMemoriesKey, userID: userID)) + claudeImportedMemoriesCount = defaults.integer( + forKey: Self.scopedDefaultsKey(claudeImportedMemoriesKey, userID: userID)) + chatGPTImportSummary = + defaults.string(forKey: Self.scopedDefaultsKey(chatGPTImportSummaryKey, userID: userID)) ?? "" + claudeImportSummary = + defaults.string(forKey: Self.scopedDefaultsKey(claudeImportSummaryKey, userID: userID)) ?? "" } deinit { @@ -220,11 +225,13 @@ final class OnboardingPagedIntroCoordinator: ObservableObject { func importMemoryLog(_ rawText: String, source: OnboardingMemoryLogSource) async { lastActionError = nil + let importUserID = Self.currentUserID() importingMemoryLogSource = source defer { importingMemoryLogSource = nil } let result = await OnboardingMemoryLogImportService.shared.importMemoryLog( rawText, source: source) + guard Self.currentUserID() == importUserID else { return } guard result.memories > 0 else { lastActionError = "Couldn’t extract durable memories from the pasted \(source.displayName) log." @@ -236,13 +243,13 @@ final class OnboardingPagedIntroCoordinator: ObservableObject { case .chatgpt: chatGPTImportedMemoriesCount = result.memories chatGPTImportSummary = result.profileSummary - defaults.set(result.memories, forKey: chatGPTImportedMemoriesKey) - defaults.set(result.profileSummary, forKey: chatGPTImportSummaryKey) + defaults.set(result.memories, forKey: Self.scopedDefaultsKey(chatGPTImportedMemoriesKey, userID: importUserID)) + defaults.set(result.profileSummary, forKey: Self.scopedDefaultsKey(chatGPTImportSummaryKey, userID: importUserID)) case .claude: claudeImportedMemoriesCount = result.memories claudeImportSummary = result.profileSummary - defaults.set(result.memories, forKey: claudeImportedMemoriesKey) - defaults.set(result.profileSummary, forKey: claudeImportSummaryKey) + defaults.set(result.memories, forKey: Self.scopedDefaultsKey(claudeImportedMemoriesKey, userID: importUserID)) + defaults.set(result.profileSummary, forKey: Self.scopedDefaultsKey(claudeImportSummaryKey, userID: importUserID)) } await saveGraph( @@ -261,6 +268,15 @@ final class OnboardingPagedIntroCoordinator: ObservableObject { ) } + private static func scopedDefaultsKey(_ key: String, userID: String?) -> String { + guard let userID, !userID.isEmpty else { return key } + return key + "." + userID + } + + private static func currentUserID() -> String? { + UserDefaults.standard.string(forKey: .authUserId) + } + func selectAppleNotesFolderAndSync() async { lastActionError = nil diff --git a/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift b/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift index 8d380e2bc26..d05c26b685b 100644 --- a/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift +++ b/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift @@ -321,8 +321,10 @@ struct RewindPage: View { private var rewindToggle: some View { ZStack { + // Green = capturing (matches the toolbar's capture-state language); + // neutral = off. Never red (reads as recording) or purple (banned). Capsule() - .fill(isMonitoring ? OmiColors.purplePrimary : Color.red) + .fill(isMonitoring ? HomePalette.green : Color.white.opacity(0.2)) .frame(width: 36, height: 20) Circle() @@ -390,7 +392,7 @@ struct RewindPage: View { // MARK: - Unified Top Bar (persistent search field) private var unifiedTopBar: some View { - HStack(spacing: 12) { + HStack(spacing: OmiHeader.controlSpacing) { // Left side: Back button (search timeline mode) or Rewind logo (other modes) if isInSearchMode && searchViewMode == .timeline { Button { @@ -405,28 +407,11 @@ struct RewindPage: View { } .buttonStyle(.plain) .help("Back to results") - } else { - // Rewind title - HStack(spacing: 8) { - Text("Rewind") - .scaledFont(size: 16, weight: .semibold) - .foregroundColor(.white) - - // Global hotkey hint - HStack(spacing: 2) { - Text("⌘") - Text("⌥") - Text("R") - } - .scaledFont(size: 10, weight: .medium, design: .rounded) - .foregroundColor(.white.opacity(0.4)) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(Color.white.opacity(0.1)) - .cornerRadius(4) - .help("Press ⌘⌥R from anywhere to open Rewind") - } } + // No standalone hotkey chip here: it read as a search shortcut, + // floated unanchored, and displayed ⌘⌥R while the registered + // hotkey is actually ⌃⌥R. The shortcut itself still works; it + // belongs in tooltips/settings, not as loose header chrome. // Search field + date picker - always present searchField(showResultsCount: isInSearchMode) @@ -478,25 +463,20 @@ struct RewindPage: View { Spacer() // Settings - Button { + OmiHeaderIconButton(systemImage: "gearshape") { NotificationCenter.default.post( name: .navigateToRewindSettings, object: nil ) - } label: { - Image(systemName: "gearshape") - .scaledFont(size: 12) - .foregroundColor(.white.opacity(0.6)) } - .buttonStyle(.plain) .help("Rewind Settings") // Rewind on/off toggle (screen capture only) rewindToggle } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(OmiColors.backgroundTertiary.opacity(0.8)) + .padding(.horizontal, OmiHeader.rowHorizontalPadding) + .padding(.top, OmiHeader.rowTopPadding) + .padding(.bottom, OmiHeader.rowBottomPadding) } // MARK: - Timeline Content Body (without top bar) @@ -607,8 +587,8 @@ struct RewindPage: View { private func searchField(showResultsCount: Bool = false) -> some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass") - .scaledFont(size: 12) - .foregroundColor(isSearchFocused ? OmiColors.purplePrimary : .white.opacity(0.5)) + .scaledFont(size: 13) + .foregroundColor(OmiColors.textTertiary) TextField("Search your screen history...", text: $viewModel.searchQuery) .textFieldStyle(.plain) @@ -647,17 +627,10 @@ struct RewindPage: View { .buttonStyle(.plain) } } - .padding(.horizontal, 12) - .padding(.vertical, 8) + // Shared header-control surface; focus reads through the active + // (raised + stronger border) treatment, not a purple accent. + .omiHeaderControl(isActive: isSearchFocused) .frame(maxWidth: 400) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color.white.opacity(0.1)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isSearchFocused ? OmiColors.purplePrimary.opacity(0.5) : Color.clear, lineWidth: 1) - ) - ) } // MARK: - Date Picker Controls @@ -668,16 +641,13 @@ struct RewindPage: View { } label: { HStack(spacing: 6) { Text(viewModel.selectedDate.formatted(.dateTime.month().day().year())) - .scaledFont(size: 12) - .foregroundColor(.white) + .scaledFont(size: 13, weight: .medium) + .foregroundColor(OmiColors.textSecondary) Image(systemName: "chevron.up.chevron.down") .scaledFont(size: 8, weight: .semibold) - .foregroundColor(.white.opacity(0.5)) + .foregroundColor(OmiColors.textTertiary) } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.white.opacity(0.15)) - .cornerRadius(6) + .omiHeaderControl() } .buttonStyle(.plain) .popover(isPresented: $showDatePicker) { diff --git a/desktop/macos/Desktop/Sources/Stores/ConversationsSearchModel.swift b/desktop/macos/Desktop/Sources/Stores/ConversationsSearchModel.swift new file mode 100644 index 00000000000..aedc2337792 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Stores/ConversationsSearchModel.swift @@ -0,0 +1,89 @@ +import Combine +import Foundation + +/// Persistent owner of the Conversations page's search: the typed query, +/// debounced execution, and results. +/// +/// Lives in `ViewModelContainer` because the page view is recreated on every +/// tab switch — keeping this state on the page meant a typed search vanished +/// whenever the user navigated away and back. Owning the debounce + request +/// here also lets us cancel superseded in-flight searches instead of letting +/// slow responses race each other out of order. +@MainActor +final class ConversationsSearchModel: ObservableObject { + /// The text in the search field. Updates immediately as the user types; + /// execution is debounced internally. + @Published var query: String = "" + @Published private(set) var results: [ServerConversation] = [] + @Published private(set) var isSearching = false + @Published private(set) var searchError: String? = nil + + private var lastSearchedQuery: String? = nil + private var searchTask: Task? = nil + private var cancellables = Set() + + init() { + $query + .debounce(for: .milliseconds(250), scheduler: DispatchQueue.main) + .removeDuplicates() + .sink { [weak self] value in + self?.performSearch(query: value) + } + .store(in: &cancellables) + } + + func clear() { + searchTask?.cancel() + query = "" + results = [] + searchError = nil + isSearching = false + lastSearchedQuery = nil + } + + func resetSessionState() { + clear() + } + + private func performSearch(query: String) { + guard !query.isEmpty else { + searchTask?.cancel() + results = [] + searchError = nil + isSearching = false + lastSearchedQuery = nil + return + } + // The debounce pipeline already drops consecutive duplicates; this + // guards the clear-and-retype path from re-firing identical requests. + guard query != lastSearchedQuery else { return } + lastSearchedQuery = query + + isSearching = true + searchError = nil + log("Search: Starting search for '\(query)'") + AnalyticsManager.shared.searchQueryEntered(query: query) + + searchTask?.cancel() + searchTask = Task { + do { + let result = try await APIClient.shared.searchConversations( + query: query, + page: 1, + perPage: 50, + includeDiscarded: false + ) + guard !Task.isCancelled else { return } + log("Search: Found \(result.items.count) results") + results = result.items + isSearching = false + } catch { + guard !Task.isCancelled else { return } + logError("Search: Failed", error: error) + searchError = error.localizedDescription + results = [] + isSearching = false + } + } + } +} diff --git a/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift b/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift new file mode 100644 index 00000000000..537c6d2765d --- /dev/null +++ b/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift @@ -0,0 +1,169 @@ +import Combine +import Foundation +import SwiftUI + +/// Persistent owner of the Home page's status data: knowledge counts, +/// import-connector and MCP-export statuses, and the refresh cooldown. +/// +/// Lives in `ViewModelContainer` so navigating away and back to Home renders +/// instantly from cached values instead of refetching — the page view itself +/// is recreated on every tab switch, so any state kept on the page (including +/// its refresh cooldown) dies with it and turns each visit into a refetch +/// storm (2 network calls + Rewind DB stats + local counts + connector and +/// MCP status scans). +@MainActor +final class HomeStatusStore: ObservableObject { + static let legacyOmiDeviceHistoryDefaultsKey = "home-omi-device-account-history" + static let omiDeviceHistoryDefaultsKeyPrefix = "home-omi-device-account-history." + + /// Import-connector (Gmail, Notes, …) status cache. Owned here so the + /// connected badges don't reset to unknown on every Home visit. + let importConnectorStatusStore = ImportConnectorStatusStore() + + // True totals for the "What omi knows" tiles. Without these the tiles + // showed only the loaded page (~50 conversations, ~100 memories), badly + // undercounting. + @Published var screenshotCount: Int? + @Published var conversationCount: Int? + @Published var memoryCount: Int? + @Published var taskCount: Int? + @Published var memoryExportStatuses: [MemoryExportDestination: MemoryExportStatus] = [:] + /// Wearable used on this account (any friend/omi-sourced conversation). + /// Seeded from UserDefaults so the badge is instant on later launches. + @Published var accountHasOmiDeviceConversations = HomeStatusStore.cachedOmiDeviceHistory() + + private var lastRefreshAt = Date.distantPast + private var isRefreshing = false + private var refreshGeneration = 0 + private var cancellables: Set = [] + + init() { + // The nested connector store publishes its own changes; forward them + // so views observing this store re-render connected badges. + importConnectorStatusStore.objectWillChange + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) + } + + /// Refresh all Home status data. Non-forced calls share the app-wide + /// activation cooldown (so tab switches and Cmd-Tab bursts render from + /// cache), and calls are coalesced while a refresh is in flight. + func refresh(force: Bool) async { + let now = Date() + if !force, + !PollingConfig.shouldAllowActivationRefresh(now: now, lastRefresh: lastRefreshAt) + { + return + } + guard !isRefreshing else { return } + isRefreshing = true + lastRefreshAt = now + let generation = refreshGeneration + defer { + if generation == refreshGeneration { + isRefreshing = false + } + } + + async let importConnectorStatuses: Void = importConnectorStatusStore.refresh() + async let screenshots = loadScreenshotCount() + async let knowledgeCounts = loadKnowledgeCounts() + async let exportStatuses = loadMemoryExportStatuses() + let (_, screenshotCount, counts, statuses) = await ( + importConnectorStatuses, screenshots, knowledgeCounts, exportStatuses) + + guard generation == refreshGeneration else { return } + self.screenshotCount = screenshotCount + if let conversationCount = counts.conversationCount { + self.conversationCount = conversationCount + } + if let memoryCount = counts.memoryCount { + self.memoryCount = memoryCount + } + if let taskCount = counts.taskCount { + self.taskCount = taskCount + } + if counts.hasOmiDeviceHistory == true { + accountHasOmiDeviceConversations = true + Self.setCachedOmiDeviceHistory() + } + memoryExportStatuses = statuses + } + + /// Clear per-account data on sign-out/account switch. + func resetSessionState() { + refreshGeneration += 1 + isRefreshing = false + screenshotCount = nil + conversationCount = nil + memoryCount = nil + taskCount = nil + memoryExportStatuses = [:] + accountHasOmiDeviceConversations = Self.cachedOmiDeviceHistory() + importConnectorStatusStore.resetSessionState() + lastRefreshAt = .distantPast + } + + // MARK: - Loaders + + private struct KnowledgeCounts { + var conversationCount: Int? + var memoryCount: Int? + var taskCount: Int? + var hasOmiDeviceHistory: Bool? + } + + private func loadScreenshotCount() async -> Int? { + let stats = await RewindIndexer.shared.getStats() + return stats?.total + } + + private func loadMemoryExportStatuses() async -> [MemoryExportDestination: MemoryExportStatus] { + await MemoryExportService.shared.allStatuses() + } + + /// Load the true totals behind the "What omi knows" tiles. Conversations come + /// from the server count endpoint (not stored locally); memories and tasks are + /// counted from the synced local DB — the same totals the detail pages show. + private func loadKnowledgeCounts() async -> KnowledgeCounts { + async let convos = try? APIClient.shared.getConversationsCount(includeDiscarded: false) + async let mems = try? MemoryStorage.shared.getLocalMemoriesCount() + // Open tasks only (matches the "Tasks" label and the old tile's intent — + // the old value just under-counted, capping each bucket at a 7-day window). + async let tasks = try? ActionItemStorage.shared.getLocalActionItemsCount(completed: false) + let shouldLoadDeviceHistory = !accountHasOmiDeviceConversations + async let deviceHistory = shouldLoadDeviceHistory ? loadOmiDeviceHistory() : nil + let (c, m, t, d) = await (convos, mems, tasks, deviceHistory) + return KnowledgeCounts( + conversationCount: c, + memoryCount: m, + taskCount: t, + hasOmiDeviceHistory: d + ) + } + + private func loadOmiDeviceHistory() async -> Bool? { + try? await APIClient.shared.hasOmiDeviceConversations() + } + + private static func cachedOmiDeviceHistory(defaults: UserDefaults = .standard) -> Bool { + guard let key = omiDeviceHistoryDefaultsKey(defaults: defaults) else { return false } + if defaults.object(forKey: key) == nil, + defaults.bool(forKey: legacyOmiDeviceHistoryDefaultsKey) + { + defaults.set(true, forKey: key) + defaults.removeObject(forKey: legacyOmiDeviceHistoryDefaultsKey) + } + return defaults.bool(forKey: key) + } + + private static func setCachedOmiDeviceHistory(defaults: UserDefaults = .standard) { + guard let key = omiDeviceHistoryDefaultsKey(defaults: defaults) else { return } + defaults.set(true, forKey: key) + } + + private static func omiDeviceHistoryDefaultsKey(defaults: UserDefaults = .standard) -> String? { + guard let userId = defaults.string(forKey: .authUserId), !userId.isEmpty else { return nil } + return omiDeviceHistoryDefaultsKeyPrefix + userId + } +} diff --git a/desktop/macos/Desktop/Sources/ViewExporter.swift b/desktop/macos/Desktop/Sources/ViewExporter.swift index 2074644e2e4..cd723eeffe9 100644 --- a/desktop/macos/Desktop/Sources/ViewExporter.swift +++ b/desktop/macos/Desktop/Sources/ViewExporter.swift @@ -59,7 +59,7 @@ enum ViewExporter { appState: AppState(), appProvider: AppProvider(), chatProvider: ChatProvider(), - memoriesViewModel: MemoriesViewModel(), + memoriesViewModel: MemoriesViewModel(), homeStatus: HomeStatusStore(), selectedIndex: .constant(0))) }, CGSize(width: 900, height: 700) @@ -73,7 +73,9 @@ enum ViewExporter { ( "04-conversations", - { AnyView(ConversationsPage(appState: AppState(), selectedConversation: .constant(nil))) }, + { AnyView(ConversationsPage( + appState: AppState(), selectedConversation: .constant(nil), + searchModel: ConversationsSearchModel())) }, CGSize(width: 900, height: 700) ), @@ -282,7 +284,7 @@ enum ViewExporter { appState: AppState(), appProvider: AppProvider(), chatProvider: ChatProvider(), - memoriesViewModel: previewMemoriesViewModel(), + memoriesViewModel: previewMemoriesViewModel(), homeStatus: HomeStatusStore(), selectedIndex: .constant(0))) } ), @@ -290,14 +292,17 @@ enum ViewExporter { "full-ai-chat", 2, { AnyView(ChatPage(appProvider: AppProvider(), chatProvider: previewChatProvider())) } ), - ("full-memories", 3, { AnyView(MemoriesPage(viewModel: previewMemoriesViewModel())) }), + ("full-memories", 3, { AnyView(MemoriesPage( + viewModel: previewMemoriesViewModel(), graphViewModel: MemoryGraphViewModel(), + appState: AppState())) }), ( "full-tasks", 4, { let cp = ChatProvider() return AnyView( TasksPage( - viewModel: TasksViewModel(), chatCoordinator: TaskChatCoordinator(chatProvider: cp), + viewModel: TasksViewModel(), appState: AppState(), + chatCoordinator: TaskChatCoordinator(chatProvider: cp), chatProvider: cp)) } ), diff --git a/desktop/macos/Desktop/Sources/ViewModelContainer.swift b/desktop/macos/Desktop/Sources/ViewModelContainer.swift index d540852b1e9..abd1dd22ca0 100644 --- a/desktop/macos/Desktop/Sources/ViewModelContainer.swift +++ b/desktop/macos/Desktop/Sources/ViewModelContainer.swift @@ -12,6 +12,14 @@ class ViewModelContainer: ObservableObject { let tasksViewModel = TasksViewModel() let appProvider = AppProvider() let memoriesViewModel = MemoriesViewModel() + /// Home status data (counts, connector/MCP statuses) — persistent so Home + /// renders from cache on every visit instead of refetching. + let homeStatusStore = HomeStatusStore() + /// Conversations search — persistent so a typed query survives navigation. + let conversationsSearchModel = ConversationsSearchModel() + /// Brain-map graph — persistent so the SceneKit scene, force layout, and + /// camera survive page navigation instead of rebuilding every visit. + let memoryGraphViewModel = MemoryGraphViewModel() let chatProvider: ChatProvider let taskChatCoordinator: TaskChatCoordinator private lazy var warmupCoordinator = StartupWarmupCoordinator( @@ -131,6 +139,8 @@ class ViewModelContainer: ObservableObject { dashboardViewModel.resetSessionState() memoriesViewModel.resetSessionState() appProvider.resetSessionState() + homeStatusStore.resetSessionState() + conversationsSearchModel.resetSessionState() isInitialLoadComplete = false isLoading = false databaseInitFailed = false diff --git a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift index cc5c1e7ee1d..d473e4e56bf 100644 --- a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift +++ b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift @@ -1,25 +1,25 @@ import XCTest final class DashboardCaptureStateTests: XCTestCase { - func testDashboardCaptureStatusUsesLiveMonitoringState() throws { - let source = try dashboardSource() + func testCaptureStatusUsesLiveMonitoringState() throws { + let source = try captureControllerSource() XCTAssertTrue( - source.contains("private var isCaptureLive: Bool"), - "DashboardPage should centralize live capture state so the header reflects the running monitor" + source.contains("var isCaptureLive: Bool"), + "CaptureListeningController should centralize live capture state so Home controls reflect the running monitor" ) XCTAssertTrue( - source.contains("if isCaptureLive {\n return .active\n }"), + source.contains("return isCaptureLive ? .active : .inactive"), "Capture status should light up when monitoring is live, even if persisted intent is stale" ) XCTAssertFalse( - source.contains("if screenAnalysisEnabled && isCaptureMonitoring {\n return .active\n }"), + source.contains("screenAnalysisEnabled && isCaptureMonitoring"), "Capture status must not require persisted intent to match the live monitor" ) } - func testDashboardCaptureToggleDerivesFromLiveState() throws { - let source = try dashboardSource() + func testCaptureToggleDerivesFromLiveState() throws { + let source = try captureControllerSource() XCTAssertTrue( source.contains("syncCaptureState()\n let enabled = !isCaptureLive"), @@ -31,21 +31,26 @@ final class DashboardCaptureStateTests: XCTestCase { ) } - func testListeningPillShowsAndTogglesCaptureMode() throws { - let source = try dashboardSource() - - XCTAssertTrue(source.contains("@AppStorage(\"systemAudioCaptureMode\")")) - XCTAssertTrue(source.contains("private var listeningModeTitle: String")) - XCTAssertTrue(source.contains("return appState.isAwaitingMeeting ? \"Meetings only\" : \"In meeting\"")) - XCTAssertTrue(source.contains("HomeListeningStatusButton(")) - XCTAssertTrue(source.contains("modeAction: toggleListeningMode")) - XCTAssertTrue(source.contains("AssistantSettings.shared.systemAudioCaptureMode = nextMode")) - XCTAssertTrue(source.contains("Image(systemName: isMeetingsOnly ? \"person.2.fill\" : \"person.fill\")")) - XCTAssertTrue(source.contains("private var modeIconColor: Color")) - XCTAssertTrue(source.contains(".frame(height: 34)")) - XCTAssertFalse(source.contains("Image(systemName: isMeetingsOnly ? \"person.2.fill\" : \"infinity\")")) - XCTAssertFalse(source.contains("Circle()\n .fill(status.indicator)")) - XCTAssertFalse(source.contains("OmiColors.purplePrimary")) + func testHomeListeningControlShowsAndTogglesCaptureMode() throws { + let controller = try captureControllerSource() + let controls = try homeControlsSource() + + // The listening control is labeled — an unlabeled mic glyph doesn't + // tell the user what it controls. + XCTAssertTrue(controls.contains("title: \"Listening\"")) + XCTAssertTrue(controls.contains("title: \"Capture\"")) + XCTAssertTrue(controls.contains("controls.toggleListeningMode()")) + XCTAssertTrue(controller.contains("var listeningModeTitle: String")) + XCTAssertTrue(controller.contains("return appState.isAwaitingMeeting ? \"Meetings only\" : \"In meeting\"")) + XCTAssertTrue(controller.contains("AssistantSettings.shared.systemAudioCaptureMode = nextMode")) + // Home controls stay neutral — the app's purple accent is banned, and + // blocked state is amber rather than a brand-breaking red treatment. + XCTAssertFalse(controls.contains("OmiColors.purplePrimary")) + XCTAssertFalse(controls.contains("Color.purple")) + // On/off must be readable at a glance: the running state fills the + // control, it doesn't hide in a tiny badge. + XCTAssertTrue(controls.contains("return HomePalette.green.opacity(0.16)")) + XCTAssertTrue(controls.contains("return Color.yellow.opacity(0.12)")) } func testHomeConnectorButtonsOpenSheetsDirectly() throws { @@ -148,49 +153,55 @@ final class DashboardCaptureStateTests: XCTestCase { } func testHomeStatusRefreshUsesSharedActivationThrottle() throws { - let source = try dashboardSource() - let normalizedSource = normalizedWhitespace(source) - let method = try methodBody(named: "refreshHomeStatusData", in: source) - - XCTAssertTrue(source.contains("@State private var lastHomeStatusRefreshAt = Date.distantPast")) - XCTAssertTrue(normalizedSource.contains("syncCaptureState() reportHomeAutomationMode() Task { await refreshHomeStatusData(force: true) }")) - XCTAssertTrue( - normalizedSource.contains( - "viewModel.refreshGoals() appState.checkAllPermissions() syncCaptureState() Task { await refreshHomeStatusData(force: false) }" - ) - ) - XCTAssertTrue(method.contains("PollingConfig.shouldAllowActivationRefresh")) - XCTAssertTrue(method.contains("lastRefresh: lastHomeStatusRefreshAt")) - XCTAssertTrue(method.contains("lastHomeStatusRefreshAt = now")) - XCTAssertTrue(method.contains("async let importConnectorStatuses: Void = importConnectorStatusStore.refresh()")) - XCTAssertTrue(method.contains("async let screenshots: Void = loadScreenshotCount()")) - XCTAssertTrue(method.contains("async let knowledgeCounts: Void = loadKnowledgeCounts()")) - XCTAssertTrue(method.contains("async let exportStatuses: Void = loadMemoryExportStatuses()")) - XCTAssertFalse(source.contains("memoryExportStatusActiveRefreshThrottle")) - XCTAssertFalse(source.contains("lastMemoryExportStatusRefreshAt")) - XCTAssertFalse(source.contains("loadMemoryExportStatuses(force:")) + let dashboard = try dashboardSource() + let store = try homeStatusStoreSource() + let normalizedDashboard = normalizedWhitespace(dashboard) + let refresh = try methodBody(named: "refresh", in: store) + + // The cooldown lives on the session-persistent HomeStatusStore, not + // on page @State — page views are recreated on every tab switch, so + // view-held state can never throttle revisits (each visit refetched). + XCTAssertTrue(store.contains("private var lastRefreshAt = Date.distantPast")) + XCTAssertFalse(dashboard.contains("lastHomeStatusRefreshAt")) + // Home renders from the cached store; appear and app-activation both + // go through the throttled path — no forced refetch per visit. + XCTAssertTrue(normalizedDashboard.contains("Task { await homeStatus.refresh(force: false) }")) + XCTAssertFalse(dashboard.contains("refresh(force: true)")) + XCTAssertTrue(refresh.contains("PollingConfig.shouldAllowActivationRefresh")) + XCTAssertTrue(refresh.contains("lastRefreshAt = now")) + XCTAssertTrue(refresh.contains("guard !isRefreshing else { return }")) + XCTAssertTrue(store.contains("private var refreshGeneration = 0")) + XCTAssertTrue(refresh.contains("guard generation == refreshGeneration else { return }")) + XCTAssertTrue(store.contains("importConnectorStatusStore.resetSessionState()")) + XCTAssertTrue(refresh.contains("async let importConnectorStatuses: Void = importConnectorStatusStore.refresh()")) + XCTAssertTrue(refresh.contains("async let screenshots = loadScreenshotCount()")) + XCTAssertTrue(refresh.contains("async let knowledgeCounts = loadKnowledgeCounts()")) + XCTAssertTrue(refresh.contains("async let exportStatuses = loadMemoryExportStatuses()")) + XCTAssertFalse(store.contains("memoryExportStatusActiveRefreshThrottle")) + XCTAssertFalse(store.contains("loadMemoryExportStatuses(force:")) } func testMemoryExportStatusesRefreshInsideHomeStatusGate() throws { - let source = try dashboardSource() - let method = try methodBody(named: "loadMemoryExportStatuses", in: source) + let store = try homeStatusStoreSource() + let method = try methodBody(named: "loadMemoryExportStatuses", in: store) - XCTAssertTrue(method.contains("let statuses = await MemoryExportService.shared.allStatuses()")) - XCTAssertTrue(method.contains("memoryExportStatuses = statuses")) + XCTAssertTrue(method.contains("await MemoryExportService.shared.allStatuses()")) + XCTAssertTrue(store.contains("memoryExportStatuses = statuses")) XCTAssertFalse(method.contains("PollingConfig.shouldAllowActivationRefresh")) - XCTAssertFalse(method.contains("lastHomeStatusRefreshAt")) + XCTAssertFalse(method.contains("lastRefreshAt")) XCTAssertFalse(method.contains("memoryExportStatusActiveRefreshThrottle")) } func testOmiDeviceHistorySkipsNetworkAfterStickyFlag() throws { - let source = try dashboardSource() - let method = try methodBody(named: "loadKnowledgeCounts", in: source) - let helper = try methodBody(named: "loadOmiDeviceHistory", in: source) + let store = try homeStatusStoreSource() + let method = try methodBody(named: "loadKnowledgeCounts", in: store) + let helper = try methodBody(named: "loadOmiDeviceHistory", in: store) - XCTAssertTrue(method.contains("let shouldLoadDeviceHistory = await MainActor.run { !accountHasOmiDeviceConversations }")) + XCTAssertTrue(method.contains("let shouldLoadDeviceHistory = !accountHasOmiDeviceConversations")) XCTAssertTrue(method.contains("async let deviceHistory = shouldLoadDeviceHistory ? loadOmiDeviceHistory() : nil")) XCTAssertTrue(helper.contains("APIClient.shared.hasOmiDeviceConversations()")) - XCTAssertTrue(method.contains("UserDefaults.standard.set(true, forKey: Self.omiDeviceHistoryDefaultsKey)")) + XCTAssertTrue(store.contains("Self.setCachedOmiDeviceHistory()")) + XCTAssertTrue(store.contains("omiDeviceHistoryDefaultsKeyPrefix + userId")) } func testConnectorRowsUseStatusConnectionForConnectedState() throws { @@ -215,6 +226,28 @@ final class DashboardCaptureStateTests: XCTestCase { XCTAssertFalse(source.contains("resultMessage = .failure(error.localizedDescription)")) } + func testOnboardingMemoryLogImportKeepsCapturedUserScope() throws { + let source = try onboardingCoordinatorSource() + let method = try methodBody(named: "importMemoryLog", in: source) + let guardRange = try XCTUnwrap( + method.range(of: "guard Self.currentUserID() == importUserID else { return }") + ) + let guardOffset = method.distance(from: method.startIndex, to: guardRange.lowerBound) + let scopedDefaultsWrites = [ + "Self.scopedDefaultsKey(chatGPTImportedMemoriesKey, userID: importUserID)", + "Self.scopedDefaultsKey(chatGPTImportSummaryKey, userID: importUserID)", + "Self.scopedDefaultsKey(claudeImportedMemoriesKey, userID: importUserID)", + "Self.scopedDefaultsKey(claudeImportSummaryKey, userID: importUserID)", + ] + + XCTAssertTrue(method.contains("let importUserID = Self.currentUserID()")) + for scopedDefaultsWrite in scopedDefaultsWrites { + let writeRange = try XCTUnwrap(method.range(of: scopedDefaultsWrite)) + let writeOffset = method.distance(from: method.startIndex, to: writeRange.lowerBound) + XCTAssertGreaterThan(writeOffset, guardOffset) + } + } + func testAppsPageSupportsPopupDismissalAndFocusedSections() throws { let source = try appsSource() @@ -309,10 +342,42 @@ final class DashboardCaptureStateTests: XCTestCase { return try String(contentsOf: dashboardURL, encoding: .utf8) } + private func homeStatusStoreSource() throws -> String { + let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let storeURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent("Sources/Stores/HomeStatusStore.swift") + return try String(contentsOf: storeURL, encoding: .utf8) + } + + private func captureControllerSource() throws -> String { + try componentSource(named: "CaptureListeningController.swift") + } + + private func homeControlsSource() throws -> String { + try componentSource(named: "ToolbarStatusControls.swift") + } + + private func componentSource(named fileName: String) throws -> String { + let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let sourceURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent("Sources/MainWindow/Components/\(fileName)") + return try String(contentsOf: sourceURL, encoding: .utf8) + } + private func appsSource() throws -> String { try source(named: "AppsPage.swift") } + private func onboardingCoordinatorSource() throws -> String { + let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let sourceURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent("Sources/Onboarding/OnboardingPagedIntroCoordinator.swift") + return try String(contentsOf: sourceURL, encoding: .utf8) + } + private func source(named fileName: String) throws -> String { let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() let sourceURL = testsURL @@ -322,12 +387,32 @@ final class DashboardCaptureStateTests: XCTestCase { } private func methodBody(named name: String, in source: String) throws -> String { - let pattern = #"private func \#(name)\([^\)]*\)[^{]*\{([\s\S]*?)\n \}"# + let pattern = #"(?:private )?func \#(name)\b"# let regex = try NSRegularExpression(pattern: pattern) let range = NSRange(source.startIndex.. String { diff --git a/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift b/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift index 11e1939b5ba..d8e05455c08 100644 --- a/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift +++ b/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift @@ -7,10 +7,12 @@ final class ImportConnectorStatusStoreTests: XCTestCase { override func setUp() { super.setUp() resetImportConnectorDefaults() + UserDefaults.standard.set("test-user", forKey: .authUserId) } override func tearDown() { resetImportConnectorDefaults() + UserDefaults.standard.removeObject(forKey: .authUserId) super.tearDown() } @@ -70,7 +72,9 @@ final class ImportConnectorStatusStoreTests: XCTestCase { func testAvailabilityTextAloneDoesNotMarkConnectorConnected() { let connector = ImportConnector.all.first { $0.id == "apple-notes" }! - UserDefaults.standard.set("Private notes accessible", forKey: "appsImportConnectorAvailabilityText.apple-notes") + UserDefaults.standard.set( + "Private notes accessible", + forKey: "appsImportConnectorAvailabilityText.test-user.apple-notes") let store = ImportConnectorStatusStore() let snapshot = store.snapshot(for: connector) @@ -83,7 +87,7 @@ final class ImportConnectorStatusStoreTests: XCTestCase { func testPositiveCountWithoutSuccessfulSyncDoesNotMarkConnectorConnected() { let connector = ImportConnector.all.first { $0.id == "calendar" }! - UserDefaults.standard.set(3, forKey: "appsImportConnectorSourceCount.calendar") + UserDefaults.standard.set(3, forKey: "appsImportConnectorSourceCount.test-user.calendar") let snapshot = ImportConnectorStatusStore().snapshot(for: connector) XCTAssertFalse(snapshot.isConnected) @@ -93,13 +97,63 @@ final class ImportConnectorStatusStoreTests: XCTestCase { func testLegacyManualImportCountStillMarksConnectorConnected() { let connector = ImportConnector.all.first { $0.id == "chatgpt" }! - UserDefaults.standard.set(4, forKey: "onboardingChatGPTImportedMemoriesCount") + UserDefaults.standard.set(4, forKey: "onboardingChatGPTImportedMemoriesCount.test-user") let snapshot = ImportConnectorStatusStore().snapshot(for: connector) XCTAssertTrue(snapshot.isConnected) XCTAssertEqual(snapshot.actionTitle, "Update") } + func testConnectorMetricsAreScopedBySignedInUser() { + let connector = ImportConnector.all.first { $0.id == "email" }! + let syncedAt = Date(timeIntervalSince1970: 1_700_000_000) + let store = ImportConnectorStatusStore() + + store.markSynced(connectorID: "email", sourceCount: 12, memoryCount: 2, syncedAt: syncedAt) + + UserDefaults.standard.set("other-user", forKey: .authUserId) + let otherUserSnapshot = ImportConnectorStatusStore().snapshot(for: connector) + + UserDefaults.standard.set("test-user", forKey: .authUserId) + let originalUserSnapshot = ImportConnectorStatusStore().snapshot(for: connector) + + XCTAssertFalse(otherUserSnapshot.isConnected) + XCTAssertEqual(otherUserSnapshot.actionTitle, "Connect") + XCTAssertTrue(originalUserSnapshot.isConnected) + XCTAssertEqual(originalUserSnapshot.primaryText, "12 emails • 2 memories") + } + + func testResetSessionStateClearsConnectorMetricsUntilCurrentUserReloads() { + let connector = ImportConnector.all.first { $0.id == "email" }! + let syncedAt = Date(timeIntervalSince1970: 1_700_000_000) + let store = ImportConnectorStatusStore() + + store.markSynced(connectorID: "email", sourceCount: 12, memoryCount: 2, syncedAt: syncedAt) + XCTAssertTrue(store.snapshot(for: connector).isConnected) + + UserDefaults.standard.removeObject(forKey: .authUserId) + store.resetSessionState() + XCTAssertFalse(store.snapshot(for: connector).isConnected) + + UserDefaults.standard.set("test-user", forKey: .authUserId) + XCTAssertTrue(ImportConnectorStatusStore().snapshot(for: connector).isConnected) + } + + func testOmiDeviceHistoryMigratesLegacyGlobalKeyToCurrentUser() { + UserDefaults.standard.set(true, forKey: "home-omi-device-account-history") + UserDefaults.standard.set("test-user", forKey: .authUserId) + + let store = HomeStatusStore() + + XCTAssertTrue(store.accountHasOmiDeviceConversations) + XCTAssertTrue(UserDefaults.standard.bool(forKey: "home-omi-device-account-history.test-user")) + XCTAssertNil(UserDefaults.standard.object(forKey: "home-omi-device-account-history")) + + UserDefaults.standard.set("other-user", forKey: .authUserId) + let otherStore = HomeStatusStore() + XCTAssertFalse(otherStore.accountHasOmiDeviceConversations) + } + private func resetImportConnectorDefaults() { let prefixes = [ "appsImportConnectorSourceCount.", @@ -112,9 +166,18 @@ final class ImportConnectorStatusStoreTests: XCTestCase { for connector in ImportConnector.all { for prefix in prefixes { UserDefaults.standard.removeObject(forKey: prefix + connector.id) + UserDefaults.standard.removeObject(forKey: prefix + "test-user." + connector.id) + UserDefaults.standard.removeObject(forKey: prefix + "other-user." + connector.id) } } UserDefaults.standard.removeObject(forKey: "onboardingChatGPTImportedMemoriesCount") UserDefaults.standard.removeObject(forKey: "onboardingClaudeImportedMemoriesCount") + UserDefaults.standard.removeObject(forKey: "onboardingChatGPTImportedMemoriesCount.test-user") + UserDefaults.standard.removeObject(forKey: "onboardingClaudeImportedMemoriesCount.test-user") + UserDefaults.standard.removeObject(forKey: "onboardingChatGPTImportedMemoriesCount.other-user") + UserDefaults.standard.removeObject(forKey: "onboardingClaudeImportedMemoriesCount.other-user") + UserDefaults.standard.removeObject(forKey: "home-omi-device-account-history") + UserDefaults.standard.removeObject(forKey: "home-omi-device-account-history.test-user") + UserDefaults.standard.removeObject(forKey: "home-omi-device-account-history.other-user") } } diff --git a/desktop/macos/Desktop/Tests/MemoryGraphRevisitTests.swift b/desktop/macos/Desktop/Tests/MemoryGraphRevisitTests.swift new file mode 100644 index 00000000000..34f3bb0d0ee --- /dev/null +++ b/desktop/macos/Desktop/Tests/MemoryGraphRevisitTests.swift @@ -0,0 +1,82 @@ +import XCTest + +/// Guards the brain-map revisit behavior: the graph view model is +/// session-persistent and page visits must not refetch, re-run the force +/// layout, or rebuild the SceneKit scene unless the graph actually changed. +/// (Regression: every Memories visit used to fetch the full graph, run 800 +/// physics ticks, rebuild the scene, and replay a 3s settle animation.) +final class MemoryGraphRevisitTests: XCTestCase { + func testGraphSurfacesUseInjectedPersistentViewModel() throws { + let source = try graphSource() + + XCTAssertFalse( + source.contains("@StateObject private var viewModel = MemoryGraphViewModel()"), + "Graph surfaces must not own their view model — page-local @StateObject dies on every tab switch and rebuilds the scene per visit" + ) + XCTAssertTrue(source.contains("@ObservedObject var viewModel: MemoryGraphViewModel")) + + let container = try containerSource() + XCTAssertTrue( + container.contains("let memoryGraphViewModel = MemoryGraphViewModel()"), + "The shared graph view model lives in ViewModelContainer so it survives navigation" + ) + } + + func testPrepareGraphIsThrottledAndSingleFlight() throws { + let source = try graphSource() + let method = try methodBody(named: "prepareGraph", in: source) + + XCTAssertTrue(method.contains("guard !isPreparing else { return }")) + XCTAssertTrue(method.contains("PollingConfig.shouldAllowActivationRefresh(lastRefresh: lastLoadedAt)")) + XCTAssertTrue( + method.contains("if isEmpty && !hasRunEmptyBootstrap {"), + "The empty-graph rebuild+poll bootstrap must run once per session, not on every visit" + ) + XCTAssertTrue( + method.contains("guard await rebuildGraph() else { return }"), + "A failed rebuild request must not spend the one-shot empty-graph bootstrap latch" + ) + } + + func testLoadGraphSkipsResimulationForUnchangedGraph() throws { + let source = try graphSource() + let method = try methodBody(named: "loadGraph", in: source) + + XCTAssertTrue(method.contains("let signature = Self.graphSignature(of: response)")) + XCTAssertTrue( + method.contains("if signature == loadedGraphSignature {"), + "An unchanged graph must keep the settled scene — no repopulate, no runSync, no camera reset" + ) + XCTAssertTrue( + method.contains("let showSpinner = isEmpty"), + "Freshness checks over a rendered scene must not flash the loading spinner" + ) + } + + // MARK: - Helpers + + private func graphSource() throws -> String { + try source(at: "Sources/MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift") + } + + private func containerSource() throws -> String { + try source(at: "Sources/ViewModelContainer.swift") + } + + private func source(at relativePath: String) throws -> String { + let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let sourceURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent(relativePath) + return try String(contentsOf: sourceURL, encoding: .utf8) + } + + private func methodBody(named name: String, in source: String) throws -> String { + let pattern = #"(?:private )?func \#(name)\([^\)]*\)[^{]*\{([\s\S]*?)\n \}"# + let regex = try NSRegularExpression(pattern: pattern) + let range = NSRange(source.startIndex..