From 3c2af29dd3b2d59877941fe62d089b2ae1bc92fa Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 02:52:57 -0400 Subject: [PATCH 01/29] refactor(desktop): mount one chat shell for every account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DesktopHomeView held the app on a "Preparing Omi…" card until a network call decided which of two shells to mount, then rendered either ChatFirstShell or a legacy sidebar + DashboardPage tree. Both were the same product with different chrome, and the legacy branch was the only reason `useLegacyHomeDesign`, `useOldestHomeDesign`, DashboardPage's inline chat, SidebarView, and the widget hub still existed. The shell now mounts immediately for everyone. The server-owned capability still resolves — same request, same analytics event, same ChatProvider projection gate — but alongside the mounted shell rather than in front of it, and it now only decides whether the capability-gated kernel features engage. Capability-off renders the same shell. `navigate help` named a "Help from Founder" page no shell had mounted for a long time: the bridge resolved a title and then timed out. It now resolves to Settings → About, where getting help from a person actually lives. Co-Authored-By: Claude Fable 5.1 --- .../DesktopAutomationHomeStageActions.swift | 6 +- .../DesktopAutomationBridge+ChatFirst.swift | 47 +- .../Sources/DesktopAutomationBridge.swift | 18 +- .../Blocks/ChatFirstRichBlockContext.swift | 26 +- .../ChatFirst/CanonicalGoalsStore.swift | 5 + ...irstPromptMaterializationCoordinator.swift | 5 + .../MainWindow/ChatFirst/ChatFirstRoute.swift | 80 +- .../MainWindow/ChatFirst/ChatFirstShell.swift | 53 +- .../Components/DailyScoreWidget.swift | 190 - .../MainWindow/Components/GoalsWidget.swift | 965 ---- .../RecentConversationsWidget.swift | 62 - .../Components/TodaysTasksWidget.swift | 177 - .../Dashboard/DashboardViewModel.swift | 208 + .../Dashboard/HomeAskFocusPolicy.swift | 38 - .../Dashboard/HomeKnowsComposer.swift | 116 - .../Dashboard/WhatMattersNowSection.swift | 369 -- .../Sources/MainWindow/DesktopHomeView.swift | 570 +-- .../DesktopShellPresentationPolicy.swift | 92 - .../DesktopUpdateStatusPresentation.swift | 2 +- .../MainWindow/LegacySidebarSurface.swift | 28 - .../MainWindow/Pages/DashboardPage.swift | 4165 ----------------- .../SettingsContentView+Assistants.swift | 54 - .../MainWindow/Pages/SettingsPage.swift | 2 - .../QueryShell/QueryShellHome.swift | 134 +- .../Sources/MainWindow/SidebarView.swift | 1497 ------ .../macos/Desktop/Sources/ViewExporter.swift | 34 - .../Desktop/Sources/ViewModelContainer.swift | 2 +- 27 files changed, 437 insertions(+), 8508 deletions(-) delete mode 100644 desktop/macos/Desktop/Sources/MainWindow/Components/DailyScoreWidget.swift delete mode 100644 desktop/macos/Desktop/Sources/MainWindow/Components/GoalsWidget.swift delete mode 100644 desktop/macos/Desktop/Sources/MainWindow/Components/RecentConversationsWidget.swift delete mode 100644 desktop/macos/Desktop/Sources/MainWindow/Components/TodaysTasksWidget.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardViewModel.swift delete mode 100644 desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeAskFocusPolicy.swift delete mode 100644 desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsComposer.swift delete mode 100644 desktop/macos/Desktop/Sources/MainWindow/Dashboard/WhatMattersNowSection.swift delete mode 100644 desktop/macos/Desktop/Sources/MainWindow/LegacySidebarSurface.swift delete mode 100644 desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift delete mode 100644 desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift diff --git a/desktop/macos/Desktop/Sources/Automation/DesktopAutomationHomeStageActions.swift b/desktop/macos/Desktop/Sources/Automation/DesktopAutomationHomeStageActions.swift index 2d8785118c5..30c077e7d48 100644 --- a/desktop/macos/Desktop/Sources/Automation/DesktopAutomationHomeStageActions.swift +++ b/desktop/macos/Desktop/Sources/Automation/DesktopAutomationHomeStageActions.swift @@ -47,9 +47,9 @@ extension DesktopAutomationActionRegistry { + "Errors on shells whose Home has no stage; connectors live on the Apps page there." ) { _ in // `homeMode` is the stage's own answer to "is there a Connect tray here". It is written only - // by the view that renders the stage and is nil everywhere else (`HomeStageAutomationPolicy`), - // so this cannot succeed silently on a Home that has no tray to toggle — which is exactly what - // it did, answering "ok" and doing nothing, from the query-shell Home landing until now. + // by the view that renders the stage. `DashboardPage` was that view and no longer exists, so + // this is now always nil and the action always refuses — rather than answering "ok" and doing + // nothing, which is what it did from the query-shell Home landing until this guard. guard DesktopAutomationStateStore.shared.current().homeMode != nil else { return [ "error": "no Home stage on this shell, so there is no Connect tray to toggle — " diff --git a/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift b/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift index 8ee97c5835f..d78327ecf83 100644 --- a/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift +++ b/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift @@ -83,15 +83,12 @@ extension DesktopAutomationBridge { ) } - /// Confirms the target resolves to a known chat-first or legacy destination - /// so the acknowledgement path cannot mask an unknown route as success. + /// Confirms the target resolves to a known destination so the acknowledgement + /// path cannot mask an unknown route as success. private func validateKnownNavigationTarget( _ payload: DesktopAutomationNavigationRequest ) throws { - let isKnown = - ChatFirstRoute.automationVisibilityDestination(named: payload.target) != nil - || legacyAutomationDestinationTitle(named: payload.target) != nil - guard isKnown else { + guard ChatFirstRoute.automationVisibilityDestination(named: payload.target) != nil else { throw DesktopAutomationActionError.invalidParams("unknown_navigation_target") } } @@ -100,7 +97,6 @@ extension DesktopAutomationBridge { _ payload: DesktopAutomationNavigationRequest ) async throws -> DesktopAutomationSnapshot { let expectedChatFirstRoute = ChatFirstRoute.automationVisibilityDestination(named: payload.target)?.stableName - let expectedLegacyTitle = legacyAutomationDestinationTitle(named: payload.target) let deadline = Date().addingTimeInterval(5) while Date() < deadline { @@ -108,10 +104,8 @@ extension DesktopAutomationBridge { if !snapshot.snapshotStale, DesktopAutomationNavigationVisibilityPolicy.isTargetVisible( shellVariant: snapshot.shellVariant, - selectedTab: snapshot.selectedTab, visibleChatFirstRoute: snapshot.visibleChatFirstRoute, - expectedChatFirstRoute: expectedChatFirstRoute, - expectedLegacyTitle: expectedLegacyTitle + expectedChatFirstRoute: expectedChatFirstRoute ) { return snapshot @@ -121,40 +115,17 @@ extension DesktopAutomationBridge { throw DesktopAutomationActionError.invalidParams("navigation_target_not_visible") } - private func legacyAutomationDestinationTitle(named target: String) -> String? { - switch target.lowercased().replacingOccurrences(of: "-", with: "_") { - // Home is the chat surface, so "chat" and "home" name the same destination. - case "dashboard", "home", "chat": return "Home" - case "conversations": return "Conversations" - case "memories": return "Memories" - case "tasks": return "Tasks" - case "rewind": return "Rewind" - case "apps", "integrations": return "Apps" - case "settings": return "Settings" - case "permissions": return "Permissions" - case "help": return "Help from Founder" - default: return nil - } - } } -/// Shared legacy and cohort visibility comparison retained separately from the -/// HTTP bridge so it has no access to rollout state beyond the sampled snapshot. +/// Visibility comparison retained separately from the HTTP bridge so it has no +/// access to shell state beyond the sampled snapshot. enum DesktopAutomationNavigationVisibilityPolicy { static func isTargetVisible( shellVariant: String?, - selectedTab: String?, visibleChatFirstRoute: String?, - expectedChatFirstRoute: String?, - expectedLegacyTitle: String? + expectedChatFirstRoute: String? ) -> Bool { - switch shellVariant { - case "chat_first": - return expectedChatFirstRoute != nil && visibleChatFirstRoute == expectedChatFirstRoute - case "legacy": - return expectedLegacyTitle != nil && selectedTab == expectedLegacyTitle - default: - return false - } + guard shellVariant == DesktopAutomationSnapshot.singleShellVariant else { return false } + return expectedChatFirstRoute != nil && visibleChatFirstRoute == expectedChatFirstRoute } } diff --git a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift index c2aa7107c8b..5f28781fc85 100644 --- a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift +++ b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift @@ -138,6 +138,10 @@ enum DesktopAutomationLaunchOptions { } struct DesktopAutomationSnapshot: Codable, Sendable { + /// The app has one shell. Flows and the navigation-visibility policy still read + /// `shellVariant`, so it is pinned here rather than removed from the contract. + static let singleShellVariant = "chat_first" + var bridgeEnabled: Bool var bridgePort: UInt16 var bundleIdentifier: String @@ -146,14 +150,14 @@ struct DesktopAutomationSnapshot: Codable, Sendable { var selectedTabIndex: Int? var selectedSettingsSection: String? var highlightedSettingId: String? - var usesLegacyHomeDesign: Bool - /// Home stage mode: `hub`, `chat`, or `connect`. Written only by `DashboardPage`, which is the only - /// view that renders the stage; nil whenever nothing on screen has one — which includes the whole - /// legacy shell, whose Home is the query surface. Never defaulted: see `HomeStageAutomationPolicy`. + /// Home stage mode: `hub`, `chat`, or `connect`. `DashboardPage` was the only view that ever + /// rendered that stage and it no longer exists, so this is now always nil. Kept in the snapshot + /// so an older flow reading it sees "no stage" rather than a missing key. var homeMode: String? - /// `loading`, `legacy`, or `chat_first`; never a local rollout preference. + /// Always `chat_first` on a mounted shell: the app has exactly one. Nil only before the shell has + /// reported state. Never a local preference. var shellVariant: String? - /// Stable typed route for the Chat-first shell. Nil for the legacy shell. + /// Stable typed route for the one shell. var chatFirstRoute: String? /// Set only by the mounted Chat-first destination after it has appeared. This /// keeps a successful navigation response equivalent to the target being @@ -167,6 +171,7 @@ struct DesktopAutomationSnapshot: Codable, Sendable { /// never an analytics dimension or a persisted navigation value. var focusedEntityID: String? var isFocusedEntityAcknowledged: Bool + /// Retained for snapshot compatibility; the legacy sidebar shell is gone, so it is always false. var showsPrimarySidebar: Bool var isSidebarCollapsed: Bool var hasCompletedOnboarding: Bool @@ -464,7 +469,6 @@ final class DesktopAutomationStateStore { selectedTabIndex: nil, selectedSettingsSection: nil, highlightedSettingId: nil, - usesLegacyHomeDesign: false, homeMode: nil, shellVariant: nil, chatFirstRoute: nil, diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift index b691f53521d..030d9c37b11 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift @@ -1,9 +1,10 @@ import Foundation -/// Explicit rendering capability for persisted chat-first blocks. The journal -/// is shared by every Chat surface, but rich controls belong exclusively to -/// the enabled main-window shell. Passing this context is therefore a -/// rendering capability, not a second transcript or a user-controlled flag. +/// The owners a persisted content block needs to become an interactable +/// control: typed navigation, the task store it checks off against, the one +/// chat provider, the canonical goals projection, and the prompt-materialization +/// coordinator. Every Chat surface has one — it is not a capability flag and it +/// is never a second transcript. @MainActor struct ChatFirstRichBlockContext { let navigation: ChatFirstShellNavigation @@ -26,3 +27,20 @@ struct ChatFirstRichBlockContext { self.promptMaterializationCoordinator = promptMaterializationCoordinator } } + +@MainActor +extension ChatFirstRichBlockContext { + /// The context for a Chat surface that is not the main-window shell — the task + /// panel and the floating/notch renderers. They own no navigation or goal + /// state, so they bind the shell's process-wide owners: a card tapped in the + /// notch routes the main window instead of a private copy of it. + static func auxiliary(chatProvider: ChatProvider) -> ChatFirstRichBlockContext { + ChatFirstRichBlockContext( + navigation: .shared, + tasksStore: .shared, + chatProvider: chatProvider, + canonicalGoalsStore: .shared, + promptMaterializationCoordinator: .shared + ) + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift index 4637b86e2a7..340557e4e25 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift @@ -33,6 +33,11 @@ extension APIClient: CanonicalGoalsClient {} /// local rollout preference or re-decides the cohort from cached goal data. @MainActor final class CanonicalGoalsStore: ObservableObject { + /// The one projection. `ViewModelContainer` binds it, and auxiliary Chat + /// surfaces reuse it so a goal link resolves against the same store the shell + /// activated rather than an inert second one. + static let shared = CanonicalGoalsStore() + enum Availability: Equatable { case inactive case loading diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift index 7bc981f634a..77923852a6b 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift @@ -24,6 +24,11 @@ enum ChatFirstPromptMaterializationPolicy { /// state remain on the backend/kernel respectively. @MainActor final class ChatFirstPromptMaterializationCoordinator: ObservableObject { + /// The one coordinator. Auxiliary Chat surfaces bind it so they cannot start a + /// second materialization lane; only the mounted main transcript ever reports + /// its first page to it. + static let shared = ChatFirstPromptMaterializationCoordinator() + private var driver: (any ChatFirstPromptMaterializationDriving)? private var didLoadTranscriptFirstPage = false private var lastAttemptAt: Date? diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift index 5cc0dde9ef4..eee77e45f30 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift @@ -1,3 +1,4 @@ +import AppKit import Combine import Foundation @@ -82,6 +83,10 @@ enum ChatFirstRoute: Hashable, Codable, Sendable { let normalized = target.lowercased().replacingOccurrences(of: "-", with: "_") switch normalized { case "dashboard", "home": return .chat + // `help` used to name a "Help from Founder" page no shell mounted. Getting + // help from a person lives in Settings → About (the Community card), so the + // legacy name resolves to the destination that actually exists. + case "help": return .more(.settings) case "rewind": return .more(.rewind) case "apps", "integrations": return .more(.apps) case "permissions": return .more(.permissions) @@ -91,6 +96,14 @@ enum ChatFirstRoute: Hashable, Codable, Sendable { } } +extension ChatFirstRoute { + /// True for the automation names that mean "get help from a person". The root + /// pre-selects the About section for these before routing to Settings. + static func isHelpAutomationTarget(_ target: String) -> Bool { + target.lowercased().replacingOccurrences(of: "-", with: "_") == "help" + } +} + enum ChatFirstMorePage: String, CaseIterable, Codable, Hashable, Sendable { case dashboard case rewind @@ -189,6 +202,11 @@ private struct ChatFirstPersistedNavigation: Codable, Equatable { final class ChatFirstShellNavigation: ObservableObject { static let storageKey = "chatFirstShell.windowNavigation.v1" + /// The one navigation owner. The main window binds it, and the auxiliary Chat + /// surfaces (task panel, floating/notch) bind the same instance so a content + /// block tapped anywhere routes the single shell rather than a private copy. + static let shared = ChatFirstShellNavigation() + @Published private(set) var route: ChatFirstRoute /// The destination currently mounted by SwiftUI. This is deliberately /// separate from `route`: navigation commands are not complete until the @@ -313,6 +331,7 @@ final class ChatFirstShellNavigation: ObservableObject { /// navigation; no legacy page can receive a pending focus. func open(focus: ChatFirstPendingFocus, destination: ChatFirstRoute) { guard destination.isPrimaryDestination else { return } + presentMainWindowIfNeeded() pendingConversation = nil invalidateLinkResolutions() route = destination @@ -338,6 +357,7 @@ final class ChatFirstShellNavigation: ObservableObject { func open(conversation: ServerConversation, destination: ChatFirstRoute) { guard destination.isPrimaryDestination else { return } guard !conversation.id.isEmpty else { return } + presentMainWindowIfNeeded() invalidateLinkResolutions() route = destination visibleRoute = nil @@ -469,6 +489,17 @@ final class ChatFirstShellNavigation: ObservableObject { } } + /// A typed deep link can originate from a surface that is not the main window + /// (a content block in the notch or the task panel). Bring the window forward + /// so the destination this call selects is actually on screen. Already-key is + /// the common case and stays a no-op. + private func presentMainWindowIfNeeded() { + guard let window = NSApp.mainWindow, window.isKeyWindow, window.isVisible else { + AppDelegate.summonWindowTarget()?.openMainAppWindow() + return + } + } + private func persistNavigation() { let persisted = ChatFirstPersistedNavigation(route: route, isSidebarCollapsed: isSidebarCollapsed) defaults.set(try? JSONEncoder().encode(persisted), forKey: Self.storageKey) @@ -502,30 +533,14 @@ final class ChatFirstShellNavigation: ObservableObject { } -/// An immutable per-root sampling result. A failed, missing, stale, or -/// owner-mismatched control response resolves to legacy. Once resolved for an -/// owner it never live-swaps; owner replacement fails closed for this launch. -enum ChatFirstShellVariant: Equatable { - case unresolved - case legacy - case chatFirst(ChatFirstCapabilityProjection) - - var projection: ChatFirstCapabilityProjection? { - guard case .chatFirst(let projection) = self else { return nil } - return projection - } - - var stableName: String { - switch self { - case .unresolved: return "loading" - case .legacy: return "legacy" - case .chatFirst: return "chat_first" - } - } -} - -struct ChatFirstShellCapabilitySample: Equatable { - private(set) var variant: ChatFirstShellVariant = .unresolved +/// An immutable per-root sampling result for the server-owned chat-first +/// capability. It never selects a shell — there is exactly one — and only says +/// whether the capability-gated kernel features may engage this launch. A +/// failed, missing, stale, or owner-mismatched control response resolves to +/// capability-off; content blocks still render either way. +struct ChatFirstCapabilitySample: Equatable { + private(set) var isResolved = false + private(set) var projection: ChatFirstCapabilityProjection? private(set) var sampledOwnerID: String? mutating func resolve( @@ -533,29 +548,30 @@ struct ChatFirstShellCapabilitySample: Equatable { requestedOwnerID: String?, ownerIsStillCurrent: Bool ) { - guard case .unresolved = variant else { return } + guard !isResolved else { return } + isResolved = true guard let ownerID = requestedOwnerID, !ownerID.isEmpty, ownerIsStillCurrent else { - variant = .legacy + projection = nil return } sampledOwnerID = ownerID - if let control, let projection = ChatFirstCapabilityProjection(control: control) { - variant = .chatFirst(projection) - } else { - variant = .legacy + guard let control else { + projection = nil + return } + projection = ChatFirstCapabilityProjection(control: control) } mutating func ownerDidChange(to ownerID: String?) { guard let sampledOwnerID else { return } guard sampledOwnerID == ownerID else { - variant = .legacy + projection = nil return } } mutating func failClosed() { - variant = .legacy + projection = nil } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift index fbe2b7efa51..6cf405b807b 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift @@ -8,10 +8,13 @@ struct ChatFirstShell: View { @ObservedObject var navigation: ChatFirstShellNavigation let appState: AppState let viewModelContainer: ViewModelContainer - let capability: ChatFirstCapabilityProjection + /// Nil until the server-owned control resolves, and permanently nil for an + /// account it does not cover. The shell mounts either way; only the + /// capability-gated features below wait on it. + let capability: ChatFirstCapabilityProjection? @Binding var selectedSettingsSection: SettingsContentView.SettingsSection @Binding var highlightedSettingID: String? - @StateObject private var promptMaterializationCoordinator = ChatFirstPromptMaterializationCoordinator() + @ObservedObject private var promptMaterializationCoordinator = ChatFirstPromptMaterializationCoordinator.shared @StateObject private var automationRuntime: ChatFirstAutomationRuntime @AppStorage(MemoryHubDestination.storageKey) private var memoryDestinationRawValue = MemoryHubDestination.memories.rawValue @@ -21,7 +24,7 @@ struct ChatFirstShell: View { navigation: ChatFirstShellNavigation, appState: AppState, viewModelContainer: ViewModelContainer, - capability: ChatFirstCapabilityProjection, + capability: ChatFirstCapabilityProjection?, selectedSettingsSection: Binding, highlightedSettingID: Binding ) { @@ -66,7 +69,7 @@ struct ChatFirstShell: View { .environmentObject(navigation) .onAppear { promptMaterializationCoordinator.activate(using: viewModelContainer.chatProvider) - viewModelContainer.canonicalGoalsStore.activate(capability: capability) + activateCapabilityGatedFeatures() automationRuntime.install() syncMemoryDestination(for: navigation.route) syncSettingsSection(for: navigation.route) @@ -75,6 +78,9 @@ struct ChatFirstShell: View { ) } .onDisappear { automationRuntime.uninstall() } + // The capability resolves after the shell is already on screen, so the + // gated features engage here rather than only at mount. + .onChange(of: capability) { _, _ in activateCapabilityGatedFeatures() } .onChange(of: navigation.route) { _, route in syncMemoryDestination(for: route) syncSettingsSection(for: route) @@ -114,6 +120,11 @@ struct ChatFirstShell: View { } } + private func activateCapabilityGatedFeatures() { + guard let capability else { return } + viewModelContainer.canonicalGoalsStore.activate(capability: capability) + } + private var isMainWindowForeground: Bool { guard NSApp.isActive, let window = NSApp.mainWindow else { return false } return window.isKeyWindow && window.isVisible @@ -204,9 +215,7 @@ struct ChatFirstShell: View { chatProvider: viewModelContainer.chatProvider, memoriesViewModel: viewModelContainer.memoriesViewModel, taskChatCoordinator: viewModelContainer.taskChatCoordinator, - forceModernPresentation: true, - chatFirstRichBlockContext: richBlockContext, - selectedIndex: legacySelectionBinding + chatFirstRichBlockContext: richBlockContext ) } @@ -331,36 +340,6 @@ struct ChatFirstShell: View { } } - /// Existing Dashboard callbacks still speak in legacy sidebar items. Keep - /// that compatibility at this one boundary while the Chat-first shell itself is - /// entirely route-typed. - private var legacySelectionBinding: Binding { - Binding( - get: { legacySidebarItem(for: navigation.route).rawValue }, - set: { rawValue in - guard let item = SidebarNavItem(rawValue: rawValue) else { return } - navigation.selectLegacyDestination(item) - } - ) - } - - private func legacySidebarItem(for route: ChatFirstRoute) -> SidebarNavItem { - switch route { - case .chat: return .dashboard - case .conversations: return .conversations - case .tasks: return .tasks - case .memories: return .memories - case .goals: return .dashboard - case .more(let page): - switch page { - case .dashboard: return .dashboard - case .rewind: return .rewind - case .apps: return .apps - case .permissions: return .permissions - case .settings: return .settings - } - } - } } /// Chat-first passes through every destination that owns search/content panels. Older single-panel diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/DailyScoreWidget.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/DailyScoreWidget.swift deleted file mode 100644 index 6b3e42f2a91..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/DailyScoreWidget.swift +++ /dev/null @@ -1,190 +0,0 @@ -import OmiTheme -import SwiftUI - -struct ScoreWidget: View { - let scoreResponse: ScoreResponse? - - private var weeklyScore: ScoreData { - scoreResponse?.weekly ?? ScoreData(score: 0, completedTasks: 0, totalTasks: 0) - } - - private var scoreColor: Color { - if !weeklyScore.hasTasks { - return Ink.secondary - } - let score = weeklyScore.score - if score >= 80 { - return .green - } else if score >= 60 { - return Color(red: 0.8, green: 0.8, blue: 0.0) - } else if score >= 40 { - return .orange - } else { - return .red - } - } - - var body: some View { - GeometryReader { geometry in - let gaugeWidth = min(geometry.size.width * 0.55, 180) - let gaugeHeight = gaugeWidth / 2 - let lineWidth = max(gaugeWidth * 0.085, 8) - let fontSize = max(gaugeWidth * 0.2, 18) - - VStack(spacing: OmiSpacing.lg) { - // Semicircle gauge - ZStack { - // Background arc - SemicircleShape() - .stroke(Ink.rowFillHover, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) - .frame(width: gaugeWidth, height: gaugeHeight) - - // Progress arc - SemicircleShape() - .trim(from: 0, to: min(weeklyScore.score / 100, 1.0)) - .stroke(scoreColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) - .frame(width: gaugeWidth, height: gaugeHeight) - .omiAnimation(.easeInOut(duration: 0.3), value: weeklyScore.score) - - // Score text - VStack(spacing: OmiSpacing.hairline) { - Text("\(Int(weeklyScore.score))%") - .scaledFont(size: fontSize, weight: .bold) - .foregroundColor(Ink.primary) - .contentTransition(.numericText()) - } - .offset(y: gaugeHeight * 0.14) - } - - // Task count and subtitle - VStack(spacing: OmiSpacing.xxs) { - if weeklyScore.hasTasks { - HStack(spacing: OmiSpacing.xxs) { - Image(systemName: "checkmark.circle.fill") - .scaledFont(size: OmiType.caption) - .foregroundColor(scoreColor) - Text("\(weeklyScore.completedTasks) of \(weeklyScore.totalTasks) tasks completed") - .scaledMonospacedDigitFont(size: 12) - .foregroundColor(Ink.secondary) - .contentTransition(.numericText()) - } - } else { - Text("No tasks this week") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - } - - Text("Last 7 days") - .scaledFont(size: OmiType.micro) - .foregroundColor(Ink.secondary) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(OmiSpacing.xl) - } - .frame(minHeight: 200) - .glassCard() - } -} - -// MARK: - Legacy Widget (for backwards compatibility) - -struct DailyScoreWidget: View { - let dailyScore: DailyScore? - - private var score: Double { - dailyScore?.score ?? 0 - } - - private var hasTasksToday: Bool { - (dailyScore?.totalTasks ?? 0) > 0 - } - - private var scoreColor: Color { - // Grey when no tasks (like Flutter) - if !hasTasksToday { - return Ink.secondary - } - if score >= 80 { - return .green - } else if score >= 60 { - return Color(red: 0.8, green: 0.8, blue: 0.0) // Lime/Yellow - } else if score >= 40 { - return .orange - } else { - return .red - } - } - - var body: some View { - VStack(spacing: OmiSpacing.lg) { - // Header - HStack { - Text("Daily Score") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - Spacer() - } - - // Semicircle gauge - ZStack { - // Background arc - SemicircleShape() - .stroke(Ink.rowFillHover, style: StrokeStyle(lineWidth: 12, lineCap: .round)) - .frame(width: 140, height: 70) - - // Progress arc - SemicircleShape() - .trim(from: 0, to: min(score / 100, 1.0)) - .stroke(scoreColor, style: StrokeStyle(lineWidth: 12, lineCap: .round)) - .frame(width: 140, height: 70) - - // Score text - VStack(spacing: OmiSpacing.hairline) { - Text("\(Int(score))%") - .scaledFont(size: OmiType.title, weight: .bold) - .foregroundColor(Ink.primary) - } - .offset(y: 10) - } - - // Task count - if let ds = dailyScore, ds.totalTasks > 0 { - HStack(spacing: OmiSpacing.xxs) { - Image(systemName: "checkmark.circle.fill") - .scaledFont(size: OmiType.caption) - .foregroundColor(scoreColor) - Text("\(ds.completedTasks) of \(ds.totalTasks) tasks completed") - .scaledMonospacedDigitFont(size: 12) - .foregroundColor(Ink.secondary) - } - } else { - Text("No tasks due today") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - } - } - .padding(OmiSpacing.xl) - .glassCard() - } -} - -// MARK: - Semicircle Shape - -struct SemicircleShape: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - let center = CGPoint(x: rect.midX, y: rect.maxY) - let radius = min(rect.width, rect.height * 2) / 2 - - path.addArc( - center: center, - radius: radius, - startAngle: .degrees(180), - endAngle: .degrees(0), - clockwise: false - ) - - return path - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/GoalsWidget.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/GoalsWidget.swift deleted file mode 100644 index 6fc8600309f..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/GoalsWidget.swift +++ /dev/null @@ -1,965 +0,0 @@ -import OmiTheme -import SwiftUI - -// MARK: - Goals Widget - -struct GoalsWidget: View { - let goals: [Goal] - let onCreateGoal: (String, Double, Double) -> Void // (title, currentValue, targetValue) - let onUpdateGoal: (Goal, String, Double, Double) -> Void - let onUpdateProgress: (Goal, Double) -> Void - let onDeleteGoal: (Goal) -> Void - - @State private var editingGoal: Goal? = nil - @State private var showingCreateSheet = false - @State private var showingHistory = false - @State private var isGeneratingGoal = false - - // AI Features - @State private var selectedGoalForInsight: Goal? = nil - - var body: some View { - VStack(alignment: .leading, spacing: OmiSpacing.lg) { - // Header - HStack { - Text("Goals") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - - Spacer() - - // Add goal button (only if less than 3 goals) - if goals.count < 4 { - GoalHeaderButton(icon: "plus", tooltip: "Add goal", color: Ink.secondary) { - showingCreateSheet = true - } - } - } - - if goals.isEmpty { - // Empty state — header already has a + button, so just offer - // the AI generation action centered in the empty area. - VStack(spacing: 0) { - Spacer(minLength: 0) - - Button(action: { triggerGoalGeneration() }) { - HStack(spacing: OmiSpacing.xs) { - if isGeneratingGoal { - ProgressView() - .scaleEffect(0.6) - .frame(width: 12, height: 12) - } else { - Image(systemName: "sparkles") - .scaledFont(size: OmiType.caption) - } - Text(isGeneratingGoal ? "Generating..." : "Generate AI Goal") - .scaledFont(size: OmiType.body, weight: .medium) - } - .foregroundColor(Ink.primary) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .glassChip(isActive: true) - } - .buttonStyle(.plain) - .disabled(isGeneratingGoal) - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - // Goals list — centered vertically in remaining cell height - // so a shorter Goals list floats to the middle when the - // Tasks card determines the row's intrinsic height. - VStack(spacing: 0) { - Spacer(minLength: 0) - - VStack(spacing: OmiSpacing.md) { - ForEach(Array(goals.enumerated()), id: \.element.id) { index, goal in - GoalRowView( - goal: goal, - index: index, - onTap: { editingGoal = goal }, - onUpdateProgress: { value in onUpdateProgress(goal, value) }, - onDelete: { onDeleteGoal(goal) }, - onGetInsight: { - selectedGoalForInsight = goal - } - ) - } - } - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .padding(OmiSpacing.xl) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .glassCard() - .sheet(isPresented: $showingCreateSheet) { - GoalEditSheet( - goal: nil, - onSave: { title, current, target in - onCreateGoal(title, current, target) - }, - onDelete: nil, - onDismiss: { showingCreateSheet = false } - ) - } - .sheet(item: $editingGoal) { goal in - GoalEditSheet( - goal: goal, - onSave: { title, current, target in - onUpdateGoal(goal, title, current, target) - }, - onDelete: { - onDeleteGoal(goal) - }, - onDismiss: { editingGoal = nil } - ) - } - .sheet(item: $selectedGoalForInsight) { goal in - GoalInsightSheet( - goal: goal, - onDismiss: { selectedGoalForInsight = nil } - ) - } - .sheet(isPresented: $showingHistory) { - GoalsHistoryPage(onDismiss: { showingHistory = false }) - .frame(width: 480, height: 500) - } - } - - private func triggerGoalGeneration() { - isGeneratingGoal = true - Task { - await GoalGenerationService.shared.generateNow() - isGeneratingGoal = false - } - } -} - -// MARK: - Goal Row View - -struct GoalRowView: View { - let goal: Goal - let index: Int - let onTap: () -> Void - let onUpdateProgress: (Double) -> Void - let onDelete: () -> Void - var onGetInsight: (() -> Void)? = nil - - @State private var isHovering = false - @State private var isDragging = false - @State private var dragValue: Double? = nil - @State private var isExpanded = false - @State private var linkedTasks: [TaskActionItem] = [] - @State private var hasLoadedTasks = false - - /// The progress fraction (0-1) to display, using drag value when active - private var displayProgress: Double { - if let dv = dragValue { - return min(max(dv, 0), 1) - } - return min(goal.progress / 100.0, 1.0) - } - - private var progressColor: Color { - let progress = displayProgress - if progress >= 0.8 { - return Color(red: 0.133, green: 0.773, blue: 0.369) // #22C55E Green - } else if progress >= 0.6 { - return Color(red: 0.518, green: 0.8, blue: 0.086) // #84CC16 Lime - } else if progress >= 0.4 { - return Color(red: 0.984, green: 0.749, blue: 0.141) // #FBBF24 Yellow - } else if progress >= 0.2 { - return Color(red: 0.976, green: 0.451, blue: 0.086) // #F97316 Orange - } else { - return Ink.secondary - } - } - - private var dragProgressText: String { - let currentVal: Double - if let dv = dragValue { - let raw = goal.minValue + dv * (goal.targetValue - goal.minValue) - currentVal = max(goal.minValue, min(raw, goal.targetValue)) - } else { - currentVal = goal.currentValue - } - return "\(Int(currentVal.rounded()))/\(Int(goal.targetValue.rounded()))" - } - - var body: some View { - HStack(spacing: OmiSpacing.md) { - // Emoji icon - tapping opens edit sheet - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(Ink.rowFill.opacity(0.9)) - .frame(width: 36, height: 36) - Text(goalEmoji) - .scaledFont(size: OmiType.subheading) - } - .onTapGesture { onTap() } - - // Content - VStack(alignment: .leading, spacing: OmiSpacing.md) { - HStack { - Text(goal.title) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.primary) - .lineLimit(1) - .onTapGesture { onTap() } - - Spacer() - - // Expand/collapse button (if has description or linked tasks) - if goal.description != nil || !linkedTasks.isEmpty { - Button(action: { - OmiMotion.withGated(.easeInOut(duration: 0.2)) { - isExpanded.toggle() - } - }) { - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .scaledFont(size: OmiType.micro, weight: .medium) - .foregroundColor(Ink.secondary) - } - .buttonStyle(.plain) - } - - // Advice button (shown on hover) - if isHovering, let onGetInsight = onGetInsight { - Button(action: onGetInsight) { - Image(systemName: "lightbulb.fill") - .scaledFont(size: OmiType.caption) - .foregroundColor(.yellow) - } - .buttonStyle(.plain) - .transition(.opacity) - } - - // Progress value (current/target) - Text(dragProgressText) - .scaledFont(size: OmiType.caption) - .foregroundColor(isDragging ? Ink.primary : Ink.secondary) - .omiAnimation(.easeInOut(duration: 0.15), value: isDragging) - } - - // Progress bar with drag gesture - GeometryReader { geometry in - ZStack(alignment: .leading) { - // Background track - visible light gray - RoundedRectangle(cornerRadius: OmiChrome.stripRadius) - .fill(Ink.rowFill) - .frame(height: isDragging ? 8 : 6) - - // Progress fill - RoundedRectangle(cornerRadius: OmiChrome.stripRadius) - .fill(progressColor) - .frame( - width: max(0, geometry.size.width * displayProgress), - height: isDragging ? 8 : 6 - ) - - // Drag thumb - always visible - Circle() - .fill(Ink.primary) - .frame(width: 14, height: 14) - .shadow(color: .black.opacity(0.08), radius: 2, y: 1) - .offset(x: max(0, min(geometry.size.width * displayProgress - 7, geometry.size.width - 14))) - } - .frame(maxHeight: .infinity) - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - isDragging = true - let fraction = value.location.x / geometry.size.width - dragValue = min(max(fraction, 0), 1) - } - .onEnded { _ in - if let dv = dragValue { - let finalValue = goal.minValue + dv * (goal.targetValue - goal.minValue) - let clampedValue = max(goal.minValue, min(finalValue, goal.targetValue)) - let roundedValue = clampedValue.rounded() - onUpdateProgress(roundedValue) - } - isDragging = false - dragValue = nil - } - ) - } - .frame(height: 18) - .omiAnimation(.easeInOut(duration: 0.15), value: isDragging) - - // Expanded section: description + linked tasks - if isExpanded { - VStack(alignment: .leading, spacing: OmiSpacing.sm) { - // Description - if let desc = goal.description, !desc.isEmpty { - Text(desc) - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - .lineLimit(3) - } - - // Linked tasks - if !linkedTasks.isEmpty { - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text("Linked Tasks") - .scaledFont(size: OmiType.micro, weight: .semibold) - .foregroundColor(Ink.secondary) - .textCase(.uppercase) - - ForEach(linkedTasks) { task in - HStack(spacing: OmiSpacing.xs) { - Image(systemName: task.completed ? "checkmark.circle.fill" : "circle") - .scaledFont(size: OmiType.caption) - .foregroundColor( - task.completed ? Color(red: 0.133, green: 0.773, blue: 0.369) : Ink.secondary) - - Text(task.description) - .scaledFont(size: OmiType.caption) - .foregroundColor(task.completed ? Ink.secondary : Ink.primary) - .strikethrough(task.completed) - .lineLimit(1) - } - } - } - } - } - .padding(.top, OmiSpacing.hairline) - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - } - .padding(.vertical, OmiSpacing.md) - .padding(.horizontal, OmiSpacing.md) - .background( - RoundedRectangle(cornerRadius: OmiChrome.controlRadius, style: .continuous) - .fill(Ink.rowFillHover.opacity(isHovering ? 0.9 : 0.72)) - ) - .onHover { hovering in - OmiMotion.withGated(.easeInOut(duration: 0.15)) { - isHovering = hovering - } - } - .task { - guard !hasLoadedTasks else { return } - hasLoadedTasks = true - await loadLinkedTasks() - } - } - - private func loadLinkedTasks() async { - do { - let response = try await APIClient.shared.getActionItems(limit: 100, completed: nil) - linkedTasks = response.items.filter { $0.goalId == goal.id } - } catch { - // Silently fail — linked tasks are supplementary - } - } - - private var goalEmoji: String { - let title = goal.title.lowercased() - - // Money/Revenue - if title.contains("revenue") || title.contains("money") || title.contains("income") || title.contains("profit") - || title.contains("sales") || title.contains("$") || title.contains("dollar") || title.contains("earn") - { - return "💰" - } - // Growth/Users - if title.contains("users") || title.contains("customers") || title.contains("clients") - || title.contains("subscribers") || title.contains("followers") || title.contains("growth") - || title.contains("million") || title.contains("1m") || title.contains("10k") || title.contains("100k") - || title.contains("mrr") || title.contains("arr") - { - return "🚀" - } - // Startup/Business - if title.contains("startup") || title.contains("launch") || title.contains("business") || title.contains("company") - { - return "🏆" - } - // Investment - if title.contains("invest") || title.contains("stock") || title.contains("crypto") || title.contains("trading") { - return "📈" - } - // Workout/Gym - if title.contains("workout") || title.contains("gym") || title.contains("exercise") || title.contains("lift") - || title.contains("muscle") || title.contains("strength") || title.contains("pushup") || title.contains("pullup") - { - return "💪" - } - // Running/Cardio - if title.contains("run") || title.contains("marathon") || title.contains("jog") || title.contains("cardio") - || title.contains("steps") || title.contains("walk") || title.contains("mile") || title.contains("km") - { - return "🏃" - } - // Weight/Diet - if title.contains("weight") || title.contains("lose") || title.contains("fat") || title.contains("diet") - || title.contains("calories") || title.contains("kg") || title.contains("lbs") || title.contains("pounds") - { - return "⚖️" - } - // Meditation/Yoga - if title.contains("meditat") || title.contains("mindful") || title.contains("yoga") || title.contains("breath") - || title.contains("calm") || title.contains("peace") || title.contains("zen") - { - return "🧘" - } - // Sleep - if title.contains("sleep") || title.contains("rest") || title.contains("hours") { - return "😴" - } - // Water/Hydration - if title.contains("water") || title.contains("hydrat") || title.contains("drink") { - return "💧" - } - // Health - if title.contains("health") || title.contains("wellness") || title.contains("healthy") { - return "❤️" - } - // Reading - if title.contains("read") || title.contains("book") || title.contains("pages") || title.contains("chapter") { - return "📚" - } - // Learning - if title.contains("learn") || title.contains("study") || title.contains("course") || title.contains("class") - || title.contains("skill") || title.contains("certif") - { - return "🎓" - } - // Coding - if title.contains("code") || title.contains("program") || title.contains("develop") || title.contains("app") - || title.contains("software") || title.contains("tech") - { - return "💻" - } - // Language - if title.contains("language") || title.contains("spanish") || title.contains("french") || title.contains("chinese") - || title.contains("english") || title.contains("german") - { - return "🗣️" - } - // Writing - if title.contains("write") || title.contains("blog") || title.contains("article") || title.contains("post") - || title.contains("content") || title.contains("words") - { - return "✍️" - } - // Video - if title.contains("video") || title.contains("youtube") || title.contains("tiktok") || title.contains("film") { - return "🎬" - } - // Music - if title.contains("music") || title.contains("song") || title.contains("piano") || title.contains("guitar") - || title.contains("sing") - { - return "🎵" - } - // Art - if title.contains("art") || title.contains("draw") || title.contains("paint") || title.contains("design") - || title.contains("create") - { - return "🎨" - } - // Photo - if title.contains("photo") || title.contains("picture") || title.contains("camera") { - return "📸" - } - // Tasks - if title.contains("task") || title.contains("todo") || title.contains("complete") || title.contains("finish") - || title.contains("done") - { - return "✅" - } - // Habits - if title.contains("habit") || title.contains("daily") || title.contains("streak") || title.contains("consistent") - || title.contains("routine") - { - return "🔥" - } - // Time/Focus - if title.contains("time") || title.contains("hour") || title.contains("minute") || title.contains("focus") - || title.contains("pomodoro") || title.contains("productive") - { - return "⏰" - } - // Project/Ship - if title.contains("project") || title.contains("ship") || title.contains("deliver") || title.contains("deadline") - || title.contains("feature") - { - return "🎯" - } - // Travel - if title.contains("travel") || title.contains("trip") || title.contains("visit") || title.contains("country") - || title.contains("city") || title.contains("vacation") - { - return "✈️" - } - // Home - if title.contains("home") || title.contains("house") || title.contains("apartment") || title.contains("move") - || title.contains("buy") - { - return "🏠" - } - // Saving - if title.contains("save") || title.contains("saving") || title.contains("budget") - || title.contains("emergency fund") - { - return "🏦" - } - // Social - if title.contains("friend") || title.contains("social") || title.contains("network") || title.contains("connect") - || title.contains("meet") || title.contains("outreach") - { - return "👥" - } - // Family - if title.contains("family") || title.contains("kids") || title.contains("parent") { - return "👨‍👩‍👧" - } - // Relationship - if title.contains("date") || title.contains("relationship") || title.contains("love") { - return "💕" - } - // Win/Success - if title.contains("win") || title.contains("first") || title.contains("best") || title.contains("top") - || title.contains("champion") - { - return "🏆" - } - // Growth/Improve - if title.contains("grow") || title.contains("improve") || title.contains("better") || title.contains("progress") { - return "🌱" - } - // Star/Success - if title.contains("star") || title.contains("success") || title.contains("excellent") { - return "⭐" - } - - // Default - return "🎯" - } -} - -// MARK: - Goal Edit Sheet - -struct GoalEditSheet: View { - let goal: Goal? - let onSave: (String, Double, Double) -> Void - let onDelete: (() -> Void)? - let onDismiss: () -> Void - - @State private var title: String = "" - @State private var currentValue: String = "0" - @State private var targetValue: String = "100" - @State private var selectedEmoji: String = "🎯" - - private let availableEmojis = [ - "🎯", "💪", "📚", "💰", "🏃", "🧘", "💡", "🔥", - "⭐", "🚀", "💎", "🏆", "📈", "❤️", "🎨", "🎵", - "✈️", "🏠", "🌱", "⏰", - ] - - var isNewGoal: Bool { goal == nil } - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Text(isNewGoal ? "Add Goal" : "Edit Goal") - .scaledFont(size: OmiType.heading, weight: .semibold) - .foregroundColor(Ink.primary) - - Spacer() - - Button(action: onDismiss) { - Image(systemName: "xmark") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.secondary) - .frame(width: 28, height: 28) - .background(Ink.rowFillHover.opacity(0.5)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - } - .padding(.horizontal, OmiSpacing.xl) - .padding(.top, OmiSpacing.xl) - .padding(.bottom, OmiSpacing.lg) - - Divider() - .background(Ink.rowFillHover) - - ScrollView { - VStack(alignment: .leading, spacing: OmiSpacing.xl) { - - // Title field - VStack(alignment: .leading, spacing: OmiSpacing.sm) { - Text("Goal Title") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - - TextField("Enter goal title", text: $title) - .textFieldStyle(.plain) - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.primary) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(Ink.rowFillHover.opacity(0.5)) - ) - } - - // Current & Target fields - HStack(spacing: OmiSpacing.md) { - VStack(alignment: .leading, spacing: OmiSpacing.sm) { - Text("Current") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - - TextField("0", text: $currentValue) - .textFieldStyle(.plain) - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.primary) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(Ink.rowFillHover.opacity(0.5)) - ) - } - - VStack(alignment: .leading, spacing: OmiSpacing.sm) { - Text("Target") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - - TextField("100", text: $targetValue) - .textFieldStyle(.plain) - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.primary) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(Ink.rowFillHover.opacity(0.5)) - ) - } - } - } - .padding(OmiSpacing.xl) - } - - Divider() - .background(Ink.rowFillHover) - - // Actions - HStack(spacing: OmiSpacing.md) { - // Delete button (only for existing goals) - if !isNewGoal, let onDelete = onDelete { - Button(action: { - onDelete() - onDismiss() - }) { - Text("Delete") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.errorRed) - } - .buttonStyle(.plain) - } - - Spacer() - - // Cancel button - Button(action: onDismiss) { - Text("Cancel") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.secondary) - } - .buttonStyle(.plain) - - // Save button - Button(action: { - let current = Double(currentValue) ?? 0 - let target = Double(targetValue) ?? 100 - onSave(title, current, target) - onDismiss() - }) { - Text(isNewGoal ? "Add Goal" : "Save") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.surface) - .padding(.horizontal, OmiSpacing.xl) - .padding(.vertical, OmiSpacing.sm) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(Ink.primary) - ) - } - .buttonStyle(.plain) - .disabled(title.isEmpty) - .opacity(title.isEmpty ? 0.5 : 1) - } - .padding(OmiSpacing.xl) - } - .frame(width: 400, height: isNewGoal ? 320 : 420) - .background(Ink.surface) - .onAppear { - if let goal = goal { - title = goal.title - currentValue = - goal.currentValue == goal.currentValue.rounded() - ? String(format: "%.0f", goal.currentValue) - : String(format: "%.1f", goal.currentValue) - targetValue = - goal.targetValue == goal.targetValue.rounded() - ? String(format: "%.0f", goal.targetValue) - : String(format: "%.1f", goal.targetValue) - } - } - } -} - -// MARK: - Goal Advice Sheet - -struct GoalInsightSheet: View { - let goal: Goal - let onDismiss: () -> Void - - @State private var isLoading = true - @State private var insight: String? = nil - @State private var errorMessage: String? = nil - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - HStack(spacing: OmiSpacing.sm) { - Image(systemName: "lightbulb.fill") - .scaledFont(size: OmiType.subheading) - .foregroundColor(.yellow) - Text("Goal Insight") - .scaledFont(size: OmiType.heading, weight: .semibold) - .foregroundColor(Ink.primary) - } - - Spacer() - - Button(action: onDismiss) { - Image(systemName: "xmark") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.secondary) - .frame(width: 28, height: 28) - .background(Ink.rowFillHover.opacity(0.5)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - } - .padding(.horizontal, OmiSpacing.xl) - .padding(.top, OmiSpacing.xl) - .padding(.bottom, OmiSpacing.lg) - - Divider() - .background(Ink.rowFillHover) - - // Goal info - HStack(spacing: OmiSpacing.md) { - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text(goal.title) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.primary) - .lineLimit(1) - - Text("\(Int(goal.currentValue))/\(Int(goal.targetValue)) (\(Int(goal.progress))%)") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - } - - Spacer() - - // Progress indicator - ZStack { - Circle() - .stroke(Ink.rowFillHover, lineWidth: 3) - Circle() - .trim(from: 0, to: min(goal.progress / 100, 1.0)) - .stroke(Ink.primary, style: StrokeStyle(lineWidth: 3, lineCap: .round)) - .rotationEffect(.degrees(-90)) - } - .frame(width: 36, height: 36) - } - .padding(.horizontal, OmiSpacing.xl) - .padding(.vertical, OmiSpacing.md) - .background(Ink.rowFillHover.opacity(0.3)) - - // Content - VStack(spacing: OmiSpacing.lg) { - if isLoading { - VStack(spacing: OmiSpacing.md) { - ProgressView() - .scaleEffect(1.2) - Text("Getting personalized insight...") - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = errorMessage { - VStack(spacing: OmiSpacing.md) { - Image(systemName: "exclamationmark.triangle") - .scaledFont(size: 32) - .foregroundColor(PageGlass.warning) - Text(error) - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let insightText = insight { - VStack(alignment: .leading, spacing: OmiSpacing.md) { - Text("This week's action:") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - - Text(insightText) - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.primary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(OmiSpacing.xl) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - Divider() - .background(Ink.rowFillHover) - - // Actions - HStack(spacing: OmiSpacing.md) { - // Refresh button - Button(action: loadInsight) { - HStack(spacing: OmiSpacing.xxs) { - Image(systemName: "arrow.clockwise") - .scaledFont(size: OmiType.caption) - Text("Refresh") - } - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.secondary) - } - .buttonStyle(.plain) - .disabled(isLoading) - - Spacer() - - // Done button - Button(action: onDismiss) { - Text("Done") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.surface) - .padding(.horizontal, OmiSpacing.xl) - .padding(.vertical, OmiSpacing.sm) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(Ink.primary) - ) - } - .buttonStyle(.plain) - } - .padding(OmiSpacing.xl) - } - .frame(width: 400, height: 380) - .background(Ink.surface) - .onAppear { - loadInsight() - } - } - - private func loadInsight() { - isLoading = true - errorMessage = nil - - Task { - do { - let result = try await GoalsAIService.shared.getGoalInsight(goal: goal) - await MainActor.run { - insight = result - isLoading = false - } - } catch { - await MainActor.run { - errorMessage = UserFacingErrorPresentation.message(for: error, while: .goals) - isLoading = false - } - } - } - } -} - -// MARK: - Goal Header Button with Tooltip - -private struct GoalHeaderButton: View { - let icon: String - let tooltip: String - let color: Color - var isLoading: Bool = false - let action: () -> Void - - @State private var isHovered = false - - var body: some View { - Button(action: action) { - if isLoading { - ProgressView() - .scaleEffect(0.6) - .frame(width: 14, height: 14) - } else { - Image(systemName: icon) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(color) - } - } - .buttonStyle(.plain) - .disabled(isLoading) - .overlay(alignment: .bottom) { - if isHovered { - Text(tooltip) - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.primary) - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.xxs) - .background( - RoundedRectangle(cornerRadius: OmiChrome.badgeRadius) - .fill(Ink.rowFillHover) - .shadow(color: .black.opacity(0.08), radius: 4, y: 2) - ) - .fixedSize() - .offset(y: 24) - .transition(.opacity) - } - } - .onHover { hovering in - OmiMotion.withGated(.easeInOut(duration: 0.15)) { - isHovered = hovering - } - } - } -} - -#if canImport(PreviewsMacros) - #Preview { - GoalsWidget( - goals: [], - onCreateGoal: { _, _, _ in }, - onUpdateGoal: { _, _, _, _ in }, - onUpdateProgress: { _, _ in }, - onDeleteGoal: { _ in } - ) - .frame(width: 350) - .padding() - .background(Ink.surface) - } -#endif diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/RecentConversationsWidget.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/RecentConversationsWidget.swift deleted file mode 100644 index cf624df9ae5..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/RecentConversationsWidget.swift +++ /dev/null @@ -1,62 +0,0 @@ -import OmiTheme -import SwiftUI - -struct RecentConversationsWidget: View { - let conversations: [ServerConversation] - let folders: [Folder] - let onViewAll: () -> Void - let onMoveToFolder: (String, String?) async -> Void - var appState: AppState - - var body: some View { - VStack(alignment: .leading, spacing: OmiSpacing.md) { - // Header - HStack { - Text("Recent Conversations") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - - Spacer() - - Button(action: onViewAll) { - Text("View All") - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundColor(Ink.secondary) - } - .buttonStyle(.plain) - } - - if conversations.isEmpty { - VStack(spacing: OmiSpacing.sm) { - Text("No conversations yet") - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, OmiSpacing.lg) - } else { - VStack(spacing: OmiSpacing.xxs) { - ForEach(conversations) { conversation in - ConversationRowView( - conversation: conversation, - onTap: onViewAll, - folders: folders, - onMoveToFolder: onMoveToFolder, - isCompactView: true, - appState: appState - ) - } - } - } - } - .padding(OmiSpacing.xl) - .background( - RoundedRectangle(cornerRadius: OmiChrome.controlRadius) - .fill(Ink.rowFillHover.opacity(0.5)) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.controlRadius) - .stroke(Ink.rowFillHover.opacity(0.5), lineWidth: 1) - ) - ) - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/TodaysTasksWidget.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/TodaysTasksWidget.swift deleted file mode 100644 index 291cdf8cb87..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/TodaysTasksWidget.swift +++ /dev/null @@ -1,177 +0,0 @@ -import OmiTheme -import SwiftUI - -struct TasksWidget: View { - let overdueTasks: [TaskActionItem] - let todaysTasks: [TaskActionItem] - let recentTasks: [TaskActionItem] - let onToggleCompletion: (TaskActionItem) -> Void - - private var totalTaskCount: Int { - overdueTasks.count + todaysTasks.count + recentTasks.count - } - - /// Combine overdue + today tasks into one "Today" section (like Flutter) - private var combinedTodayTasks: [TaskActionItem] { - // Sort: overdue first (by due date), then today's tasks (by due date) - let sorted = (overdueTasks + todaysTasks).sorted { a, b in - guard let aDate = a.dueAt, let bDate = b.dueAt else { return a.dueAt != nil } - return aDate < bDate - } - return sorted - } - - var body: some View { - VStack(alignment: .leading, spacing: OmiSpacing.lg) { - // Header - HStack { - Text("Tasks") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - } - - if totalTaskCount == 0 { - // Empty state — vertically centered in the cell - VStack(spacing: 0) { - Spacer(minLength: 0) - - VStack(spacing: OmiSpacing.sm) { - Image(systemName: "checkmark.circle") - .scaledFont(size: OmiType.title) - .foregroundColor(Ink.secondary) - Text("No incomplete tasks") - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.secondary) - } - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - let allTasks = (combinedTodayTasks + recentTasks).prefix(3) - - // Task rows + "View all" centered vertically in remaining - // cell height — when the Goals card is taller, the row - // group floats to the middle instead of pinning to the top. - VStack(spacing: 0) { - Spacer(minLength: 0) - - VStack(spacing: OmiSpacing.sm) { - ForEach(Array(allTasks)) { task in - TaskRowView( - task: task, - onToggle: { onToggleCompletion(task) } - ) - } - } - - Button(action: { - NotificationCenter.default.post( - name: .navigateToTasks, - object: nil - ) - }) { - HStack { - Spacer() - Text("View all tasks") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundColor(Ink.secondary) - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.micro) - .foregroundColor(Ink.secondary) - Spacer() - } - } - .buttonStyle(.plain) - .padding(.top, OmiSpacing.sm) - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .padding(OmiSpacing.xl) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .glassCard() - } -} - -// MARK: - Task Row View - -struct TaskRowView: View { - let task: TaskActionItem - let onToggle: () -> Void - - @State private var isToggling = false - - var body: some View { - HStack(spacing: OmiSpacing.md) { - // Checkbox - Button(action: { - guard !isToggling else { return } - isToggling = true - onToggle() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - isToggling = false - } - }) { - Image(systemName: task.completed ? "checkmark.circle.fill" : "circle") - .scaledFont(size: OmiType.heading) - .foregroundColor(task.completed ? Ink.primary : Ink.secondary) - } - .buttonStyle(.plain) - .disabled(isToggling) - .opacity(isToggling ? 0.5 : 1) - - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - HStack(spacing: OmiSpacing.xs) { - Text(task.description) - .scaledFont(size: OmiType.body) - .foregroundColor(task.completed ? Ink.secondary : Ink.primary) - .strikethrough(task.completed) - .lineLimit(2) - - if task.recurrenceRule == "daily" { - Image(systemName: "repeat") - .scaledFont(size: OmiType.micro) - .foregroundColor(Ink.secondary) - } - } - - if task.recurrenceRule == "daily" { - Text("Daily") - .scaledFont(size: OmiType.micro, weight: .medium) - .foregroundColor(Ink.secondary) - .padding(.horizontal, OmiSpacing.xs) - .padding(.vertical, OmiSpacing.hairline) - .background( - RoundedRectangle(cornerRadius: OmiChrome.stripRadius) - .fill(Ink.rowFillHover) - ) - } - } - - Spacer() - } - .padding(.vertical, OmiSpacing.sm) - .padding(.horizontal, OmiSpacing.md) - .background( - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .fill(task.completed ? Ink.rowFill.opacity(0.55) : Ink.rowFillHover.opacity(0.45)) - ) - } -} - -#if canImport(PreviewsMacros) - #Preview { - TasksWidget( - overdueTasks: [], - todaysTasks: [], - recentTasks: [], - onToggleCompletion: { _ in } - ) - .frame(width: 350) - .padding() - .background(Ink.surface) - } -#endif diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardViewModel.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardViewModel.swift new file mode 100644 index 00000000000..23f8e834f07 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardViewModel.swift @@ -0,0 +1,208 @@ +import Combine +import Foundation + +// MARK: - Dashboard View Model + +@MainActor +class DashboardViewModel: ObservableObject { + // Observe the shared TasksStore + private let tasksStore = TasksStore.shared + + @Published var scoreResponse: ScoreResponse? + @Published var goals: [Goal] = [] + @Published var isLoading = false + @Published var error: String? + + private var cancellables = Set() + private var lastGoalRefreshTime: Date = .distantPast + + // Computed properties that delegate to TasksStore + var overdueTasks: [TaskActionItem] { tasksStore.overdueTasks } + var todaysTasks: [TaskActionItem] { tasksStore.todaysTasks } + var recentTasks: [TaskActionItem] { tasksStore.tasksWithoutDueDate } + + init() { + // Forward TasksStore changes to trigger view updates + tasksStore.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + + // Load goals from local SQLite for instant display + loadGoalsFromLocal() + + // Refresh goals when one is auto-created + NotificationCenter.default.publisher(for: .goalAutoCreated) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + Task { [weak self] in + await self?.loadGoals() + } + } + .store(in: &cancellables) + } + + func loadDashboardData() async { + isLoading = true + error = nil + + // Load all data in parallel + async let scoreTask: Void = loadScores() + async let tasksTask: Void = tasksStore.refreshDashboardTasksFromServer() + async let goalsTask: Void = loadGoals() + + let _ = await (scoreTask, tasksTask, goalsTask) + + isLoading = false + } + + func loadCachedDashboardData() async { + await loadGoalsFromLocalSnapshot() + } + + func resetSessionState() { + scoreResponse = nil + goals = [] + isLoading = false + error = nil + lastGoalRefreshTime = .distantPast + } + + private func loadScores() async { + do { + scoreResponse = try await APIClient.shared.getScores() + } catch { + logError("Failed to load scores", error: error) + } + } + + private func loadGoals() async { + // 1. Show local data first (already loaded in init) + // 2. Fetch from API + do { + let apiGoals = try await APIClient.shared.getGoals() + // 3. Sync to SQLite + try await GoalStorage.shared.syncServerGoals(apiGoals) + // 4. Reload from SQLite (source of truth) + goals = try await GoalStorage.shared.getLocalGoals() + lastGoalRefreshTime = Date() + } catch { + logError("Failed to load goals", error: error) + } + } + + /// Refresh goals with 30-second debounce (for app lifecycle events) + func refreshGoals() { + let now = Date() + guard now.timeIntervalSince(lastGoalRefreshTime) > 30 else { return } + Task { + await loadGoals() + } + } + + // MARK: - Local Goals Storage + + private func loadGoalsFromLocal() { + Task { + await loadGoalsFromLocalSnapshot() + } + } + + private func loadGoalsFromLocalSnapshot() async { + do { + goals = try await GoalStorage.shared.getLocalGoals() + } catch { + logError("Failed to load goals from local storage", error: error) + } + } + + func toggleTaskCompletion(_ task: TaskActionItem) async { + // Delegate to shared store - it handles the update + await tasksStore.toggleTask(task) + // Reload scores after task completion change + await loadScores() + } + + func createGoal(title: String, goalType: GoalType, targetValue: Double, unit: String?) async { + do { + let goal = try await APIClient.shared.createGoal( + title: title, + goalType: goalType, + targetValue: targetValue, + unit: unit, + source: "user" + ) + _ = try? await GoalStorage.shared.syncServerGoal(goal) + goals = try await GoalStorage.shared.getLocalGoals() + } catch { + logError("Failed to create goal", error: error) + } + } + + func updateGoalProgress(_ goal: Goal, currentValue: Double) async { + log("Goals: Updating '\(goal.title)' progress to \(currentValue)") + + // Optimistically update local SQLite + if let index = goals.firstIndex(where: { $0.id == goal.id }) { + goals[index].currentValue = currentValue + } + try? await GoalStorage.shared.updateProgress(backendId: goal.id, currentValue: currentValue) + + do { + let updated = try await APIClient.shared.updateGoalProgress( + goalId: goal.id, + currentValue: currentValue + ) + + // Sync API response to SQLite + _ = try? await GoalStorage.shared.syncServerGoal(updated) + + // Check if the backend auto-completed this goal + if updated.completedAt != nil { + log("Goals: '\(goal.title)' COMPLETED! Triggering celebration.") + goals = try await GoalStorage.shared.getLocalGoals() + NotificationCenter.default.post(name: .goalCompleted, object: updated) + return + } + + goals = try await GoalStorage.shared.getLocalGoals() + log("Goals: Updated '\(goal.title)' progress confirmed by API") + } catch { + logError("Failed to update goal progress", error: error) + } + } + + func updateGoal(_ goal: Goal, title: String, currentValue: Double, targetValue: Double) async { + log("Goals: Updating goal '\(goal.title)' -> title='\(title)', current=\(currentValue), target=\(targetValue)") + + do { + let updated = try await APIClient.shared.updateGoal( + goalId: goal.id, + title: title, + currentValue: currentValue, + targetValue: targetValue + ) + + _ = try? await GoalStorage.shared.syncServerGoal(updated) + goals = try await GoalStorage.shared.getLocalGoals() + log("Goals: Updated goal '\(updated.title)' confirmed by API") + } catch { + logError("Failed to update goal", error: error) + goals = (try? await GoalStorage.shared.getLocalGoals()) ?? goals + } + } + + func deleteGoal(_ goal: Goal) async { + do { + // Soft-delete locally first for instant UI update + try? await GoalStorage.shared.softDelete(backendId: goal.id) + goals = try await GoalStorage.shared.getLocalGoals() + // Then delete on backend + try await APIClient.shared.deleteGoal(id: goal.id) + } catch { + logError("Failed to delete goal", error: error) + } + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeAskFocusPolicy.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeAskFocusPolicy.swift deleted file mode 100644 index c47f8e5b95c..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeAskFocusPolicy.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -/// Monotonic generation policy for the deferred Home ask-field focus. -/// -/// `DashboardPage.openHomeChat(focusInput:)` schedules the ask-field focus -/// after a run-loop yield so it lands once the stage transition has rendered. -/// Without invalidation that deferred focus is stale the instant the user — or -/// the automation bridge — connects / collapses / closes before the yield -/// resumes, and a stale focus reopens chat through the focus observer -/// (home-stage S6 regression: expected hub, returned chat before the query -/// completed). -/// -/// Each invalidation bumps the generation; a deferred focus applies only if its -/// token still matches the current generation *and* the stage is still chat. -/// This type is the production seam — pure and deterministic, unit-tested -/// without touching the run loop. -final class HomeAskFocusPolicy { - /// Monotonic invalidation counter. Bumped by every connect / collapse / close. - private(set) var generation: Int = 0 - - /// Captured before scheduling a deferred focus; compared on resume. - struct Token: Equatable { - let generation: Int - } - - /// Snapshot the current generation to pair with a deferred focus. - func currentToken() -> Token { Token(generation: generation) } - - /// Invalidate every outstanding deferred focus. Returns the new generation. - @discardableResult - func invalidate() -> Int { - generation += 1 - return generation - } - - /// True only if `token` was captured against the still-current generation. - func isCurrent(_ token: Token) -> Bool { token.generation == generation } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsComposer.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsComposer.swift deleted file mode 100644 index eea2664ff39..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsComposer.swift +++ /dev/null @@ -1,116 +0,0 @@ -import Foundation - -// MARK: - "Here's what it already knows to do" rows - -/// One row in the Home hub's knows-list: a concrete task, a proactive insight, -/// or a suggested question to ask. -enum HomeKnowsRowKind: Equatable { - case task(id: String) - case insight(id: String) - case question -} - -struct HomeKnowsRow: Identifiable, Equatable { - let kind: HomeKnowsRowKind - let text: String - - var id: String { - switch kind { - case .task(let id): return "task-\(id)" - case .insight(let id): return "insight-\(id)" - case .question: return "question-\(text)" - } - } -} - -struct HomeKnowsTaskCandidate: Equatable { - let id: String - let text: String -} - -struct HomeKnowsInsightCandidate: Equatable { - let id: String - let text: String -} - -/// Builds the hub rows under the greeting as a deliberately DIVERSE set — one -/// pressing task, a tip (a real insight if there is one, otherwise a composed, -/// high-agency nudge you can hand Omi), a second task, and a prefilled ask. -/// Fixed typed slots keep it from collapsing into an all-tasks list when one -/// source (usually insights) is thin. -enum HomeKnowsListComposer { - static let maxRows = 4 - - /// How many candidates must exist beyond what's shown before the hub starts - /// rotating — otherwise the same rows would "rotate" back onto themselves. - static func canRotate(taskCount: Int, insightCount: Int, questionCount: Int) -> Bool { - taskCount > 2 || insightCount > 1 || questionCount > 1 - } - - static func compose( - tasks: [HomeKnowsTaskCandidate], - insights: [HomeKnowsInsightCandidate], - tip: String? = nil, - questions: [String], - dismissedTaskIDs: Set = [], - rotation: Int = 0 - ) -> [HomeKnowsRow] { - let freshTasksRaw = tasks.filter { candidate in - !dismissedTaskIDs.contains(candidate.id) - && !candidate.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - let cleanInsightsRaw = insights.filter { - !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - let trimmedTip = tip?.trimmingCharacters(in: .whitespacesAndNewlines) - let cleanTip = (trimmedTip?.isEmpty == false) ? trimmedTip : nil - - // Question rows are identified by their text, so a repeated suggestion - // would collide as a ForEach ID — keep only the first occurrence. - var seenQuestions = Set() - let cleanQuestionsRaw = - questions - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty && seenQuestions.insert($0).inserted } - - // Rotate each source so the hub cycles through fresh candidates over time - // while the diverse task · tip · task · ask structure below stays fixed. - func rotated(_ arr: [T]) -> [T] { - guard arr.count > 1 else { return arr } - let k = ((rotation % arr.count) + arr.count) % arr.count - return Array(arr[k...] + arr[.. 1, ask == nil || rows.count < maxRows - 1 { - let task = freshTasks[1] - rows.append(HomeKnowsRow(kind: .task(id: task.id), text: task.text)) - } - - // 4) A prefilled ask, so there's always a distinct thing to hand Omi. - if let ask, rows.count < maxRows { - rows.append(HomeKnowsRow(kind: .question, text: ask)) - } - - return Array(rows.prefix(maxRows)) - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/WhatMattersNowSection.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/WhatMattersNowSection.swift deleted file mode 100644 index e33bf9f48d3..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/WhatMattersNowSection.swift +++ /dev/null @@ -1,369 +0,0 @@ -import OmiTheme -import SwiftUI - -// Recommendation ("what matters now") surfacing moved into the Home hub's -// knows-list rows in DashboardPage; this file keeps the goals surfaces. - -struct FocusedGoalsSection: View { - @ObservedObject var store: DashboardIntelligenceStore - let onOpenGoal: (String) async -> Void - let onShowAll: () -> Void - - var body: some View { - if !store.focusedGoals.isEmpty { - HStack(spacing: 8) { - Text("Focused goals") - .scaledFont(size: 11, weight: .semibold) - .foregroundColor(Ink.secondary) - ForEach(store.focusedGoals.prefix(5), id: \.goalId) { goal in - Button { - Task { await onOpenGoal(goal.goalId) } - } label: { - Text(goal.title) - .scaledFont(size: 10, weight: .medium) - .lineLimit(1) - .padding(.horizontal, 9) - .padding(.vertical, 6) - .background(Capsule().fill(Ink.rowFill.opacity(0.8))) - } - .buttonStyle(.plain) - .accessibilityIdentifier("focused-goal-\(goal.goalId)") - } - Spacer() - Button("All goals", action: onShowAll) - .buttonStyle(.plain) - .scaledFont(size: 10, weight: .medium) - .foregroundColor(Ink.secondary) - } - .accessibilityIdentifier("focused-goals") - } else if store.accountGeneration != nil { - HStack { - Text("No focused goals") - .scaledFont(size: 10) - .foregroundColor(Ink.secondary) - Spacer() - Button(store.goals.isEmpty ? "Add goal" : "Choose focus", action: onShowAll) - .buttonStyle(.plain) - .scaledFont(size: 10, weight: .medium) - } - } - } -} - -struct AllGoalsSheet: View { - @ObservedObject var store: DashboardIntelligenceStore - let onOpenGoal: (String) async -> Void - let onDismiss: () -> Void - - @State private var showHistory = false - @State private var focusTarget: GoalFocusTarget? - @State private var replacementGoalID: String = "" - @State private var showingCreateGoal = false - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - Text("All goals") - .scaledFont(size: 20, weight: .semibold) - Spacer() - Picker("View", selection: $showHistory) { - Text("Current").tag(false) - Text("History").tag(true) - } - .pickerStyle(.segmented) - .frame(width: 180) - Button("Add goal") { showingCreateGoal = true } - .buttonStyle(.bordered) - Button("Done", action: onDismiss) - .buttonStyle(.borderedProminent) - .tint(Ink.primary) - .foregroundColor(Ink.surface) - } - - ScrollView { - LazyVStack(spacing: 8) { - ForEach(displayedGoals, id: \.goalId) { goal in - goalRow(goal) - } - } - } - - if let error = store.error { - Text(error) - .scaledFont(size: 10) - .foregroundColor(Ink.secondary) - } - } - .padding(20) - .frame(width: 620, height: 540) - .sheet(item: $focusTarget) { target in - focusReplacementSheet(target.goal) - } - .sheet(isPresented: $showingCreateGoal) { - CanonicalGoalCreateSheet( - error: store.error, - onSave: { title, outcome, why, criteria, idempotencyKey in - if await store.createGoal( - title: title, - desiredOutcome: outcome, - whyItMatters: why, - successCriteria: criteria, - idempotencyKey: idempotencyKey - ) { - showingCreateGoal = false - } - }, - onDismiss: { showingCreateGoal = false } - ) - } - } - - private var displayedGoals: [OmiAPI.GoalResponse] { - showHistory ? store.endedGoals : store.currentGoals - } - - private func goalRow(_ goal: OmiAPI.GoalResponse) -> some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 3) { - Text(goal.title) - .scaledFont(size: 13, weight: .semibold) - .foregroundColor(Ink.primary) - Text(goal.desiredOutcome) - .scaledFont(size: 10) - .foregroundColor(Ink.secondary) - .lineLimit(1) - } - Spacer() - Text(goal.status.rawValue.capitalized) - .scaledFont(size: 9) - .foregroundColor(Ink.secondary) - - Button("Open") { Task { await onOpenGoal(goal.goalId) } } - .buttonStyle(.bordered) - - if !showHistory { - Button(goal.status == .focused ? "Unfocus" : "Focus") { - Task { - if goal.status == .focused { - await store.unfocus(goalID: goal.goalId) - } else { - let focused = await store.focus(goalID: goal.goalId, replacing: nil) - if !focused, store.focusReplacementGoalID == goal.goalId { - replacementGoalID = store.focusedGoals.first?.goalId ?? "" - focusTarget = GoalFocusTarget(goal: goal) - } - } - } - } - .buttonStyle(.bordered) - - Menu("More") { - Button("Pause") { Task { await store.transition(goalID: goal.goalId, status: .paused) } } - Button("Mark achieved") { Task { await store.transition(goalID: goal.goalId, status: .achieved) } } - Button("Abandon") { Task { await store.transition(goalID: goal.goalId, status: .abandoned) } } - } - .menuStyle(.borderlessButton) - .frame(width: 55) - } - } - .padding(10) - .background(RoundedRectangle(cornerRadius: 9).fill(Ink.rowFill.opacity(0.7))) - } - - private struct GoalFocusTarget: Identifiable { - let goal: OmiAPI.GoalResponse - var id: String { goal.goalId } - } - - private func focusReplacementSheet(_ goal: OmiAPI.GoalResponse) -> some View { - VStack(alignment: .leading, spacing: 14) { - Text("Replace a focused goal") - .scaledFont(size: 16, weight: .semibold) - Text("Your focus set is full. Nothing is archived; the replaced goal moves to All goals.") - .scaledFont(size: 11) - .foregroundColor(Ink.secondary) - Picker("Replace", selection: $replacementGoalID) { - ForEach(store.focusedGoals, id: \.goalId) { focused in - Text(focused.title).tag(focused.goalId) - } - } - HStack { - Button("Cancel") { focusTarget = nil } - Spacer() - Button("Replace focus") { - Task { - if await store.focus(goalID: goal.goalId, replacing: replacementGoalID) { - focusTarget = nil - } - } - } - .buttonStyle(.borderedProminent) - .tint(Ink.primary) - .foregroundColor(Ink.surface) - } - } - .padding(20) - .frame(width: 420) - } -} - -private struct CanonicalGoalCreateSheet: View { - let error: String? - let onSave: (String, String, String?, [String], String) async -> Void - let onDismiss: () -> Void - - @State private var title = "" - @State private var desiredOutcome = "" - @State private var whyItMatters = "" - @State private var successCriteria = "" - @State private var createGoalOccurrenceID = UUID().uuidString.lowercased() - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Text("Add goal") - .scaledFont(size: 18, weight: .semibold) - TextField("Short name", text: $title) - .textFieldStyle(.roundedBorder) - TextField("Desired outcome", text: $desiredOutcome, axis: .vertical) - .textFieldStyle(.roundedBorder) - .lineLimit(2...4) - TextField("Why it matters (optional)", text: $whyItMatters, axis: .vertical) - .textFieldStyle(.roundedBorder) - .lineLimit(2...4) - TextField("Success criteria, one per line", text: $successCriteria, axis: .vertical) - .textFieldStyle(.roundedBorder) - .lineLimit(2...5) - if let error, !error.isEmpty { - Text(error) - .scaledFont(size: 10) - .foregroundColor(Ink.secondary) - } - HStack { - Button("Cancel", action: onDismiss) - Spacer() - Button("Add goal") { - let criteria = successCriteria.split(separator: "\n").map { - String($0).trimmingCharacters(in: .whitespacesAndNewlines) - }.filter { !$0.isEmpty } - Task { - await onSave( - title.trimmingCharacters(in: .whitespacesAndNewlines), - desiredOutcome.trimmingCharacters(in: .whitespacesAndNewlines), - whyItMatters.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, - criteria, - createGoalOccurrenceID - ) - } - } - .buttonStyle(.borderedProminent) - .tint(Ink.primary) - .foregroundColor(Ink.surface) - .disabled( - title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || desiredOutcome.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - .padding(20) - .frame(width: 460) - } -} - -extension String { - fileprivate var nilIfEmpty: String? { isEmpty ? nil : self } -} - -struct CanonicalGoalDetailSheet: View { - let detail: OmiAPI.GoalDetailProjection - let error: String? - let onResumeThread: (String) async -> Void - let onStartWork: () async -> Void - let onDismiss: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - VStack(alignment: .leading, spacing: 3) { - Text(detail.goal.title) - .scaledFont(size: 20, weight: .semibold) - Text(detail.goal.desiredOutcome) - .scaledFont(size: 12) - .foregroundColor(Ink.secondary) - } - Spacer() - Button("Done", action: onDismiss) - } - - ScrollView { - VStack(alignment: .leading, spacing: 14) { - if let why = detail.goal.whyItMatters, !why.isEmpty { - goalDetailBlock(title: "Why it matters", text: why) - } - if let criteria = detail.goal.successCriteria, !criteria.isEmpty { - goalDetailBlock(title: "Success looks like", text: criteria.joined(separator: " • ")) - } - if let metric = detail.goal.metric { - goalDetailBlock( - title: "Progress", - text: "\(metric.current.formatted()) / \(metric.target.formatted()) \(metric.unit ?? "")" - ) - } - - if !detail.activeThreads.isEmpty { - Text("Active work") - .scaledFont(size: 12, weight: .semibold) - ForEach(detail.activeThreads, id: \.workstreamId) { work in - HStack { - VStack(alignment: .leading, spacing: 3) { - Text(work.title) - .scaledFont(size: 12, weight: .semibold) - Text(work.currentStateSummary ?? work.objective) - .scaledFont(size: 10) - .foregroundColor(Ink.secondary) - .lineLimit(2) - } - Spacer() - Button("Continue") { Task { await onResumeThread(work.workstreamId) } } - .buttonStyle(.bordered) - } - .padding(10) - .background(RoundedRectangle(cornerRadius: 9).fill(Ink.rowFill.opacity(0.7))) - } - } - - if !detail.progressEvents.isEmpty { - Text("Meaningful progress") - .scaledFont(size: 12, weight: .semibold) - ForEach(detail.progressEvents, id: \.eventId) { event in - HStack(alignment: .top, spacing: 8) { - Circle().fill(Ink.secondary).frame(width: 5, height: 5).padding(.top, 5) - Text(event.summary) - .scaledFont(size: 10) - .foregroundColor(Ink.secondary) - } - } - } - } - } - - Button("Work on this with Omi") { Task { await onStartWork() } } - .buttonStyle(.borderedProminent) - .tint(Ink.primary) - .foregroundColor(Ink.surface) - .accessibilityIdentifier("goal-work-with-omi-\(detail.goal.goalId)") - if let error, !error.isEmpty { - Text(error) - .scaledFont(size: 10) - .foregroundColor(Ink.secondary) - } - } - .padding(20) - .frame(width: 620, height: 600) - } - - private func goalDetailBlock(title: String, text: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title).scaledFont(size: 11, weight: .semibold) - Text(text).scaledFont(size: 10).foregroundColor(Ink.secondary) - } - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 4202095d81a..fb9d7f67be4 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -19,13 +19,6 @@ enum PersistedCaptureLaunchPolicy { } } -enum DesktopHomeEscapeNavigation { - static func shouldNavigateHome(selectedIndex: Int, usesLegacyHomeDesign: Bool) -> Bool { - guard !usesLegacyHomeDesign, let item = SidebarNavItem(rawValue: selectedIndex) else { return false } - return [.conversations, .memories, .tasks, .rewind].contains(item) - } -} - // MARK: - NSHostingView sizingOptions access /// Protocol to access sizingOptions on any NSHostingView regardless of the generic parameter. @@ -51,16 +44,10 @@ struct DesktopHomeView: View { @ObservedObject private var accountCutoverControl = AccountCutoverControlManager.shared @ObservedObject private var automationPresentationCoordinator = DesktopAutomationPresentationCoordinator.shared - @State private var selectedIndex: Int = { - if OMIApp.launchMode == .rewind { return SidebarNavItem.rewind.rawValue } - return SidebarNavItem.dashboard.rawValue - }() - @State private var isSidebarCollapsed: Bool = true @AppStorage("currentTierLevel") private var currentTierLevel = 0 @AppStorage("onboardingStep") private var onboardingStep = 0 @AppStorage("onboardingFurthestStep") private var onboardingFurthestStep = 0 @AppStorage("onboardingJustCompleted") private var onboardingJustCompleted = false - @AppStorage("useLegacyHomeDesign") private var useLegacyHomeDesign = false @AppStorage(MemoryHubDestination.storageKey) private var memoryDestinationRawValue = MemoryHubDestination.memories.rawValue /// Reference instant for the top bar's "new since you were last here" counts — @@ -71,7 +58,6 @@ struct DesktopHomeView: View { @State private var selectedSettingsSection: SettingsContentView.SettingsSection = .general @State private var highlightedSettingId: String? = nil @State private var showTryAskingPopup = false - @State private var previousIndexBeforeSettings: Int = 0 @State private var logoPulse = false @State private var lastActivationRefresh = Date.distantPast @State private var didScheduleAgentVMProvisioning = false @@ -85,7 +71,9 @@ struct DesktopHomeView: View { @State private var initialFileIndexingBackfill = DelayedFileIndexingBackfillState() @State private var automationPresentationReadinessGate = DesktopAutomationPresentationReadinessGate() - @State private var chatFirstCapabilitySample = ChatFirstShellCapabilitySample() + /// Server-authoritative capability for the one shell. It never decides which + /// shell mounts — only whether the capability-gated kernel features engage. + @State private var chatFirstCapability = ChatFirstCapabilitySample() // Pre-loaded hero logo to avoid NSImage init crashes during SwiftUI body evaluation private static let heroLogoImage: NSImage? = { @@ -95,14 +83,6 @@ struct DesktopHomeView: View { return NSImage(data: data) }() - /// Whether we're currently viewing the settings page - private var isInSettings: Bool { - selectedIndex == SidebarNavItem.settings.rawValue - || selectedIndex == SidebarNavItem.permissions.rawValue - } - - private var homeOwnsItsPanels: Bool { !useLegacyHomeDesign } - private var shouldShowAuthEntryShell: Bool { authState.isRestoringAuth || authState.sessionPhase == .recoveryRequired || !authState.isSignedIn || !hasCompletedOnboardingAtAuthorityRead @@ -214,8 +194,9 @@ struct DesktopHomeView: View { .onAppear { log("DesktopHomeView: Showing mainContent (signed in and onboarded)") - // Only the legacy shell arms the first-use popup; chat-first renders starters in main chat. - if !usesChatFirstShell && PostOnboardingPromptSuggestions.shouldArmPopup() { + // The first-use popup is armed by the same guidance policy for every + // account now that there is one shell. + if PostOnboardingPromptSuggestions.shouldArmPopup() { showTryAskingPopup = true } updatePolicyManager.refresh(force: true) @@ -279,7 +260,7 @@ struct DesktopHomeView: View { log( "DesktopHomeView: userDidSignOut — resetting hasCompletedOnboarding and stopping transcription" ) - chatFirstCapabilitySample.ownerDidChange(to: nil) + chatFirstCapability.ownerDidChange(to: nil) resetSessionScopedStartupWarmups() appState.conversationRepository.reset() appState.folders = [] @@ -327,12 +308,6 @@ struct DesktopHomeView: View { Group { if shouldShowAuthEntryShell { authEntryShell - } else if case .unresolved = chatFirstCapabilitySample.variant { - // Hold the legacy shell until the server-authoritative cohort settles. - ChatFirstCapabilityLoadingView() - .task(id: RuntimeOwnerIdentity.currentOwnerId() ?? "missing-owner") { - await resolveChatFirstCapabilityIfNeeded() - } } else { ZStack { // After onboarding completes, navigate to Tasks page @@ -345,6 +320,11 @@ struct DesktopHomeView: View { } } mainContentWithLifecycle + // The shell mounts immediately; the capability resolves alongside it and + // only decides whether capability-gated features engage. + .task(id: RuntimeOwnerIdentity.currentOwnerId() ?? "missing-owner") { + await resolveChatFirstCapabilityIfNeeded() + } if !viewModelContainer.isInitialLoadComplete { TransparentWindowStatusPanel { @@ -413,20 +393,16 @@ struct DesktopHomeView: View { // extrema and resets our pin, after which the window can be dragged small enough // to hide content. Re-pin on every live resize so AppKit keeps clamping the drag. installMinimumSizeGuardIfNeeded() - // Redirect if current page isn't visible at current tier - redirectIfPageHidden() reportAutomationState() handleAutomationPresentationReadinessChange(viewModelContainer.isInitialLoadComplete) } .onChange(of: currentTierLevel) { _, _ in - redirectIfPageHidden() reportAutomationState() } - .onChange(of: selectedIndex) { _, _ in - // Page nav recreates the content hosting view with default sizingOptions, which + .onChange(of: chatFirstNavigation.route) { _, _ in + // Route nav recreates the content hosting view with default sizingOptions, which // resets the window min — re-pin + re-disable to hold the minimum. enforceMainWindowMinimumSize() - reportAutomationState() } .onChange(of: automationPresentationCoordinator.activeCommand?.generation) { _, _ in guard @@ -443,12 +419,9 @@ struct DesktopHomeView: View { .onChange(of: authState.isSignedIn) { _, _ in reportAutomationState() } .onChange(of: authState.isRestoringAuth) { _, _ in reportAutomationState() } .onChange(of: appState.hasCompletedOnboarding) { _, _ in reportAutomationState() } - .onChange(of: chatFirstCapabilitySample.variant) { _, _ in - consumePendingMainChatRequestForChatFirstShell() - } .onReceive(NotificationCenter.default.publisher(for: .runtimeOwnerDidChange)) { _ in reconcileOnboardingCompletionOwner() - chatFirstCapabilitySample.ownerDidChange(to: RuntimeOwnerIdentity.currentOwnerId()) + chatFirstCapability.ownerDidChange(to: RuntimeOwnerIdentity.currentOwnerId()) // The provider's owner-bound gate rejects the previous sample for this // owner; no replacement sample is persisted or inferred locally. reportAutomationState() @@ -471,19 +444,12 @@ struct DesktopHomeView: View { } .onReceive(NotificationCenter.default.publisher(for: .navigateToChat)) { _ in // The global shortcut / notch "Ask Omi" opens the continuous chat, which - // lives on the chat-first home. DashboardPage focuses the input when it's - // already mounted; if we're on another tab, switch home first and re-emit - // so the now-mounted page catches it. Guard on the tab to avoid a loop. - if selectedIndex != SidebarNavItem.dashboard.rawValue { - selectedIndex = SidebarNavItem.dashboard.rawValue - DispatchQueue.main.async { - NotificationCenter.default.post(name: .navigateToChat, object: nil) - } - } + // is the shell's Chat route. Selecting it is idempotent, so no re-emit + // loop guard is needed. + chatFirstNavigation.selectPrimary(.chat) } - // "Continue in Omi" from the floating bar. The legacy Dashboard owns its - // existing pending-request consumption, while the Chat-first shell has no - // Dashboard chat panel to consume it on its behalf. + // "Continue in Omi" from the floating bar. The one shell has no second chat + // panel to consume the pending request on its behalf. .onReceive(NotificationCenter.default.publisher(for: .openMainChatRequested)) { _ in handleMainChatRequest() } @@ -502,15 +468,7 @@ struct DesktopHomeView: View { } private func handleMainChatRequest() { - guard usesChatFirstShell else { - selectedIndex = SidebarNavItem.dashboard.rawValue - return - } - consumePendingMainChatRequestForChatFirstShell() - } - - private func consumePendingMainChatRequestForChatFirstShell() { - guard usesChatFirstShell, MainChatNavigationRequestStore.shared.consume() else { return } + guard MainChatNavigationRequestStore.shared.consume() else { return } chatFirstNavigation.selectPrimary(.chat, origin: .chatDeeplink) } @@ -614,54 +572,6 @@ struct DesktopHomeView: View { } } - /// Redirect to conversations if current page isn't visible at the current tier level - private func redirectIfPageHidden() { - guard !usesChatFirstShell else { return } - // Tier 0 or tier 6+ shows everything — no redirect needed - guard currentTierLevel > 0 && currentTierLevel < 6 else { return } - // Don't redirect from settings/permissions pages - let nonMainPages: Set = [ - SidebarNavItem.settings.rawValue, SidebarNavItem.permissions.rawValue, - ] - guard !nonMainPages.contains(selectedIndex) else { return } - - var visibleRawValues: Set = [ - SidebarNavItem.dashboard.rawValue, SidebarNavItem.rewind.rawValue, - ] - if currentTierLevel >= 2 { visibleRawValues.insert(SidebarNavItem.memories.rawValue) } - if currentTierLevel >= 3 { visibleRawValues.insert(SidebarNavItem.tasks.rawValue) } - // Conversations replaced Chat in the sidebar; tier 1 unlocks it. - if currentTierLevel >= 1 { visibleRawValues.insert(SidebarNavItem.conversations.rawValue) } - - if !visibleRawValues.contains(selectedIndex) { - selectedIndex = SidebarNavItem.dashboard.rawValue - } - } - - /// Whether to hide the sidebar (rewind mode) - private var hideSidebar: Bool { - OMIApp.launchMode == .rewind - } - - private var showsPrimarySidebar: Bool { - !usesChatFirstShell && useLegacyHomeDesign && !hideSidebar - } - - /// The constant floating top bar (nav + new-item counts + Capture/Listening) - /// replaces the old left nav rail. It shows on every main content page — - /// including Settings, whose page has no back button, so the bar's nav pills - /// are the way out. Permissions is a full-screen utility flow with its own - /// chrome and stays bar-less — the Memory atlas is the same: it has its - /// own back affordance and header, so the redundant top bar hides while it's open. - private var showsTopBar: Bool { - !useLegacyHomeDesign && SidebarNavItem(rawValue: selectedIndex) != nil - } - - /// Reference instant for the top bar's "new since you were last here" counts. - private var topBarSinceDate: Date { - topBarNewSinceRaw > 0 ? Date(timeIntervalSince1970: topBarNewSinceRaw) : Date() - } - private func seedTopBarNewSinceIfNeeded() { let currentValue = topBarNewSinceRaw guard currentValue == 0 else { return } @@ -708,36 +618,32 @@ struct DesktopHomeView: View { let currentWindow = NSApp.windows.first(where: { $0.title.lowercased().hasPrefix("omi") && $0.isVisible }) - let priorHomeMode = DesktopAutomationStateStore.shared.current().homeMode - let chatFirstRoute = usesChatFirstShell ? chatFirstNavigation.route : nil + let chatFirstRoute = chatFirstNavigation.route let snapshot = DesktopAutomationSnapshot( bridgeEnabled: true, bridgePort: DesktopAutomationLaunchOptions.port, bundleIdentifier: Bundle.main.bundleIdentifier ?? "unknown", appState: currentAppStateLabel, - selectedTab: chatFirstRoute?.title ?? SidebarNavItem(rawValue: selectedIndex)?.title, - selectedTabIndex: usesChatFirstShell ? nil : selectedIndex, - selectedSettingsSection: usesChatFirstShell - ? (chatFirstRoute == .more(.settings) ? selectedSettingsSection.rawValue : nil) - : (isInSettings ? selectedSettingsSection.rawValue : nil), + selectedTab: chatFirstRoute.title, + selectedTabIndex: nil, + selectedSettingsSection: chatFirstRoute == .more(.settings) + ? selectedSettingsSection.rawValue : nil, highlightedSettingId: highlightedSettingId, - usesLegacyHomeDesign: !usesChatFirstShell && useLegacyHomeDesign, - // Carried from `DashboardPage`, the stage's only writer, or nil when no surface renders one. - // Never defaulted — see `HomeStageAutomationPolicy`. - homeMode: HomeStageAutomationPolicy.reportedHomeMode( - usesChatFirstShell: usesChatFirstShell, - chatFirstRoute: chatFirstRoute, - lastPublishedMode: priorHomeMode), - shellVariant: chatFirstCapabilitySample.variant.stableName, - chatFirstRoute: chatFirstRoute?.stableName, - visibleChatFirstRoute: usesChatFirstShell ? chatFirstNavigation.visibleRoute?.stableName : nil, + // There is one shell and it renders no Home stage: `DashboardPage` was its + // only writer and no longer exists. `nil` says exactly that — never a + // plausible-looking default a flow could wait on forever. + homeMode: nil, + // Pinned: the app has one shell. Retained in the snapshot because e2e + // flows and the navigation-visibility policy read it. + shellVariant: DesktopAutomationSnapshot.singleShellVariant, + chatFirstRoute: chatFirstRoute.stableName, + visibleChatFirstRoute: chatFirstNavigation.visibleRoute?.stableName, pendingFocusKind: chatFirstNavigation.pendingFocus?.stableName, acknowledgedFocusKind: chatFirstNavigation.lastAcknowledgedFocusKind, focusedEntityID: chatFirstNavigation.focusedEntityID, isFocusedEntityAcknowledged: chatFirstNavigation.isFocusedEntityAcknowledged, - showsPrimarySidebar: showsPrimarySidebar, - isSidebarCollapsed: usesChatFirstShell - ? chatFirstNavigation.isSidebarCollapsed : isSidebarCollapsed, + showsPrimarySidebar: false, + isSidebarCollapsed: chatFirstNavigation.isSidebarCollapsed, hasCompletedOnboarding: appState.hasCompletedOnboarding, isSignedIn: authState.isSignedIn, isRestoringAuth: authState.isRestoringAuth, @@ -789,15 +695,21 @@ struct DesktopHomeView: View { return } - if usesChatFirstShell, let route = ChatFirstRoute.automationVisibilityDestination(named: target) { + // `navigate help` used to name a "Help from Founder" page that no shell has + // mounted for a long time, so the bridge resolved a title and then timed out + // waiting for it. Settings → About is where getting help from a person + // actually lives (the Community / Join Discord card), so the name now lands + // on a destination that exists. + if ChatFirstRoute.isHelpAutomationTarget(target) { + selectedSettingsSection = .about + } + if let route = ChatFirstRoute.automationVisibilityDestination(named: target) { switch route { case .more(let page): chatFirstNavigation.selectMore(page) default: chatFirstNavigation.selectPrimary(route) } - } else if let item = SidebarNavItem.automationDestination(named: target) { - navigateToLegacyDestination(item) } reportAutomationState() @@ -1028,36 +940,23 @@ struct DesktopHomeView: View { restorePersistedCaptureServices(reason: "settings sync") } - private func updateStoreActivity(for index: Int) { - viewModelContainer.tasksStore.isActive = - index == SidebarNavItem.dashboard.rawValue || index == SidebarNavItem.tasks.rawValue - viewModelContainer.memoriesViewModel.isActive = - index == SidebarNavItem.conversations.rawValue || index == SidebarNavItem.memories.rawValue - } - - private var usesChatFirstShell: Bool { - DesktopShellPresentationPolicy.usesChatFirst(useLegacyHomeDesign, chatFirstCapabilitySample.variant) - } - private func updateStoreActivityForCurrentShell() { - guard usesChatFirstShell else { - updateStoreActivity(for: selectedIndex) - return - } viewModelContainer.tasksStore.isActive = chatFirstNavigation.route == .tasks || chatFirstNavigation.route == .more(.dashboard) viewModelContainer.memoriesViewModel.isActive = chatFirstNavigation.route == .memories } - /// One fresh server read decides both the shell and the local runtime - /// projection. A failed response, missing owner, stale auth snapshot, or - /// owner change resolves legacy; there is no cached local enablement. + /// One fresh server read decides the local runtime projection. It does not + /// decide which shell mounts — there is only one — so the shell is already on + /// screen while this runs. A failed response, missing owner, stale auth + /// snapshot, or owner change resolves capability-off: rich blocks still + /// render, kernel features stay dormant. private func resolveChatFirstCapabilityIfNeeded() async { - guard case .unresolved = chatFirstCapabilitySample.variant else { return } + guard !chatFirstCapability.isResolved else { return } guard let ownerID = RuntimeOwnerIdentity.currentOwnerId(), let authorization = RuntimeOwnerIdentity.captureAuthorizationSnapshot(expectedOwnerID: ownerID) else { - chatFirstCapabilitySample.resolve( + chatFirstCapability.resolve( control: nil, requestedOwnerID: nil, ownerIsStillCurrent: false @@ -1083,7 +982,7 @@ struct DesktopHomeView: View { let current = RuntimeOwnerIdentity.isAuthorizationCurrent(authorization) && RuntimeOwnerIdentity.currentOwnerId() == ownerID - chatFirstCapabilitySample.resolve( + chatFirstCapability.resolve( control: control, requestedOwnerID: ownerID, ownerIsStillCurrent: current @@ -1092,26 +991,27 @@ struct DesktopHomeView: View { let current = RuntimeOwnerIdentity.isAuthorizationCurrent(authorization) && RuntimeOwnerIdentity.currentOwnerId() == ownerID - chatFirstCapabilitySample.resolve( + chatFirstCapability.resolve( control: nil, requestedOwnerID: ownerID, ownerIsStillCurrent: current ) capabilityErrorClass = .unavailable - log("DesktopHomeView: chat-first control unavailable; using legacy shell") + log("DesktopHomeView: chat-first control unavailable; capability stays off") } let projectionConfigured = viewModelContainer.chatProvider.configureChatFirstMainChatCapability( - chatFirstCapabilitySample.variant.projection + chatFirstCapability.projection ) if !projectionConfigured { // A pre-existing Main Chat session cannot be retroactively upgraded with - // dynamic tools. Keep this launch on the byte-equivalent legacy path. - chatFirstCapabilitySample.failClosed() + // dynamic tools. Fail closed to capability-off for this launch; the shell + // and its content blocks are unaffected. + chatFirstCapability.failClosed() capabilityErrorClass = .projectionRejected - log("DesktopHomeView: chat-first projection handoff rejected; using legacy shell") + log("DesktopHomeView: chat-first projection handoff rejected; capability stays off") } - let projection = chatFirstCapabilitySample.variant.projection + let projection = chatFirstCapability.projection let capabilityOutcome: ChatFirstAnalyticsEvent.CapabilityOutcome if capabilityErrorClass == .projectionRejected { capabilityOutcome = .projectionRejected @@ -1133,13 +1033,8 @@ struct DesktopHomeView: View { } private func navigateAfterOnboarding() { - if usesChatFirstShell { - chatFirstNavigation.selectPrimary(.chat) - log("DesktopHomeView: Onboarding just completed — opening Chat") - } else { - selectedIndex = SidebarNavItem.dashboard.rawValue - log("DesktopHomeView: Onboarding just completed — navigating to Dashboard") - } + chatFirstNavigation.selectPrimary(.chat) + log("DesktopHomeView: Onboarding just completed — opening Chat") } /// Existing menu, keyboard, and automation callers retain their legacy @@ -1148,21 +1043,13 @@ struct DesktopHomeView: View { private func navigateToLegacyDestination(_ item: SidebarNavItem) { if item == .permissions { selectedSettingsSection = .permissions - if usesChatFirstShell { - chatFirstNavigation.selectMore(.settings) - } else { - selectedIndex = SidebarNavItem.settings.rawValue - } + chatFirstNavigation.selectMore(.settings) return } if let destination = MemoryHubDestination.destination(for: item) { memoryDestinationRawValue = destination.rawValue } - if usesChatFirstShell { - chatFirstNavigation.selectLegacyDestination(item) - } else { - selectedIndex = item.rawValue - } + chatFirstNavigation.selectLegacyDestination(item) } private var mainContent: some View { @@ -1198,7 +1085,7 @@ struct DesktopHomeView: View { await RatingPromptManager.shared.seedFromHistoryIfNeeded() } .overlay { - if !usesChatFirstShell && showTryAskingPopup { + if showTryAskingPopup { TryAskingPopupView( onTry: { useCase in showTryAskingPopup = false @@ -1217,7 +1104,9 @@ struct DesktopHomeView: View { private func mainContentWithNotifications(_ content: Content) -> some View { content .onReceive(NotificationCenter.default.publisher(for: .showTryAskingPopup)) { _ in - guard !usesChatFirstShell else { return } + // The first-use popup belongs to whoever just finished onboarding, and + // there is now one shell for all of them. `shouldArmPopup` upstream is + // still the only thing that decides whether it is due. showTryAskingPopup = true } .onReceive(NotificationCenter.default.publisher(for: .navigateToRewindSettings)) { _ in @@ -1252,12 +1141,7 @@ struct DesktopHomeView: View { } } .onReceive(NotificationCenter.default.publisher(for: .navigateToChat)) { _ in - if usesChatFirstShell { - chatFirstNavigation.selectPrimary(.chat) - } else { - // Legacy Home owns the historic Chat notification contract. - selectedIndex = SidebarNavItem.dashboard.rawValue - } + chatFirstNavigation.selectPrimary(.chat) } .onReceive(NotificationCenter.default.publisher(for: .navigateToTasks)) { _ in navigateToLegacyDestination(.tasks) @@ -1286,7 +1170,7 @@ struct DesktopHomeView: View { } .onReceive(NotificationCenter.default.publisher(for: .desktopAutomationOpenMemoryAtlasRequested)) { _ in memoryDestinationRawValue = MemoryHubDestination.brainMap.rawValue - selectedIndex = SidebarNavItem.conversations.rawValue + chatFirstNavigation.selectPrimary(.memories) } .onReceive(NotificationCenter.default.publisher(for: .desktopAutomationOpenConversationRequested)) { _ in memoryDestinationRawValue = MemoryHubDestination.conversations.rawValue @@ -1296,321 +1180,27 @@ struct DesktopHomeView: View { private func mainContentWithLifecycle(_ content: Content) -> some View { content - .onChange(of: selectedIndex) { oldValue, newValue in - if newValue == SidebarNavItem.settings.rawValue - && oldValue != SidebarNavItem.settings.rawValue - { - previousIndexBeforeSettings = oldValue - } - updateStoreActivity(for: newValue) - } .onChange(of: chatFirstNavigation.route) { _, _ in updateStoreActivityForCurrentShell() reportAutomationState() } .onChange(of: chatFirstNavigation.visibleRoute) { _, _ in reportAutomationState() } .onChange(of: chatFirstNavigation.isSidebarCollapsed) { _, _ in reportAutomationState() } - .onChange(of: useLegacyHomeDesign) { _, newValue in - if usesChatFirstShell { showTryAskingPopup = false } - OmiMotion.withGated(.easeInOut(duration: 0.2)) { - isSidebarCollapsed = !newValue - } - } .onAppear { - if case .legacy = chatFirstCapabilitySample.variant { - isSidebarCollapsed = !useLegacyHomeDesign - } updateStoreActivityForCurrentShell() restorePreChatWindowWidth() } } - /// Keep the legacy HStack out of the chat-first branch's SwiftUI generic - /// expression. The runtime choice is already immutable for this app session; - /// this is only an erased rendering boundary, not a second state owner. - @ViewBuilder private var shellContent: some View { - if case (true, .chatFirst(let capability)) = (usesChatFirstShell, chatFirstCapabilitySample.variant) { - ChatFirstShell( - navigation: chatFirstNavigation, - appState: appState, - viewModelContainer: viewModelContainer, - capability: capability, - selectedSettingsSection: $selectedSettingsSection, - highlightedSettingID: $highlightedSettingId - ) - } else { - legacyMainContent - } - } - - private var legacyMainContent: some View { - HStack(spacing: 0) { - sidebarSlot - mainContentContainer - } - } - - // Sidebar slot: settings sidebar overlays main sidebar - // IMPORTANT: SidebarView is kept alive (but hidden) when in settings to prevent - // EXC_BAD_ACCESS crash in SwiftUI's tooltip system. When the view is conditionally - // removed, its .help() tooltip graph nodes get invalidated, but the macOS tooltip - // tracking system still tries to evaluate them during window key state changes. - // - // Extracted from `mainContent` (rather than inlined in its HStack) so the - // compiler type-checks each slot independently instead of one very large - // combined expression. - @ViewBuilder - private var sidebarSlot: some View { - if showsPrimarySidebar { - LegacySidebarSurface { - ZStack { - SidebarView( - selectedIndex: $selectedIndex, - isCollapsed: $isSidebarCollapsed, - memoryDestinationRawValue: $memoryDestinationRawValue, - appState: appState - ) - .opacity(isInSettings ? 0 : 1) - .allowsHitTesting(!isInSettings) - if isInSettings { settingsSidebar } - } - } - } - } - - /// The settings section list. Modern settings hosts it inside the page panel; legacy Home hosts the - /// whole sidebar slot on `LegacySidebarSurface`, so this view always inherits a glass ground. - private var settingsSidebar: some View { - SettingsSidebar( - selectedSection: $selectedSettingsSection, - highlightedSettingId: $highlightedSettingId, - onBack: { - OmiMotion.withGated(Self.pageNavigationAnimation) { - selectedIndex = - previousIndexBeforeSettings == SidebarNavItem.settings.rawValue - ? SidebarNavItem.dashboard.rawValue - : previousIndexBeforeSettings - } - }, appState: appState) - } - - // Main content area. It paints **no background**: the window has no ground at all - // (`ShellWindowChrome`), so each destination floats on its own panel and one painted - // here would slip an opaque sheet between the desktop and every `.behindWindow` blur. - private var mainContentContainer: some View { - // Page content - switch recreates views on tab change - // Extracted into a separate struct so that pages like TasksPage - // are not re-rendered when AppState publishes unrelated changes. - VStack(spacing: 0) { - // Constant floating top bar — primary nav, new-item counts, and the - // Capture/Listening controls. Replaces the old left nav rail. Hidden - // for the Memory atlas (see showsTopBar), which has its own chrome. - if showsTopBar { - DesktopTopBar( - selectedIndex: $selectedIndex, - memoryDestinationRawValue: $memoryDestinationRawValue, - appState: appState, - memoriesViewModel: viewModelContainer.memoriesViewModel, - tasksStore: viewModelContainer.tasksStore, - sinceDate: topBarSinceDate - ) - .zIndex(1) - } - - // One panel per destination — see `PageGlassLane`. Settings' own section list rides inside it - // so the page is one object rather than a panel with its nav stranded on the wallpaper. - PageGlassLane( - selectedIndex: selectedIndex, - homeOwnsItsPanels: homeOwnsItsPanels - ) { - HStack(spacing: 0) { - if isInSettings && !showsPrimarySidebar { settingsSidebar } - PageContentView( - selectedIndex: selectedIndex, - appState: appState, - viewModelContainer: viewModelContainer, - memoryDestinationRawValue: $memoryDestinationRawValue, - selectedSettingsSection: $selectedSettingsSection, - highlightedSettingId: $highlightedSettingId, - selectedTabIndex: $selectedIndex - ) - } - } - } - .onEscapeKey(priority: .navigation) { navigateHomeOnEscapeIfNeeded() } - // The top bar occupies the hidden title-bar band; the window's top edge is the glass. - .padding(.top, GlassShell.titlebarClearance) - } - - private func navigateHomeOnEscapeIfNeeded() -> Bool { - if usesChatFirstShell { - guard chatFirstNavigation.route != .chat else { return false } - OmiMotion.withGated(Self.pageNavigationAnimation) { - chatFirstNavigation.selectPrimary(.chat) - } - return true - } - guard - DesktopHomeEscapeNavigation.shouldNavigateHome( - selectedIndex: selectedIndex, - usesLegacyHomeDesign: useLegacyHomeDesign - ) - else { return false } - OmiMotion.withGated(Self.pageNavigationAnimation) { - selectedIndex = SidebarNavItem.dashboard.rawValue - } - return true - } -} - -private struct ChatFirstCapabilityLoadingView: View { - var body: some View { - TransparentWindowStatusPanel { - VStack(spacing: OmiSpacing.md) { - ProgressView() - .controlSize(.small) - .tint(Ink.secondary) - Text("Preparing Omi…") - .inkStyle(.prose, color: Ink.secondary) - } - } - // The main window is transparent and the destination shell has not mounted yet. This loading - // card therefore owns its ground rather than assuming a window-scale surface underneath it. - .accessibilityElement(children: .combine) - .accessibilityLabel("Preparing Omi") - } -} - -private struct PageChromeBar: View { - let onHome: () -> Void - - var body: some View { - HStack(spacing: OmiSpacing.sm) { - PageChromeButton(title: "Home", systemImage: "house.fill", action: onHome) - Spacer() - } - .frame(height: 34) - } -} - -private struct PageChromeButton: View { - let title: String - let systemImage: String - let action: () -> Void - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.xs) { - Image(systemName: systemImage) - .scaledFont(size: OmiType.caption, weight: .semibold) - Text(title) - .scaledFont(size: OmiType.caption, weight: .semibold) - } - .foregroundStyle(isHovering ? Ink.primary : Ink.secondary) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.xs) - // Never `Material`: that is within-window vibrancy and would frost the - // page under this pill instead of the desktop. A wash is the shape here. - .background(GlassPillBackground(isSelected: false, isHovering: isHovering)) - .overlay( - Capsule(style: .continuous) - .strokeBorder(Ink.hairline, lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .help(title) - .accessibilityLabel(title) - } -} - -private struct PageContentView: View { - let selectedIndex: Int - let appState: AppState - let viewModelContainer: ViewModelContainer - @Binding var memoryDestinationRawValue: Int - @Binding var selectedSettingsSection: SettingsContentView.SettingsSection - @Binding var highlightedSettingId: String? - @Binding var selectedTabIndex: Int - - /// The list/detail pages (Conversations, Memories, Tasks, Apps) render their - /// content in a centered, width-capped column so wide monitors get calm - /// gutters instead of a full-bleed stretch. Pages paint a clear background, so - /// the gutters show the shell surface seamlessly. - @ViewBuilder - private func constrainedListPage(_ page: V) -> some View { - page - .frame(maxWidth: MemoryHubLayoutPolicy.readableContentWidth, maxHeight: .infinity) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - var body: some View { - pages - } - - @ViewBuilder - private var pages: some View { - Group { - switch selectedIndex { - case 0: - QueryShellHome( - viewModel: viewModelContainer.dashboardViewModel, - homeStatusStore: viewModelContainer.homeStatusStore, - appState: appState, - appProvider: viewModelContainer.appProvider, - chatProvider: viewModelContainer.chatProvider, - memoriesViewModel: viewModelContainer.memoriesViewModel, - taskChatCoordinator: viewModelContainer.taskChatCoordinator, - selectedIndex: $selectedTabIndex) - case SidebarNavItem.conversations.rawValue, - SidebarNavItem.memories.rawValue, - SidebarNavItem.rewind.rawValue: - MemoryHubPage( - appState: appState, - viewModelContainer: viewModelContainer, - memoriesViewModel: viewModelContainer.memoriesViewModel, - destinationRawValue: $memoryDestinationRawValue - ) - case 4: - constrainedListPage( - TasksPage( - viewModel: viewModelContainer.tasksViewModel, - chatCoordinator: viewModelContainer.taskChatCoordinator, - chatProvider: viewModelContainer.chatProvider, - onOpenRewindEvidence: { screenshotID in - RewindCitationFocusState.shared.request(screenshotID) - memoryDestinationRawValue = MemoryHubDestination.rewind.rawValue - selectedTabIndex = SidebarNavItem.rewind.rawValue - })) - case 8: - constrainedListPage( - AppsPage( - appProvider: viewModelContainer.appProvider, - appState: appState, - connectorStatusStore: viewModelContainer.homeStatusStore.connectorStatusStore, - handlesAutomationPresentations: viewModelContainer.isInitialLoadComplete)) - case SidebarNavItem.settings.rawValue, SidebarNavItem.permissions.rawValue: - SettingsPage( - appState: appState, - selectedSection: $selectedSettingsSection, - highlightedSettingId: $highlightedSettingId, - chatProvider: viewModelContainer.chatProvider - ) - default: - QueryShellHome( - viewModel: viewModelContainer.dashboardViewModel, - homeStatusStore: viewModelContainer.homeStatusStore, - appState: appState, - appProvider: viewModelContainer.appProvider, - chatProvider: viewModelContainer.chatProvider, - memoriesViewModel: viewModelContainer.memoriesViewModel, - taskChatCoordinator: viewModelContainer.taskChatCoordinator, - selectedIndex: $selectedTabIndex) - } - } + ChatFirstShell( + navigation: chatFirstNavigation, + appState: appState, + viewModelContainer: viewModelContainer, + capability: chatFirstCapability.projection, + selectedSettingsSection: $selectedSettingsSection, + highlightedSettingID: $highlightedSettingId + ) } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopShellPresentationPolicy.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopShellPresentationPolicy.swift index 35024ecc421..583505f58c2 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopShellPresentationPolicy.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopShellPresentationPolicy.swift @@ -1,46 +1,3 @@ -enum DesktopShellPresentationPolicy { - static func usesChatFirst(_ useLegacyHomeDesign: Bool, _ capabilityVariant: ChatFirstShellVariant) -> Bool { - guard !useLegacyHomeDesign else { return false } - if case .chatFirst = capabilityVariant { return true } - return false - } - - /// The first-use popup belongs to the legacy shell. Chat-first owns its starter prompts inside - /// the main chat. Neither shell exposes the notch as a text-entry surface. - static func usesLegacyPostOnboardingPopup( - _ useLegacyHomeDesign: Bool, - _ capabilityVariant: ChatFirstShellVariant - ) -> Bool { - !usesChatFirst(useLegacyHomeDesign, capabilityVariant) - } -} - -enum HomeDesignPresentation: Equatable { - case queryShell - case redesignedHub - case oldestLegacy - - static func resolve( - useLegacyHomeDesign: Bool, - useOldestHomeDesign: Bool, - forceModernPresentation: Bool - ) -> Self { - guard !forceModernPresentation, useLegacyHomeDesign else { return .queryShell } - return useOldestHomeDesign ? .oldestLegacy : .redesignedHub - } - - static func queryShellOwnsItsPanels( - useLegacyHomeDesign: Bool, - forceModernPresentation: Bool - ) -> Bool { - resolve( - useLegacyHomeDesign: useLegacyHomeDesign, - useOldestHomeDesign: false, - forceModernPresentation: forceModernPresentation - ) == .queryShell - } -} - /// The notch is not a text surface. Typed conversation lives in the main window on every shell, so /// backing out of an agent chat with nothing else to show in the notch lands in the main chat /// rather than an empty composer. This used to be a per-shell flag (chat-first only); the legacy @@ -51,52 +8,3 @@ enum FloatingPrimaryTextInputRouting { !hasMainConversation } } - -/// Whether the Home stage — `HomeStageMode`'s hub / chat / connect — is mounted at all, and therefore -/// whether `DesktopAutomationSnapshot.homeMode` has anything true to say. -/// -/// **`DashboardPage` renders that stage and is its only writer.** The shell must never synthesize a -/// value for it. This began as an inline `(priorHomeMode ?? "hub")` guarded on "not chat-first, not -/// legacy, on the Dashboard tab" — which named `DashboardPage` exactly, on the day it was written. -/// Home then became `QueryShellHome`, a surface with no stage at all, and that same guard went on -/// answering `hub` forever. -/// -/// **A fabricated reading is worse than a missing one**, because `hub` is *plausible*. Nothing looks -/// broken: a flow waiting for `chat` waits for a transition that can never arrive, a flow asserting -/// `hub` passes without touching the app, and an agent reading `/state` draws a confident wrong -/// conclusion about a surface that is not on screen. `nil` says the one true thing — this shell has -/// no stage — and every reader already handles it, because legacy Home has always reported `nil`. -enum HomeStageAutomationPolicy { - - /// The last mode `DashboardPage` published, or `nil` when nothing is rendering the stage. Never a - /// default and never a guess: the shell's job here is to carry the owner's value or say there is - /// no owner. - static func reportedHomeMode( - usesChatFirstShell: Bool, - chatFirstRoute: ChatFirstRoute?, - lastPublishedMode: String? - ) -> String? { - guard usesChatFirstShell, let chatFirstRoute, mountsHomeStage(chatFirstRoute) else { return nil } - return lastPublishedMode - } - - /// The routes that mount `DashboardPage`, the only view that renders the stage. - /// - /// The legacy shell has no entry here on purpose, and that is the whole correction: its Home is - /// `QueryShellHome`, which renders the query surface, and the one branch that still mounts - /// `DashboardPage` there requires `useLegacyHomeDesign` — which routes to `legacyHome`. No stage - /// either way. - /// - /// An exhaustive `switch` rather than a `default`, so a route added later has to state its answer - /// instead of inheriting "reports a stage mode" from a fallthrough. - static func mountsHomeStage(_ route: ChatFirstRoute) -> Bool { - switch route { - case .chat: - return true - case .more(let page): - return page == .dashboard - case .conversations, .tasks, .goals, .memories: - return false - } - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopUpdateStatusPresentation.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopUpdateStatusPresentation.swift index 30c8abdb0a1..4eaf0361ec1 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopUpdateStatusPresentation.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopUpdateStatusPresentation.swift @@ -172,7 +172,7 @@ struct DesktopUpdateStatusChipLabel: View { } /// Compact chip shown in `DesktopTopBar` so chat-first shell users see Sparkle -/// progress (the legacy sidebar widget is unreachable when `usesChatFirstShell`). +/// progress (the legacy sidebar widget it used to share this job with is gone). struct DesktopUpdateStatusChip: View { @ObservedObject private var updaterViewModel = UpdaterViewModel.shared @State private var glowAnimating = false diff --git a/desktop/macos/Desktop/Sources/MainWindow/LegacySidebarSurface.swift b/desktop/macos/Desktop/Sources/MainWindow/LegacySidebarSurface.swift deleted file mode 100644 index 7c5b96c2c8d..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/LegacySidebarSurface.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// LegacySidebarSurface.swift — the one ground under the old Home sidebar slot. -// - -import OmiTheme -import SwiftUI - -/// Hosts the old Home navigation slot on its own piece of glass. -/// -/// `ShellWindowChrome` leaves the top-level window transparent and `PageGlassLane` grounds only the -/// destination beside this slot. Keeping the surface here means the primary navigation and the -/// Settings menu share one owner for both their visible glass and their mouse-hit region. -struct LegacySidebarSurface: View { - private let content: Content - private let reduceTransparency: Bool? - - init(reduceTransparency: Bool? = nil, @ViewBuilder content: () -> Content) { - self.content = content() - self.reduceTransparency = reduceTransparency - } - - var body: some View { - content - .fixedSize(horizontal: true, vertical: false) - .clipped() - .inkGlassPanel(cornerRadius: 0, shadow: nil, reduceTransparency: reduceTransparency) - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift deleted file mode 100644 index 978aa898777..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ /dev/null @@ -1,4165 +0,0 @@ -import AppKit -import Combine -import OmiTheme -import SwiftUI -import UniformTypeIdentifiers - -// MARK: - Dashboard View Model - -@MainActor -class DashboardViewModel: ObservableObject { - // Observe the shared TasksStore - private let tasksStore = TasksStore.shared - - @Published var scoreResponse: ScoreResponse? - @Published var goals: [Goal] = [] - @Published var isLoading = false - @Published var error: String? - - private var cancellables = Set() - private var lastGoalRefreshTime: Date = .distantPast - - // Computed properties that delegate to TasksStore - var overdueTasks: [TaskActionItem] { tasksStore.overdueTasks } - var todaysTasks: [TaskActionItem] { tasksStore.todaysTasks } - var recentTasks: [TaskActionItem] { tasksStore.tasksWithoutDueDate } - - init() { - // Forward TasksStore changes to trigger view updates - tasksStore.objectWillChange - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.objectWillChange.send() - } - .store(in: &cancellables) - - // Load goals from local SQLite for instant display - loadGoalsFromLocal() - - // Refresh goals when one is auto-created - NotificationCenter.default.publisher(for: .goalAutoCreated) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - Task { [weak self] in - await self?.loadGoals() - } - } - .store(in: &cancellables) - } - - func loadDashboardData() async { - isLoading = true - error = nil - - // Load all data in parallel - async let scoreTask: Void = loadScores() - async let tasksTask: Void = tasksStore.refreshDashboardTasksFromServer() - async let goalsTask: Void = loadGoals() - - let _ = await (scoreTask, tasksTask, goalsTask) - - isLoading = false - } - - func loadCachedDashboardData() async { - await loadGoalsFromLocalSnapshot() - } - - func resetSessionState() { - scoreResponse = nil - goals = [] - isLoading = false - error = nil - lastGoalRefreshTime = .distantPast - } - - private func loadScores() async { - do { - scoreResponse = try await APIClient.shared.getScores() - } catch { - logError("Failed to load scores", error: error) - } - } - - private func loadGoals() async { - // 1. Show local data first (already loaded in init) - // 2. Fetch from API - do { - let apiGoals = try await APIClient.shared.getGoals() - // 3. Sync to SQLite - try await GoalStorage.shared.syncServerGoals(apiGoals) - // 4. Reload from SQLite (source of truth) - goals = try await GoalStorage.shared.getLocalGoals() - lastGoalRefreshTime = Date() - } catch { - logError("Failed to load goals", error: error) - } - } - - /// Refresh goals with 30-second debounce (for app lifecycle events) - func refreshGoals() { - let now = Date() - guard now.timeIntervalSince(lastGoalRefreshTime) > 30 else { return } - Task { - await loadGoals() - } - } - - // MARK: - Local Goals Storage - - private func loadGoalsFromLocal() { - Task { - await loadGoalsFromLocalSnapshot() - } - } - - private func loadGoalsFromLocalSnapshot() async { - do { - goals = try await GoalStorage.shared.getLocalGoals() - } catch { - logError("Failed to load goals from local storage", error: error) - } - } - - func toggleTaskCompletion(_ task: TaskActionItem) async { - // Delegate to shared store - it handles the update - await tasksStore.toggleTask(task) - // Reload scores after task completion change - await loadScores() - } - - func createGoal(title: String, goalType: GoalType, targetValue: Double, unit: String?) async { - do { - let goal = try await APIClient.shared.createGoal( - title: title, - goalType: goalType, - targetValue: targetValue, - unit: unit, - source: "user" - ) - _ = try? await GoalStorage.shared.syncServerGoal(goal) - goals = try await GoalStorage.shared.getLocalGoals() - } catch { - logError("Failed to create goal", error: error) - } - } - - func updateGoalProgress(_ goal: Goal, currentValue: Double) async { - log("Goals: Updating '\(goal.title)' progress to \(currentValue)") - - // Optimistically update local SQLite - if let index = goals.firstIndex(where: { $0.id == goal.id }) { - goals[index].currentValue = currentValue - } - try? await GoalStorage.shared.updateProgress(backendId: goal.id, currentValue: currentValue) - - do { - let updated = try await APIClient.shared.updateGoalProgress( - goalId: goal.id, - currentValue: currentValue - ) - - // Sync API response to SQLite - _ = try? await GoalStorage.shared.syncServerGoal(updated) - - // Check if the backend auto-completed this goal - if updated.completedAt != nil { - log("Goals: '\(goal.title)' COMPLETED! Triggering celebration.") - goals = try await GoalStorage.shared.getLocalGoals() - NotificationCenter.default.post(name: .goalCompleted, object: updated) - return - } - - goals = try await GoalStorage.shared.getLocalGoals() - log("Goals: Updated '\(goal.title)' progress confirmed by API") - } catch { - logError("Failed to update goal progress", error: error) - } - } - - func updateGoal(_ goal: Goal, title: String, currentValue: Double, targetValue: Double) async { - log("Goals: Updating goal '\(goal.title)' -> title='\(title)', current=\(currentValue), target=\(targetValue)") - - do { - let updated = try await APIClient.shared.updateGoal( - goalId: goal.id, - title: title, - currentValue: currentValue, - targetValue: targetValue - ) - - _ = try? await GoalStorage.shared.syncServerGoal(updated) - goals = try await GoalStorage.shared.getLocalGoals() - log("Goals: Updated goal '\(updated.title)' confirmed by API") - } catch { - logError("Failed to update goal", error: error) - goals = (try? await GoalStorage.shared.getLocalGoals()) ?? goals - } - } - - func deleteGoal(_ goal: Goal) async { - do { - // Soft-delete locally first for instant UI update - try? await GoalStorage.shared.softDelete(backendId: goal.id) - goals = try await GoalStorage.shared.getLocalGoals() - // Then delete on backend - try await APIClient.shared.deleteGoal(id: goal.id) - } catch { - logError("Failed to delete goal", error: error) - } - } -} - -// MARK: - Dashboard Page - -struct DashboardPage: View { - @ObservedObject var viewModel: DashboardViewModel - @ObservedObject var homeStatusStore: HomeStatusStore = HomeStatusStore() - @ObservedObject var appState: AppState - @ObservedObject var appProvider: AppProvider - @ObservedObject var chatProvider: ChatProvider - @ObservedObject var memoriesViewModel: MemoriesViewModel - var taskChatCoordinator: TaskChatCoordinator? = nil - /// Present only for the capability-gated main-window Home chat. Shared - /// Dashboard callers leave this nil and keep journaled rich blocks inert. - var chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil - /// The Chat-first shell reuses dashboard content under More, but Chat itself - /// has one primary home. Legacy callers leave this nil and retain their - /// inline Home chat exactly as before. - var onOpenPrimaryChat: (() -> Void)? = nil - @ObservedObject private var deviceProvider = DeviceProvider.shared - @ObservedObject private var homeSuggestionsStore = HomeSuggestionsStore.shared - @StateObject private var intelligenceStore = DashboardIntelligenceStore() - /// Learned insights ("things about you") — surfaced in the home hub's rotating - /// knows-list alongside tasks and asks, not just on the Insights page. - @ObservedObject private var insightStorage = InsightStorage.shared - @State private var dismissedKnowsTaskIDs: Set = [] - @State private var homeAskFocusPolicy = HomeAskFocusPolicy() - @Binding var selectedIndex: Int - @State private var selectedCatalogApp: OmiApp? - @State private var selectedImportConnector: ImportConnector? - @State private var selectedExportDestination: MemoryExportDestination? - @State private var homeConnectSheetAcceptsInput = false - @State private var isCaptureMonitoring = false - @State private var isTogglingCapture = false - @State private var isTogglingListening = false - @State private var showingAllGoals = false - @State private var showingGoalDetail = false - @AppStorage("dashboardWidgetsCollapsed") private var widgetsCollapsed = false - @AppStorage("screenAnalysisEnabled") private var screenAnalysisEnabled = true - @AppStorage(AssistantSettings.audioRecordingModeDefaultsKey) private var audioRecordingModeRaw = - AssistantSettings.AudioRecordingMode.onlyMeetings.rawValue - @AppStorage("useLegacyHomeDesign") private var useLegacyHomeDesign = false - @AppStorage("useOldestHomeDesign") private var useOldestHomeDesign = false - @State private var homeMode: HomeStageMode = .hub - @State private var didReportChatFirstTranscriptPage = false - @FocusState private var homeAskFieldFocused: Bool - - private var routesChatToPrimaryShell: Bool { - onOpenPrimaryChat != nil - } - /// Rotation index for the home knows-list; a timer advances it so the hub - /// cycles through fresh suggestions while you're looking at it. - @State private var knowsRotation = 0 - private let knowsRotationTimer = Timer.publish(every: 7, on: .main, in: .common).autoconnect() - - private var selectedApp: OmiApp? { - guard let appId = chatProvider.selectedAppId else { return nil } - return appProvider.chatApps.first { $0.id == appId } - } - - private var captureStatus: HomeStatusState { - CaptureListeningLogic.captureStatus(appState: appState, isCaptureMonitoring: isCaptureMonitoring) - } - - private var isCaptureLive: Bool { - CaptureListeningLogic.isCaptureLive(isCaptureMonitoring: isCaptureMonitoring) - } - - private var listeningModeTitle: String { - CaptureListeningLogic.listeningModeTitle(appState: appState, raw: audioRecordingModeRaw) - } - - private static let homeStageMaxWidth: CGFloat = 1360 - private static let homeStageMinSideInset: CGFloat = 30 - private static let homeStageMaxSideInset: CGFloat = 96 - private static let homeAskBarMinWidth: CGFloat = 560 - private static let homeAskBarMaxWidth: CGFloat = 980 - private static let homeStagePanelMaxWidth: CGFloat = 1280 - private static let homeChatColumnMaxWidth = ChatComposerLayout.contentLaneMaxWidth - private static let homeStageTopPadding: CGFloat = 74 - private static let homeStageBottomPadding: CGFloat = 26 - private static let homeStageAnimation = Animation.spring(response: 0.46, dampingFraction: 0.86) - private static let homeConnectSheetHorizontalMargin: CGFloat = 56 - private static let homeConnectSheetVerticalMargin: CGFloat = 44 - private static let homeConnectSheetMinWidth: CGFloat = 360 - private static let homeConnectSheetMinHeight: CGFloat = 360 - private static let homeConnectSheetCornerRadius: CGFloat = 24 - private static let appDetailSheetPreferredSize = CGSize(width: 500, height: 600) - private static let importConnectorSheetPreferredSize = CGSize(width: 520, height: 500) - private static let exportDestinationSheetPreferredSize = CGSize(width: 520, height: 560) - - private var homeConnectSheetIsPresented: Bool { - selectedCatalogApp != nil || selectedImportConnector != nil || selectedExportDestination != nil - } - - private var isHomeModalPresented: Bool { - homeConnectSheetIsPresented - } - - private var legacySelectedCatalogApp: Binding { - Binding( - get: { useLegacyHomeDesign ? selectedCatalogApp : nil }, - set: { selectedCatalogApp = $0 } - ) - } - - private var legacySelectedImportConnector: Binding { - Binding( - get: { useLegacyHomeDesign ? selectedImportConnector : nil }, - set: { selectedImportConnector = $0 } - ) - } - - private var legacySelectedExportDestination: Binding { - Binding( - get: { useLegacyHomeDesign ? selectedExportDestination : nil }, - set: { selectedExportDestination = $0 } - ) - } - - private var hasOmiDeviceHistory: Bool { - deviceProvider.connectedDevice != nil || deviceProvider.pairedDevice != nil - || homeStatusStore.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 homeStatusStore.connectorStatusStore.snapshot(for: connector).isConnected - } - - private func isMCPDestinationConnected(_ destination: MemoryExportDestination) -> Bool { - switch destination { - case .claude, .claudeCode: - return [.claude, .claudeCode].contains { homeStatusStore.memoryExportStatuses[$0]?.hasConnection == true } - case .chatgpt, .codex: - return [.chatgpt, .codex].contains { homeStatusStore.memoryExportStatuses[$0]?.hasConnection == true } - default: - return homeStatusStore.memoryExportStatuses[destination]?.hasConnection == true - } - } - - var body: some View { - applyChatNavigation(to: applyHomeLifecycle(to: applyHomeSheets(to: homeSurface))) - } - - /// Opening chat from the notch / Ask-Omi shortcut (posts `.navigateToChat`) - /// lands in the live chat surface — which shares the notch's transcript — - /// rather than the resting hero. Kept in its own modifier so the main - /// lifecycle chain stays type-checkable. - private func applyChatNavigation(to content: Content) -> some View { - content - .onReceive(NotificationCenter.default.publisher(for: .navigateToChat)) { _ in - openHomeChat(focusInput: true) - } - } - - private var homeSurface: some View { - Group { - if useLegacyHomeDesign && useOldestHomeDesign && !routesChatToPrimaryShell { - legacyHome - } else { - redesignedHome - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - // `PageGlassLane.panel` supplies the ground for older Home surfaces; keep this clear to match it. - .background(Color.clear) - } - - private func applyHomeSheets(to content: Content) -> some View { - content - .sheet(isPresented: $showingAllGoals) { - AllGoalsSheet( - store: intelligenceStore, - onOpenGoal: { goalID in await openGoal(goalID) }, - onDismiss: { showingAllGoals = false } - ) - } - .sheet(isPresented: $showingGoalDetail) { - if let detail = intelligenceStore.selectedGoalDetail { - CanonicalGoalDetailSheet( - detail: detail, - error: intelligenceStore.error, - onResumeThread: { workstreamID in - _ = await resumeThread(workstreamID: workstreamID, taskID: nil) - }, - onStartWork: { await startWorkFromSelectedGoal() }, - onDismiss: { - showingGoalDetail = false - intelligenceStore.clearGoalDetail() - } - ) - } else { - ProgressView().frame(width: 300, height: 180) - } - } - .dismissableSheet(item: legacySelectedCatalogApp) { app in - AppDetailSheet(app: app, appProvider: appProvider, onDismiss: { selectedCatalogApp = nil }) - .frame(width: 500, height: 650) - .onAppear { - AnalyticsManager.shared.appDetailViewed(appId: app.id, appName: app.name) - } - } - .dismissableSheet(item: legacySelectedImportConnector) { connector in - ImportConnectorSheet( - connector: connector, - appState: appState, - statusStore: homeStatusStore.connectorStatusStore, - onDismiss: { - selectedImportConnector = nil - } - ) - .frame(width: 520, height: 620) - } - .dismissableSheet(item: legacySelectedExportDestination) { destination in - ConnectDestinationSheet( - destination: destination, - statuses: $homeStatusStore.memoryExportStatuses, - onDismiss: { - selectedExportDestination = nil - } - ) - .frame(width: 520, height: 620) - } - } - - // Split in two (`applyHomeLifecycle` → `applyHomeStageObservers`) so each - // modifier chain stays within the type-checker's budget. - private func applyHomeLifecycle(to content: Content) -> some View { - applyHomeStageObservers(to: applyHomeLifecycleCore(to: content)) - } - - private func applyHomeLifecycleCore(to content: Content) -> some View { - content - .onAppear { - // The "try asking" popup is armed by the shell that owns its overlay - // (`DesktopHomeView`), not from here: this page is only Home behind - // `useLegacyHomeDesign`, and while it held the only trigger the popup - // could not fire on the default Home at all. - syncCaptureState() - autoOpenChatForExistingHistoryIfNeeded() - // Post-onboarding, the resting hub is shown by default — open the chat - // surface so the personalized opener (set on onboarding completion) is - // actually visible instead of hidden behind the hub. - if chatProvider.onboardingOpener != nil { openHomeChat(focusInput: false) } - consumePendingMainChatOpenRequest() - reportHomeAutomationMode() - intelligenceStore.setRecommendationActionHandler { recommendation in - await openRecommendation(recommendation) - } - intelligenceStore.registerAutomationActions() - Task { await intelligenceStore.load() } - Task { - if let recommendationID = ContextualTaskNavigationRouter.shared.consume() { - _ = await intelligenceStore.openRecommendation(id: recommendationID) - } - } - Task { await homeStatusStore.refreshIfNeeded() } - Task { await homeSuggestionsStore.refreshIfNeeded() } - } - .onDisappear { - intelligenceStore.setRecommendationActionHandler(nil) - } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - viewModel.refreshGoals() - Task { await intelligenceStore.load() } - appState.checkAllPermissions() - syncCaptureState() - Task { await homeStatusStore.refreshIfNeeded() } - Task { await homeSuggestionsStore.refreshIfNeeded() } - } - .onReceive(NotificationCenter.default.publisher(for: .assistantMonitoringStateDidChange)) { _ in - syncCaptureState() - } - .onReceive(NotificationCenter.default.publisher(for: .whatMattersNowContextDidRefresh)) { notification in - guard let projection = notification.object as? OmiAPI.WhatMattersNowProjection else { return } - intelligenceStore.applyContextProjection(projection) - } - .onReceive(NotificationCenter.default.publisher(for: .openWhatMattersNowRecommendation)) { notification in - guard - let recommendationID = notification.userInfo?[ - TaskContextualResurfacingService.recommendationIDUserInfoKey - ] as? String - else { return } - guard ContextualTaskNavigationRouter.shared.consume(requestedID: recommendationID) != nil else { return } - Task { _ = await intelligenceStore.openRecommendation(id: recommendationID) } - } - .onReceive(NotificationCenter.default.publisher(for: .screenCapturePermissionLost)) { _ in - syncCaptureState() - } - .onReceive(NotificationCenter.default.publisher(for: .screenCaptureKitBroken)) { _ in - syncCaptureState() - } - } - - private func applyHomeStageObservers(to content: Content) -> some View { - content - // "Continue in Omi" while the dashboard is already mounted; the - // not-yet-mounted case is covered by the consume in onAppear. - .onReceive(NotificationCenter.default.publisher(for: .openMainChatRequested)) { _ in - consumePendingMainChatOpenRequest() - } - // Chat history is the home surface: as soon as the (async) history - // load shows prior messages, land on the chat panel, not the greeting. - .onChange(of: chatProvider.messages.count) { _, _ in - autoOpenChatForExistingHistoryIfNeeded() - } - // The journal projection is installed before the initial-load flag is - // cleared. Observe the flag as well so Home reveals the atomic snapshot - // only after restoration is complete. - .onChange(of: chatProvider.isLoading) { _, _ in - autoOpenChatForExistingHistoryIfNeeded() - } - // Clicking into the ask bar reveals the inline chat; the same is true - // when focus lands there via keyboard (Tab / Full Keyboard Access). - .onChange(of: homeAskFieldFocused) { _, focused in - if focused && !useLegacyHomeDesign && homeMode != .chat { - openHomeChat() - } - } - // Automation-bridge entry points (home_open_chat / home_connect_toggle / - // home_close_panel / home_ask) — they call the exact functions the - // on-screen controls call. - .onReceive(NotificationCenter.default.publisher(for: .homeStageOpenChat)) { _ in - guard !useLegacyHomeDesign else { return } - openHomeChat() - } - .onReceive(NotificationCenter.default.publisher(for: .homeStageToggleConnect)) { _ in - guard !useLegacyHomeDesign else { return } - toggleHomeConnectPanel() - } - .onReceive(NotificationCenter.default.publisher(for: .homeStageClose)) { _ in - guard !useLegacyHomeDesign else { return } - collapseHomeStagePanel() - } - .onReceive(NotificationCenter.default.publisher(for: .homeStageAsk)) { note in - guard !useLegacyHomeDesign, - let query = note.userInfo?["query"] as? String - else { return } - askHomeSuggestion(query) - } - .onReceive(NotificationCenter.default.publisher(for: .homeStageAttach)) { note in - guard !useLegacyHomeDesign, - let path = note.userInfo?["path"] as? String - else { return } - // Same wiring the ask bar's paperclip/drag-drop runs after the - // OS hands back file URLs. - if let attachment = ChatAttachment.from(url: URL(fileURLWithPath: path)) { - chatProvider.addAttachments([attachment]) - } - } - } - - private var legacyHome: some View { - VStack(spacing: 0) { - dashboardWidgets - - ChatMessagesView( - messages: chatProvider.messages, - conversationIdentity: chatProvider.currentSessionId ?? ChatConversationIdentity.mainChatDefault, - isSending: chatProvider.isSending, - hasMoreMessages: chatProvider.hasMoreMessages, - isLoadingMoreMessages: chatProvider.isLoadingMoreMessages, - isLoadingInitial: chatProvider.isLoading && !chatProvider.isClearing, - app: selectedApp, - onLoadMore: { await chatProvider.loadMoreMessages() }, - onRate: { messageId, rating in - Task { await chatProvider.rateMessage(messageId, rating: rating) } - }, - onCitationTap: { citation in - handleCitationTap(citation) - }, - sessionsLoadError: chatProvider.sessionsLoadError.map { - UserFacingErrorPresentation.message(from: $0, while: .chatSessions) - }, - onRetry: { Task { await chatProvider.retryLoad() } }, - localSendToken: chatProvider.localSendToken, - onOpenAgent: { agentID, completion in - FloatingControlBarManager.shared.openAgentChatFromTimeline(agentID: agentID, completion: completion) - }, - onOpenAgentRef: FloatingControlBarManager.shared.openAgentChatFromTimeline(ref:completion:), - chatFirstRichBlockContext: chatFirstRichBlockContext, - welcomeContent: { dashboardChatWelcome } - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .mask( - LinearGradient( - stops: [ - .init(color: .clear, location: 0.0), - .init(color: .black, location: 0.08), - .init(color: .black, location: 0.92), - .init(color: .clear, location: 1.0), - ], - startPoint: .top, - endPoint: .bottom - ) - ) - - dashboardChatErrorCard - .padding(.horizontal, OmiSpacing.section) - - ChatDraftScope(draft: chatProvider.composerDraft) { draft in - ChatInputView( - onSend: { text in - Task { - await chatProvider.sendMainDraft( - text, - onAccepted: { - AnalyticsManager.shared.chatMessageSent( - messageLength: text.count, - hasSelectedAppContext: selectedApp != nil, - source: "dashboard_chat" - ) - }) - } - }, - onStop: { - chatProvider.stopAgent(owner: .mainChat) - }, - isSending: chatProvider.isSending, - isStopping: chatProvider.isStopping, - placeholder: "Ask omi anything", - mode: $chatProvider.chatMode, - inputText: draft, - attachments: $chatProvider.pendingAttachments, - onAttachmentsAdded: { urls in - let toAdd = urls.compactMap { ChatAttachment.from(url: $0) } - chatProvider.addAttachments(toAdd) - }, - onAttachmentRemoved: { id in - chatProvider.removePendingAttachment(id: id) - }, - references: chatProvider.pendingComposerReferences, - onReferenceRemoved: { id in - chatProvider.removeComposerReference(id: id) - } - ) - .padding(.horizontal, OmiSpacing.section) - .padding(.top, OmiSpacing.md) - .padding(.bottom, OmiSpacing.xl) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.clear) - } - - // MARK: - Redesigned Home - - private var redesignedHome: some View { - GeometryReader { proxy in - let panelHeight = min(max(proxy.size.height - 132, CGFloat(440)), CGFloat(640)) - let panelTop = max(CGFloat(82), (proxy.size.height - panelHeight) / 2) - let panelWidth = homeStageContentWidth(for: proxy.size.width) - - // No canvas of its own. The window has no ground at all (`ShellWindowChrome`) and Home's - // own panels are the glass; Home used to paint a near-black gradient edge to edge, which - // survived the palette conversion and left every `Ink` colour on the page — all of - // which resolve *dark* on the light-pinned panel — drawn near-black on near-black. - ZStack(alignment: .topTrailing) { - // Clicking anywhere outside the chat / connect panel collapses - // back to the resting surface (panels and the ask bar consume their - // own clicks above this catcher). When chat history exists, chat IS - // the resting Home surface, so no catcher is mounted over it — and - // the hub is never an overlay, so no catcher is ever mounted over - // the hub either (a stray click must not throw the user into chat). - if HomeStageMode.collapseCatcherActive(mode: homeMode, resting: homeRestingMode) { - Color.black.opacity(0.001) - .ignoresSafeArea() - .contentShape(Rectangle()) - .onTapGesture { - collapseHomeStagePanel() - } - } - - homeStage(stageWidth: proxy.size.width, stageHeight: proxy.size.height) - .frame(width: proxy.size.width, height: proxy.size.height) - // The popup/sheet overlays are modal: while one is up, the - // stage underneath must not be reachable by VoiceOver / - // Full Keyboard Access. - .accessibilityHidden(isHomeModalPresented) - - // Capture/Listening now live in the shell's constant top bar (see - // DesktopTopBar), so the home no longer renders its own header copy. - - homeConnectSheetOverlay( - contentWidth: proxy.size.width, - panelWidth: panelWidth, - panelHeight: panelHeight, - panelTop: panelTop - ) - - // Esc collapses the connect tray (and, with no chat history, the - // inline chat) back to the resting surface — but only while no modal - // overlay owns the key. Chat with history is Home itself and cannot - // be escaped; the hub is likewise never escaped *into* a panel. - if HomeStageMode.collapseCatcherActive(mode: homeMode, resting: homeRestingMode) - && !isHomeModalPresented - { - OverlayModalEscapeCatcher { - collapseHomeStagePanel() - } - } - } - .omiAnimation(.easeOut(duration: 0.2), value: homeConnectSheetIsPresented) - .omiAnimation(Self.homeStageAnimation, value: homeMode) - } - } - - /// Vertical stage: mode content on top (hub metrics, inline chat, or the - /// connect tray), the persistent ask bar anchored beneath it, and the - /// suggested questions under the bar while the hub is showing. - private func homeStage(stageWidth: CGFloat, stageHeight: CGFloat) -> some View { - Group { - if homeMode == .hub { - homeHubStage(stageWidth: stageWidth) - .transition(.homeHubStage) - } else { - homePanelStage(stageWidth: stageWidth, askBarWidth: homeChatColumnWidth(for: stageWidth)) - } - } - .padding(.top, homeMode.topPadding(hub: Self.homeStageTopPadding)) - .padding(.bottom, Self.homeStageBottomPadding) - } - - /// Hub layout: the greeting headline and knows-list rows centered on the - /// stage over the memory constellation, with the goals/error surfaces and - /// the ask bar docked as one column at the bottom. - private func homeHubStage(stageWidth: CGFloat) -> some View { - // Keep the knows-list column tight so short rows (e.g. "Call Rabia") don't - // strand their trailing icon across a wide gap; long one-liners still fit. - let columnWidth = min(CGFloat(520), homeStageContentWidth(for: stageWidth)) - - return VStack(spacing: 0) { - Spacer(minLength: 0) - - homeHubHeadline - .transition(.homeHubFade) - - homeKnowsList(width: columnWidth) - .padding(.top, OmiSpacing.xxl) - .transition(.homeSuggestionsFade) - - Spacer(minLength: 0) - - // Only this column's width tracks the typed text, so only it subscribes. - ChatDraftScope(draft: chatProvider.composerDraft) { draft in - let askBarWidth = homeHubAskBarWidth(for: stageWidth, draft: draft.wrappedValue) - VStack(spacing: 0) { - dashboardIntelligenceError - .frame(width: askBarWidth) - .padding(.bottom, intelligenceStore.error == nil ? 0 : OmiSpacing.sm) - - FocusedGoalsSection( - store: intelligenceStore, - onOpenGoal: { goalID in await openGoal(goalID) }, - onShowAll: { showingAllGoals = true } - ) - .frame(width: askBarWidth) - .padding(.bottom, hasFocusedGoalsSurface ? OmiSpacing.md : 0) - - homeAskBar - .frame(width: askBarWidth) - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var hasFocusedGoalsSurface: Bool { - !intelligenceStore.focusedGoals.isEmpty || intelligenceStore.accountGeneration != nil - } - - /// Panel layout (chat / connect): the surface fills the height with the ask - /// bar anchored directly beneath it. - private func homePanelStage(stageWidth: CGFloat, askBarWidth: CGFloat) -> some View { - VStack(spacing: 0) { - ZStack { - switch homeMode { - case .chat: - homeChatPanel(width: askBarWidth) - .transition(.homeChatRise) - case .connect: - homeConnectPanel(stageWidth: stageWidth) - .transition(.homeDropFromTop) - case .hub: - EmptyView() - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - // Rolling suggestions sit just above the ask bar while the chat is empty — - // but not for a just-onboarded user, whose empty chat shows the personalized - // onboarding opener (with its own starter questions) instead. - if chatProvider.messages.isEmpty && chatProvider.onboardingOpener == nil { - homeRollingSuggestions - .frame(width: askBarWidth) - .padding(.bottom, OmiSpacing.sm) - } - - homeAskBar - .frame(width: askBarWidth) - .padding(.top, OmiSpacing.xxs) - - dashboardChatErrorCard - .frame(width: askBarWidth) - .padding(.top, OmiSpacing.sm) - } - } - - /// A small, auto-rotating set of prompt suggestions shown above the ask bar on - /// an empty home chat — replaces the old greeting hero + knows-list cards. - private var homeRollingSuggestions: some View { - VStack(spacing: OmiSpacing.xs) { - ForEach(Array(homeKnowsRows.prefix(3))) { row in - Button { - openKnowsRow(row) - } label: { - HStack(spacing: OmiSpacing.sm) { - Image(systemName: rollingSuggestionIcon(row.kind)) - .scaledFont(size: OmiType.caption) - .foregroundStyle(HomePalette.muted) - Text(row.text) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.secondary) - .lineLimit(1) - Spacer(minLength: 8) - } - .padding(.horizontal, OmiSpacing.md) - .frame(height: 34) - .frame(maxWidth: .infinity) - .background(RoundedRectangle(cornerRadius: 11, style: .continuous).fill(Ink.rowFill)) - .overlay( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 11)) - } - .buttonStyle(.plain) - .transition(.opacity) - } - } - .omiAnimation(.easeInOut(duration: 0.45), value: knowsRotation) - .onReceive(knowsRotationTimer) { _ in - guard homeMode == .chat, chatProvider.messages.isEmpty, !chatProvider.isSending, homeKnowsCanRotate - else { return } - knowsRotation += 1 - } - } - - private func rollingSuggestionIcon(_ kind: HomeKnowsRowKind) -> String { - switch kind { - case .task: return "circle" - case .insight: return ProactiveNotificationBadge.insightSystemImage - case .question: return "bubble.left" - } - } - - // MARK: Hub centerpiece - - private var homeHubHeadline: some View { - VStack(spacing: OmiSpacing.sm) { - SBLogo(size: 40, spinning: chatProvider.isSending) - .padding(.bottom, OmiSpacing.lg) - - Text(homeHubGreeting) - .scaledFont(size: OmiType.hero, weight: .bold) - .foregroundStyle(HomePalette.ink) - .multilineTextAlignment(.center) - - Text(homeDailyBrief) - .scaledFont(size: OmiType.subheading) - .foregroundStyle(HomePalette.muted) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .center) - } - - private var homeHubGreeting: String { - let name = AuthService.shared.givenName.trimmingCharacters(in: .whitespacesAndNewlines) - return name.isEmpty ? "I'm ready." : "Hey \(name). I'm ready." - } - - // MARK: Knows list - - /// Insight rows for the hub: the task-intelligence recommendations plus the - /// learned insights ("things about you") from the Insights store, so the hub - /// surfaces insights, not only tasks and asks. - private var homeKnowsInsightCandidates: [HomeKnowsInsightCandidate] { - let recommendations = intelligenceStore.recommendations.map { - HomeKnowsInsightCandidate(id: $0.id, text: $0.headline) - } - let learned = insightStorage.insightHistory - .filter { !$0.isDismissed } - .prefix(12) - .map { HomeKnowsInsightCandidate(id: $0.id, text: $0.insight.insight) } - return recommendations + Array(learned) - } - - private var homeKnowsRows: [HomeKnowsRow] { - HomeKnowsListComposer.compose( - tasks: homeKnowsTaskCandidates, - insights: homeKnowsInsightCandidates, - tip: homeActionTip, - questions: homeSuggestedQuestions, - dismissedTaskIDs: dismissedKnowsTaskIDs, - rotation: knowsRotation - ) - } - - /// True when there are more candidates than the hub shows, so rotating cycles - /// to genuinely different rows instead of the same set. - private var homeKnowsCanRotate: Bool { - HomeKnowsListComposer.canRotate( - taskCount: homeKnowsTaskCandidates.filter { !dismissedKnowsTaskIDs.contains($0.id) }.count, - insightCount: homeKnowsInsightCandidates.count, - questionCount: homeSuggestedQuestions.count - ) - } - - /// A composed, high-agency nudge for the tip slot when there's no server - /// insight — one thing you can hand Omi with a tap (it prefills the chat). - private var homeActionTip: String? { - let openCount = - homeKnowsTaskCandidates - .filter { !dismissedKnowsTaskIDs.contains($0.id) } - .count - if openCount >= 5 { - return "Sort my open tasks — which 3 actually matter today?" - } - return "Recap what I got done today" - } - - /// A short, conversational read on the day — what you've been doing and how - /// much is waiting — shown under the greeting. It absorbs the focus status so - /// the action rows below stay purely actionable. - private var homeDailyBrief: String { - let openCount = - homeKnowsTaskCandidates - .filter { !dismissedKnowsTaskIDs.contains($0.id) } - .count - let tail: String - switch openCount { - case 0: tail = "nothing's waiting on you." - case 1: tail = "one thing needs you." - default: tail = "\(openCount) things need you." - } - - return tail.prefix(1).uppercased() + tail.dropFirst() - } - - private var homeKnowsTaskCandidates: [HomeKnowsTaskCandidate] { - (viewModel.overdueTasks + viewModel.todaysTasks + viewModel.recentTasks) - .filter { !$0.completed && !$0.isRetired } - .map { HomeKnowsTaskCandidate(id: $0.id, text: $0.description) } - } - - private func homeKnowsList(width: CGFloat) -> some View { - VStack(spacing: OmiSpacing.sm) { - ForEach(homeKnowsRows) { row in - HomeKnowsRowView( - row: row, - onOpen: { openKnowsRow(row) }, - onDismiss: knowsDismissHandler(for: row), - onLater: knowsLaterHandler(for: row) - ) - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - } - .frame(width: width) - .omiAnimation(.easeInOut(duration: 0.45), value: knowsRotation) - .onReceive(knowsRotationTimer) { _ in - // Only rotate on the resting hub, when idle, and when there's genuinely - // more to show — so the set feels alive without churning under you. - guard homeMode == .hub, !chatProvider.isSending, homeKnowsCanRotate else { return } - knowsRotation += 1 - } - .accessibilityIdentifier("home-knows-list") - } - - private func openKnowsRow(_ row: HomeKnowsRow) { - switch row.kind { - case .task(let id): - if let task = (viewModel.overdueTasks + viewModel.todaysTasks + viewModel.recentTasks) - .first(where: { $0.id == id }) - { - TaskNavigationRequestStore.shared.request(task: task) - } - navigate(to: .tasks) - case .insight(let id): - guard let recommendation = intelligenceStore.recommendations.first(where: { $0.id == id }) - else { return } - Task { - if await openRecommendation(recommendation) { - await intelligenceStore.recordPrimaryAction(recommendation) - } - } - case .question: - // Prefill the ask bar so you can glance it over and edit before sending, - // rather than firing the suggestion blindly. - chatProvider.draftText = row.text - homeAskFieldFocused = true - } - } - - private func knowsDismissHandler(for row: HomeKnowsRow) -> ((OmiAPI.TaskIntelligenceFeedbackReason?) -> Void)? { - switch row.kind { - case .task(let id): - return { _ in dismissedKnowsTaskIDs.insert(id) } - case .insight(let id): - return { reason in - guard let recommendation = intelligenceStore.recommendations.first(where: { $0.id == id }) - else { return } - Task { await intelligenceStore.dismiss(recommendation, reason: reason) } - } - case .question: - return nil - } - } - - private func knowsLaterHandler(for row: HomeKnowsRow) -> (() -> Void)? { - guard case .insight(let id) = row.kind else { return nil } - return { - guard let recommendation = intelligenceStore.recommendations.first(where: { $0.id == id }) - else { return } - Task { await intelligenceStore.later(recommendation) } - } - } - - // MARK: Inline chat panel - - private func homeChatPanel(width: CGFloat) -> some View { - VStack(spacing: 0) { - ChatMessagesView( - messages: chatProvider.messages, - conversationIdentity: chatProvider.currentSessionId ?? ChatConversationIdentity.mainChatDefault, - isSending: chatProvider.isSending, - hasMoreMessages: chatProvider.hasMoreMessages, - isLoadingMoreMessages: chatProvider.isLoadingMoreMessages, - isLoadingInitial: chatProvider.isLoading && !chatProvider.isClearing, - app: selectedApp, - onLoadMore: { await chatProvider.loadMoreMessages() }, - onRate: { messageId, rating in - Task { await chatProvider.rateMessage(messageId, rating: rating) } - }, - onCitationTap: { citation in - handleCitationTap(citation) - }, - sessionsLoadError: chatProvider.sessionsLoadError.map { - UserFacingErrorPresentation.message(from: $0, while: .chatSessions) - }, - onRetry: { Task { await chatProvider.retryLoad() } }, - localSendToken: chatProvider.localSendToken, - onCancelTurn: { chatProvider.stopAgent(owner: .mainChat) }, - onOpenAgent: { agentID, completion in - FloatingControlBarManager.shared.openAgentChatFromTimeline(agentID: agentID, completion: completion) - }, - onOpenAgentRef: { ref, completion in - FloatingControlBarManager.shared.openAgentChatFromTimeline(ref: ref, completion: completion) - }, - horizontalContentPadding: 0, - chatFirstRichBlockContext: chatFirstRichBlockContext, - verticalContentPadding: OmiSpacing.sm, - trailingContentPadding: OmiSpacing.md, - welcomeContent: { dashboardChatWelcome } - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { reportChatFirstTranscriptPageIfReady() } - .onChange(of: chatProvider.isMainChatJournalFirstPageReady) { _, _ in - reportChatFirstTranscriptPageIfReady() - } - .onDisappear { - didReportChatFirstTranscriptPage = false - chatFirstRichBlockContext?.promptMaterializationCoordinator.chatTranscriptDidDisappear() - } - // The composer already has its own visual boundary. Masking this viewport - // fades the live edge and can cut off the first lines of an incoming reply. - .padding(.bottom, OmiSpacing.xs) - - } - // Chat is the Home surface itself — no card chrome, it sits directly on - // the ambient canvas. The column matches the ask bar's width exactly so - // message edges align with the bar's edges. - .frame(width: width) - } - - private func reportChatFirstTranscriptPageIfReady() { - guard !didReportChatFirstTranscriptPage, - chatFirstRichBlockContext != nil, - chatProvider.isMainChatJournalFirstPageReady - else { return } - didReportChatFirstTranscriptPage = true - chatFirstRichBlockContext?.promptMaterializationCoordinator.chatTranscriptFirstPageDidLoad() - } - - // MARK: Connect tray - - private func homeConnectPanel(stageWidth: CGFloat) -> some View { - // Sources feed omi; omi's memory flows out to the AI destinations — - // the chevron between the two cards reads that direction. The tray - // hugs its content: no scroll filler below the columns. - HStack(alignment: .center, spacing: OmiSpacing.md) { - homeConnectColumnCard { - VStack(alignment: .leading, spacing: OmiSpacing.md) { - sourceColumnHeader - sourceConstellation - } - } - - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.body, weight: .bold) - .foregroundStyle(HomePalette.secondary) - .frame(width: 30, height: 30) - .background(Circle().fill(HomePalette.tile)) - .overlay(Circle().stroke(HomePalette.hairline, lineWidth: 1)) - .accessibilityHidden(true) - - homeConnectColumnCard { - destinationStack - } - } - .padding(OmiSpacing.lg) - .background( - RoundedRectangle(cornerRadius: 28, style: .continuous) - .fill(Ink.rowFillHover) - ) - .overlay( - RoundedRectangle(cornerRadius: 28, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - .overlay(alignment: .topTrailing) { - HomeIconActionButton(title: "Close connect", systemImage: "xmark") { - collapseHomeStagePanel() - } - .padding(OmiSpacing.md) - } - .shadow(color: .black.opacity(0.12), radius: 20, y: 8) - .frame(width: homeStagePanelWidth(for: stageWidth)) - } - - private func homeConnectColumnCard(@ViewBuilder content: () -> Content) -> some View { - content() - .padding(OmiSpacing.lg) - .frame(maxWidth: .infinity, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - } - - // MARK: Ask bar + suggestions - - @ViewBuilder - private var dashboardChatErrorCard: some View { - if let cardState = chatProvider.currentError { - ChatErrorCard( - state: cardState, - onRecover: { - Task { await chatProvider.recoverFromError() } - }, - onDismiss: { - chatProvider.dismissCurrentError() - } - ) - } - } - - private var homeAskBar: some View { - ChatDraftScope(draft: chatProvider.composerDraft) { draft in - HomeAskBar( - text: draft, - isSending: chatProvider.isSending, - isStopping: chatProvider.isStopping, - isConnectActive: homeMode == .connect, - focus: $homeAskFieldFocused, - attachments: $chatProvider.pendingAttachments, - onAttachmentsAdded: { urls in - let toAdd = urls.compactMap { ChatAttachment.from(url: $0) } - chatProvider.addAttachments(toAdd) - }, - onAttachmentRemoved: { id in - chatProvider.removePendingAttachment(id: id) - }, - onSend: sendFromHomeAskBar, - onStop: { chatProvider.stopAgent(owner: .mainChat) }, - onConnect: toggleHomeConnectPanel, - // Tapping the bar begins a fresh chat and focuses it to type, staying on - // the hero; only sending enters the chat surface (see sendFromHomeAskBar). - onActivate: { focusHomeAskBar() } - ) - } - } - - private var homeSuggestedQuestions: [String] { - HomeSuggestionComposer.compose( - personalized: homeSuggestionsStore.personalizedQuestions, - onboarding: PostOnboardingPromptSuggestions.suggestions() - ) - } - - private func homeStageSideInset(for stageWidth: CGFloat) -> CGFloat { - min(Self.homeStageMaxSideInset, max(Self.homeStageMinSideInset, stageWidth * 0.06)) - } - - private func homeStageContentWidth(for stageWidth: CGFloat) -> CGFloat { - let sideInset = homeStageSideInset(for: stageWidth) - return min(Self.homeStageMaxWidth, max(CGFloat(0), stageWidth - (sideInset * 2))) - } - - private func homeStagePanelWidth(for stageWidth: CGFloat) -> CGFloat { - min(Self.homeStagePanelMaxWidth, homeStageContentWidth(for: stageWidth)) - } - - /// Chat mode: bar and message column share one readable width. Draft-independent. - private func homeChatColumnWidth(for stageWidth: CGFloat) -> CGFloat { - min(Self.homeChatColumnMaxWidth, homeStageContentWidth(for: stageWidth)) - } - - /// Hub mode: the resting bar grows to fit what has been typed. - private func homeHubAskBarWidth(for stageWidth: CGFloat, draft: String) -> CGFloat { - let availableWidth = min(Self.homeAskBarMaxWidth, homeStageContentWidth(for: stageWidth)) - let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return min(availableWidth, Self.homeAskBarMinWidth) } - let measuredTextWidth = (text as NSString).size(withAttributes: [.font: NSFont.systemFont(ofSize: 15)]).width - // Paperclip + mic + Send/Connect + the bar's own padding. The mic joined the - // leading cluster after this was first measured; a stale value here crops the - // typed text instead of growing the bar. - let chromeWidth: CGFloat = 252 - return min(availableWidth, max(Self.homeAskBarMinWidth, measuredTextWidth + chromeWidth)) - } - - // MARK: Stage actions - - private func reportHomeAutomationMode() { - guard DesktopAutomationLaunchOptions.isEnabled else { return } - let modeLabel = useLegacyHomeDesign ? nil : homeMode.automationLabel - _ = DesktopAutomationStateStore.shared.updateLiveFields { snapshot in - snapshot.homeMode = modeLabel - snapshot.updatedAt = ISO8601DateFormatter().string(from: Date()) - } - } - - /// Keep the useful insights hub visible while the canonical journal restores. - /// Once the atomic snapshot is ready, existing history becomes Home without - /// exposing the generic transcript loading spinner. - private func autoOpenChatForExistingHistoryIfNeeded() { - guard - HomeHistoryPresentationPolicy.restingMode( - isLoading: chatProvider.isLoading, - messageCount: chatProvider.messages.count - ) == .chat, - homeMode == .hub, - chatProvider.onboardingOpener == nil - else { return } - openHomeChat(focusInput: false) - } - - /// Floating-bar "Continue in Omi": land directly on the chat panel instead - /// of whatever surface Home was resting on. - private func consumePendingMainChatOpenRequest() { - guard MainChatNavigationRequestStore.shared.consume() else { return } - guard !useLegacyHomeDesign else { return } - openHomeChat() - } - private func openHomeChat(focusInput: Bool = true) { - if let onOpenPrimaryChat { - onOpenPrimaryChat() - return - } - if homeMode != .chat { - OmiMotion.withGated(Self.homeStageAnimation) { - homeMode = .chat - } - } - if focusInput { - focusHomeAskFieldAfterStageTransition() - } - reportHomeAutomationMode() - } - - private func focusHomeAskFieldAfterStageTransition() { - let token = homeAskFocusPolicy.currentToken() - Task { @MainActor in - await Task.yield() - // A deferred focus is stale once anything connects / collapses / closes - // (each bumps the policy's generation), and must never land on a non-chat - // stage — both would route back through the focus observer into chat. - guard homeAskFocusPolicy.isCurrent(token), homeMode == .chat else { return } - homeAskFieldFocused = true - } - } - - /// The surface Home rests on when no panel is explicitly open: the chat - /// timeline once any history exists, otherwise the greeting hub. - /// Home opens directly in the continuous chat (no greeting hero). Rolling - /// suggestions sit above the ask bar while the chat is empty. - private var homeRestingMode: HomeStageMode { - HomeHistoryPresentationPolicy.restingMode( - isLoading: chatProvider.isLoading, - messageCount: chatProvider.messages.count - ) - } - - /// User-facing collapse (click outside, Esc, connect ×) and the automation - /// bridge's `home_close_panel`: returns to the resting surface. There is a - /// single close path now — the bridge no longer force-jumps to the hub. - private func collapseHomeStagePanel() { - homeAskFieldFocused = false - homeAskFocusPolicy.invalidate() - OmiMotion.withGated(Self.homeStageAnimation) { - homeMode = homeRestingMode - } - reportHomeAutomationMode() - } - - private func toggleHomeConnectPanel() { - homeAskFocusPolicy.invalidate() - let target: HomeStageMode = homeMode == .connect ? homeRestingMode : .connect - if target == .connect { - homeAskFieldFocused = false - } - OmiMotion.withGated(Self.homeStageAnimation) { - homeMode = target - } - reportHomeAutomationMode() - } - - /// Omi is one continuous chat — tapping the ask bar just focuses it to type, - /// continuing the single thread (no new sessions, no history). - private func focusHomeAskBar() { - homeAskFieldFocused = true - } - - private func sendFromHomeAskBar() { - let draft = chatProvider.draftText - let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) - // Text is required — ChatProvider.sendMessage no-ops on empty text, so - // an attachment-only "send" would silently drop the turn. - guard !text.isEmpty else { return } - if let onOpenPrimaryChat { - onOpenPrimaryChat() - guard !chatProvider.isSending else { return } - Task { - await chatProvider.sendMainDraft( - draft, - onAccepted: { - AnalyticsManager.shared.chatMessageSent( - messageLength: text.count, - hasSelectedAppContext: selectedApp != nil, - source: "home_ask_bar" - ) - }) - } - return - } - openHomeChat(focusInput: false) - if !chatProvider.isSending { - Task { - await chatProvider.sendMainDraft( - draft, - onAccepted: { - AnalyticsManager.shared.chatMessageSent( - messageLength: text.count, - hasSelectedAppContext: selectedApp != nil, - source: "home_ask_bar" - ) - }) - } - } - } - - private func askHomeSuggestion(_ suggestion: String) { - if let onOpenPrimaryChat { - onOpenPrimaryChat() - guard !chatProvider.isSending else { return } - Task { - _ = await chatProvider.sendMessage( - suggestion, - onAccepted: { - AnalyticsManager.shared.chatMessageSent( - messageLength: suggestion.count, - hasSelectedAppContext: selectedApp != nil, - source: "home_suggested_question" - ) - }) - } - return - } - openHomeChat(focusInput: false) - Task { - _ = await chatProvider.sendMessage( - suggestion, - onAccepted: { - AnalyticsManager.shared.chatMessageSent( - messageLength: suggestion.count, - hasSelectedAppContext: selectedApp != nil, - source: "home_suggested_question" - ) - }) - } - } - - @ViewBuilder - private func homeConnectSheetOverlay( - contentWidth: CGFloat, - panelWidth: CGFloat, - panelHeight: CGFloat, - panelTop: CGFloat - ) -> some View { - ZStack { - if homeConnectSheetIsPresented { - // Same lane as the apps popup above it, for the same reason. - ShellModalScrim(onTap: dismissHomeConnectSheet) - .transition(.opacity) - .zIndex(4) - - let sheetSize = homeConnectSheetSize(panelWidth: panelWidth, panelHeight: panelHeight) - - homeConnectSheetContent() - .frame(width: sheetSize.width, height: sheetSize.height) - // Same as the apps popup: a bounded card is its own surface. - .shellModalScrimBounds(.ownSurface) - .background(Ink.surface) - .clipShape(RoundedRectangle(cornerRadius: Self.homeConnectSheetCornerRadius, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Self.homeConnectSheetCornerRadius, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - .shadow(color: .black.opacity(0.12), radius: 20, y: 8) - .position(x: contentWidth / 2, y: panelTop + panelHeight / 2) - .transition(.scale(scale: 0.96).combined(with: .opacity)) - .accessibilityAddTraits(.isModal) - .zIndex(5) - - if homeConnectSheetAcceptsInput { - OverlayModalEscapeCatcher { - dismissHomeConnectSheet() - } - .zIndex(5) - } - } - } - .allowsHitTesting(homeConnectSheetAcceptsInput) - .zIndex(4) - } - - private func homeConnectSheetSize(panelWidth: CGFloat, panelHeight: CGFloat) -> CGSize { - let preferred = homeConnectSheetPreferredSize - return CGSize( - width: min( - preferred.width, - max(Self.homeConnectSheetMinWidth, panelWidth - (Self.homeConnectSheetHorizontalMargin * 2)) - ), - height: min( - preferred.height, - max(Self.homeConnectSheetMinHeight, panelHeight - (Self.homeConnectSheetVerticalMargin * 2)) - ) - ) - } - - private var homeConnectSheetPreferredSize: CGSize { - if selectedCatalogApp != nil { - return Self.appDetailSheetPreferredSize - } - if selectedImportConnector != nil { - return Self.importConnectorSheetPreferredSize - } - return Self.exportDestinationSheetPreferredSize - } - - @ViewBuilder - private func homeConnectSheetContent() -> some View { - if let app = selectedCatalogApp { - AppDetailSheet(app: app, appProvider: appProvider, onDismiss: { dismissHomeConnectSheet() }) - .onAppear { - AnalyticsManager.shared.appDetailViewed(appId: app.id, appName: app.name) - } - } else if let connector = selectedImportConnector { - ImportConnectorSheet( - connector: connector, - appState: appState, - statusStore: homeStatusStore.connectorStatusStore, - onDismiss: { - dismissHomeConnectSheet() - } - ) - } else if let destination = selectedExportDestination { - ConnectDestinationSheet( - destination: destination, - statuses: $homeStatusStore.memoryExportStatuses, - onDismiss: { - dismissHomeConnectSheet() - } - ) - } - } - - private var homeHeader: some View { - let transcriptionUnavailable = appState.transcriptionServiceError != nil - - return HStack { - Spacer() - HStack(spacing: OmiSpacing.sm) { - HomeStatusButton( - title: "Capture", - systemImage: "viewfinder", - status: captureStatus, - isToggling: isTogglingCapture, - action: toggleCapture - ) - // Rewind isn't a top-level tab; it opens from a right-click on Capture. - .contextMenu { - Button { - navigate(to: .rewind) - } label: { - Label("Open Rewind", systemImage: "clock.arrow.circlepath") - } - } - - HomeListeningStatusButton( - title: transcriptionUnavailable ? "Transcription unavailable" : "Listening", - systemImage: transcriptionUnavailable - ? "exclamationmark.triangle.fill" - : (appState.isLiveCapturing ? "waveform.circle.fill" : "mic.circle"), - status: CaptureListeningLogic.listeningStatus(appState: appState), - modeTitle: listeningModeTitle, - isAwaitingMeeting: appState.isAwaitingMeeting, - isToggling: isTogglingListening, - action: toggleListening - ) - // Settings lives in the nav rail (bottom-left) — no duplicate gear here. - } - } - .frame(height: 36) - } - - private var sourceColumnHeader: some View { - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text("Connect data") - .font(.system(size: 20, weight: .medium, design: .serif)) - .foregroundStyle(HomePalette.ink) - - Text("Sources Omi learns from.") - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - } - - private var sourceConstellation: some View { - VStack(alignment: .leading, spacing: OmiSpacing.md) { - HomeAIChoiceButton(title: "Gmail", brand: .gmail, isConnected: isImportConnectorConnected("email")) { - openImportConnector("email") - } - HomeAIChoiceButton(title: "Calendar", brand: .calendar, isConnected: isImportConnectorConnected("calendar")) { - openImportConnector("calendar") - } - HomeAIChoiceButton(title: "Files", brand: .localFiles, isConnected: isImportConnectorConnected("local-files")) { - openImportConnector("local-files") - } - HomeAIChoiceButton(title: "Notes", brand: .appleNotes, isConnected: isImportConnectorConnected("apple-notes")) { - openImportConnector("apple-notes") - } - HomeAIChoiceButton(title: "Omi Device", usesOmiMark: true, isConnected: hasOmiDeviceHistory) { - openOmiDeviceWebsite() - } - HomeAIChoiceButton(title: "More", systemImage: "plus") { - openAppsPage() - } - } - } - - private var destinationStack: some View { - VStack(alignment: .leading, spacing: OmiSpacing.md) { - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text("Use omi memory anywhere") - .font(.system(size: 20, weight: .medium, design: .serif)) - .foregroundStyle(HomePalette.ink) - - Text("Bring your memories to the apps you use") - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.muted) - .fixedSize(horizontal: false, vertical: true) - } - - HomeAIChoiceButton(title: "Ask Omi", usesOmiMark: true) { - openHomeChat() - } - HomeAIChoiceButton(title: "Claude / Claude Code", brand: .claude, isConnected: isMCPDestinationConnected(.claude)) - { - openExportDestination(.claudeCode) - } - HomeAIChoiceButton(title: "ChatGPT / Codex", brand: .chatgpt, isConnected: isMCPDestinationConnected(.chatgpt)) { - openExportDestination(.chatgpt) - } - HomeAIChoiceButton(title: "OpenClaw", brand: .openclaw, isConnected: isMCPDestinationConnected(.openclaw)) { - openExportDestination(.openclaw) - } - HomeAIChoiceButton(title: "Hermes", brand: .hermes, isConnected: isMCPDestinationConnected(.hermes)) { - openExportDestination(.hermes) - } - HomeAIChoiceButton(title: "More", systemImage: "plus") { - openAppsPage() - } - } - } - - private func navigate(to item: SidebarNavItem) { - selectedIndex = item.rawValue - AnalyticsManager.shared.tabChanged(tabName: item.title) - } - - private func openAppsPage() { - // The Apps page is the sole catalog owner. Contextual "More" actions clear - // stale filters, then navigate there instead of mounting a bounded copy. - appProvider.clearFilters() - navigate(to: .apps) - } - - private func openImportConnector(_ connectorID: String) { - if let connector = ImportConnector.all.first(where: { $0.id == connectorID }) { - presentImportConnector(connector) - } - } - - private func openExportDestination(_ destination: MemoryExportDestination) { - presentExportDestination(destination) - } - - private func presentCatalogApp(_ app: OmiApp) { - homeConnectSheetAcceptsInput = true - selectedImportConnector = nil - selectedExportDestination = nil - selectedCatalogApp = app - } - - private func presentImportConnector(_ connector: ImportConnector) { - homeConnectSheetAcceptsInput = true - selectedCatalogApp = nil - selectedExportDestination = nil - selectedImportConnector = connector - } - - private func presentExportDestination(_ destination: MemoryExportDestination) { - homeConnectSheetAcceptsInput = true - selectedCatalogApp = nil - selectedImportConnector = nil - selectedExportDestination = destination - } - - private func dismissHomeConnectSheet() { - homeConnectSheetAcceptsInput = false - selectedCatalogApp = nil - selectedImportConnector = nil - selectedExportDestination = nil - } - - private func openOmiDeviceWebsite() { - if let url = URL(string: "https://www.omi.me") { - NSWorkspace.shared.open(url) - } - } - - private func toggleListening() { - CaptureListeningLogic.cycleListening( - appState: appState, audioRecordingModeRaw: $audioRecordingModeRaw, - isTogglingListening: $isTogglingListening) - } - - private func toggleCapture() { - CaptureListeningLogic.toggleCapture( - appState: appState, screenAnalysisEnabled: $screenAnalysisEnabled, - isCaptureMonitoring: $isCaptureMonitoring, isTogglingCapture: $isTogglingCapture) - } - - private func syncCaptureState() { - CaptureListeningLogic.syncCaptureState( - screenAnalysisEnabled: $screenAnalysisEnabled, isCaptureMonitoring: $isCaptureMonitoring) - } - - /// Welcome message shown when there are no chat messages yet. - /// Transparent — no card chrome — so it morphs into the dashboard background. - /// Empty-state of the Home chat: the personalized post-onboarding opener when - /// one is pending (this is where onboarding lands the user), else the default - /// "Ask omi anything" welcome. - @ViewBuilder private var dashboardChatWelcome: some View { - if let opener = chatProvider.onboardingOpener { - OnboardingOpenerView(opener: opener, chatProvider: chatProvider) - } else { - defaultChatWelcome - } - } - - private var defaultChatWelcome: some View { - VStack(spacing: OmiSpacing.md) { - if let logoURL = Bundle.resourceBundle.url(forResource: "herologo", withExtension: "png"), - let logoImage = NSImage(contentsOf: logoURL) - { - Image(nsImage: logoImage) - .resizable() - .scaledToFit() - .frame(width: 40, height: 40) - } - - Text("Ask omi anything") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - - Text("Your personal AI assistant — knows you through your memories and conversations") - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, OmiSpacing.page) - } - .frame(maxWidth: .infinity) - .padding(.vertical, OmiSpacing.section) - } - - /// Conversation citations use the same root handoff as every other source. - /// The Memory hub owns the only conversation browser/detail presentation. - private func handleCitationTap(_ citation: Citation) { - guard citation.sourceType == .conversation else { - log("Citation tapped: \(citation.title) (memory - no detail view)") - return - } - - ConversationDetailAutomationState.shared.requestOpen( - conversationId: citation.id, - showTranscript: false - ) - NotificationCenter.default.post(name: .desktopAutomationOpenConversationRequested, object: nil) - } - - private func openRecommendation(_ recommendation: DashboardRecommendation) async -> Bool { - switch recommendation.destination { - case .suggested(let candidateID): - guard let candidate = await intelligenceStore.candidateForNavigation(candidateID: candidateID) else { - return false - } - TaskNavigationRequestStore.shared.request(candidate: candidate) - selectedIndex = 4 - return true - case .task(let taskID, let workstreamID): - if let workstreamID { - return await resumeThread(workstreamID: workstreamID, taskID: taskID) - } else { - guard let task = await intelligenceStore.taskForNavigation(taskID: taskID) else { - return false - } - TaskNavigationRequestStore.shared.request(task: task) - selectedIndex = 4 - return true - } - case .thread(let workstreamID, let taskID): - return await resumeThread(workstreamID: workstreamID, taskID: taskID) - case .unavailable: - intelligenceStore.error = "This review target is no longer available." - return false - } - } - - private func openGoal(_ goalID: String) async { - await intelligenceStore.loadGoalDetail(goalID: goalID) - guard intelligenceStore.selectedGoalDetail != nil else { return } - showingAllGoals = false - showingGoalDetail = true - } - - @discardableResult - private func resumeThread(workstreamID: String, taskID: String?) async -> Bool { - guard let taskChatCoordinator else { - intelligenceStore.error = "The task thread is unavailable." - return false - } - if await taskChatCoordinator.openExistingThread( - workstreamID: workstreamID, - preferredTaskID: taskID - ) { - showingGoalDetail = false - showingAllGoals = false - selectedIndex = 4 - return true - } else { - intelligenceStore.error = taskChatCoordinator.errorMessage ?? "The task thread could not be opened." - return false - } - } - - private func startWorkFromSelectedGoal() async { - guard let detail = intelligenceStore.selectedGoalDetail, let taskChatCoordinator else { - intelligenceStore.error = "The goal thread is unavailable." - return - } - do { - let receipt = try await taskChatCoordinator.resolveGoalOrigin( - goalId: detail.goal.goalId, - occurrenceId: "goal-detail-primary-v1", - title: detail.goal.title, - objective: detail.goal.desiredOutcome, - anchorTaskDescription: "Make progress on \(detail.goal.title)" - ) - await resumeThread(workstreamID: receipt.workstreamId, taskID: receipt.taskId) - } catch { - intelligenceStore.error = "Omi could not start work on this goal." - } - } - - // MARK: - Summary counts for collapsed bar - - private var incompleteTaskCount: Int { - viewModel.overdueTasks.count + viewModel.todaysTasks.count + viewModel.recentTasks.count - } - - private var activeGoalCount: Int { - intelligenceStore.accountGeneration == nil - ? viewModel.goals.count - : intelligenceStore.currentGoals.count - } - - // MARK: - Dashboard Widgets (collapsible) - - private var dashboardWidgets: some View { - VStack(alignment: .leading, spacing: widgetsCollapsed ? 0 : OmiSpacing.xl) { - if shouldShowSuggestionBanner { - PromptSuggestionBanner( - suggestions: postOnboardingSuggestions, - onOpen: { - dismissSuggestionBanner() - NotificationCenter.default.post(name: .showTryAskingPopup, object: nil) - }, - onAsk: handleSuggestedPrompt, - onDismiss: dismissSuggestionBanner - ) - } - - dashboardIntelligenceError - - FocusedGoalsSection( - store: intelligenceStore, - onOpenGoal: { goalID in await openGoal(goalID) }, - onShowAll: { showingAllGoals = true } - ) - - if widgetsCollapsed { - // Collapsed: slim summary bar - collapsedWidgetBar - } else { - // Expanded: full Tasks + Goals cards - expandedWidgets - - // Collapse button centered below widgets - collapseButton - } - } - .padding(.horizontal, OmiSpacing.section) - .padding(.top, widgetsCollapsed ? OmiSpacing.xl : OmiSpacing.section) - .padding(.bottom, OmiSpacing.sm) - .omiAnimation(.easeInOut(duration: 0.25), value: widgetsCollapsed) - } - - @ViewBuilder - private var dashboardIntelligenceError: some View { - if let error = intelligenceStore.error, !error.isEmpty { - HStack(spacing: OmiSpacing.sm) { - Image(systemName: "exclamationmark.triangle.fill") - .scaledFont(size: OmiType.caption) - .foregroundColor(PageGlass.warning) - Text(error) - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - Spacer(minLength: OmiSpacing.sm) - Button("Retry") { - Task { await intelligenceStore.load() } - } - .buttonStyle(.plain) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundColor(Ink.primary) - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(Ink.rowFillHover) - ) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - .accessibilityIdentifier("dashboard-intelligence-error") - } - } - - private var collapsedWidgetBar: some View { - Button(action: { widgetsCollapsed = false }) { - HStack(spacing: OmiSpacing.lg) { - // Tasks summary - HStack(spacing: OmiSpacing.xs) { - Image(systemName: "checklist") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - Text( - incompleteTaskCount == 0 - ? "No tasks" - : "\(incompleteTaskCount) task\(incompleteTaskCount == 1 ? "" : "s")" - ) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.secondary) - } - - // Subtle divider dot - Circle() - .fill(Ink.secondary) - .frame(width: 3, height: 3) - - // Goals summary - HStack(spacing: OmiSpacing.xs) { - Image(systemName: "target") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - Text( - activeGoalCount == 0 - ? "No goals" - : "\(activeGoalCount) goal\(activeGoalCount == 1 ? "" : "s")" - ) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.secondary) - } - - Spacer() - - // Expand chevron - Image(systemName: "chevron.down") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundColor(Ink.secondary) - } - .padding(.horizontal, OmiSpacing.lg) - .padding(.vertical, OmiSpacing.md) - .background( - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .fill(Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - } - .buttonStyle(.plain) - .transition(.opacity.combined(with: .move(edge: .top))) - } - - private var expandedWidgets: some View { - // fixedSize(vertical:) constrains the Grid to its row's intrinsic - // height so Tasks/Goals stop competing with ChatMessagesView for - // vertical space; each cell still fills the row, so the two cards - // remain visually equal-height (matching the taller intrinsic). - Grid(horizontalSpacing: OmiSpacing.xl, verticalSpacing: OmiSpacing.xl) { - GridRow { - TasksWidget( - overdueTasks: viewModel.overdueTasks, - todaysTasks: viewModel.todaysTasks, - recentTasks: viewModel.recentTasks, - onToggleCompletion: { task in - Task { - await viewModel.toggleTaskCompletion(task) - } - } - ) - .frame(minWidth: 0, maxWidth: .infinity) - - if intelligenceStore.accountGeneration != nil { - canonicalGoalsWidget - } else { - GoalsWidget( - goals: viewModel.goals, - onCreateGoal: { title, current, target in - Task { - await viewModel.createGoal( - title: title, - goalType: .numeric, - targetValue: target, - unit: nil - ) - } - }, - onUpdateGoal: { goal, title, current, target in - Task { - await viewModel.updateGoal( - goal, - title: title, - currentValue: current, - targetValue: target - ) - } - }, - onUpdateProgress: { goal, value in - Task { await viewModel.updateGoalProgress(goal, currentValue: value) } - }, - onDeleteGoal: { goal in - Task { await viewModel.deleteGoal(goal) } - } - ) - .frame(minWidth: 0, maxWidth: .infinity) - } - } - } - .fixedSize(horizontal: false, vertical: true) - .transition(.opacity.combined(with: .move(edge: .top))) - } - - private var canonicalGoalsWidget: some View { - VStack(alignment: .leading, spacing: OmiSpacing.md) { - HStack { - Text("Goals") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - Spacer() - Button("All goals") { showingAllGoals = true } - .buttonStyle(.plain) - .scaledFont(size: OmiType.micro, weight: .medium) - } - FocusedGoalsSection( - store: intelligenceStore, - onOpenGoal: { goalID in await openGoal(goalID) }, - onShowAll: { showingAllGoals = true } - ) - if intelligenceStore.focusedGoals.isEmpty { - Text("Keep a few outcomes in focus.") - .scaledFont(size: OmiType.caption) - .foregroundColor(Ink.secondary) - } - Spacer(minLength: 0) - } - .padding(OmiSpacing.lg) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 150, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(Ink.rowFill) - ) - } - - private var collapseButton: some View { - HStack { - Spacer() - Button(action: { widgetsCollapsed = true }) { - Image(systemName: "chevron.up") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundColor(Ink.secondary) - .frame(width: 48, height: 20) - } - .buttonStyle(.plain) - Spacer() - } - } - - private var postOnboardingSuggestions: [String] { - PostOnboardingPromptSuggestions.suggestions() - } - - private var shouldShowSuggestionBanner: Bool { - !routesChatToPrimaryShell && !postOnboardingSuggestions.isEmpty - && !PostOnboardingPromptSuggestions.isDismissed - } - - private func dismissSuggestionBanner() { - PostOnboardingPromptSuggestions.consume() - } - - private func handleSuggestedPrompt(_ suggestion: String) { - PostOnboardingPromptSuggestions.consume() - FloatingControlBarManager.shared.openAIInputWithQuery(suggestion) - } - -} - -// MARK: - Home Components - -private enum HomeRowStatus { - case connect - case connected - case open -} - -private enum HomeDestinationProminence { - case primary - case quiet -} - -/// The persistent home ask bar: a pill-shaped chat input with attachments -/// (paperclip + drag-drop, same limits as the chat page), a send/stop action, -/// and the Connect toggle living inside the pill. -struct HomeAskBar: View { - @Binding var text: String - let isSending: Bool - let isStopping: Bool - let isConnectActive: Bool - var focus: FocusState.Binding - @Binding var attachments: [ChatAttachment] - let onAttachmentsAdded: ([URL]) -> Void - let onAttachmentRemoved: (String) -> Void - let onSend: () -> Void - let onStop: () -> Void - let onConnect: () -> Void - let onActivate: () -> Void - - @State private var isHovering = false - @State private var isDropTargeted = false - - private var hasText: Bool { - !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - /// Requires text: ChatProvider.sendMessage drops empty-text sends, so - /// presenting attachment-only as sendable would silently do nothing. - /// Staged files ride along with the typed message instead. - private var canSend: Bool { - hasText - } - - private var isFocused: Bool { focus.wrappedValue } - - var body: some View { - VStack(spacing: OmiSpacing.sm) { - if !attachments.isEmpty { - AttachmentPreviewRow( - attachments: attachments, - onRemove: onAttachmentRemoved - ) - .padding(.top, OmiSpacing.sm) - .padding(.horizontal, OmiSpacing.md) - } - - HStack(alignment: .bottom, spacing: OmiSpacing.sm) { - Button(action: pickFiles) { - Image(systemName: "paperclip") - .scaledFont(size: OmiType.subheading, weight: .medium) - .foregroundStyle(isFocused ? HomePalette.secondary : HomePalette.muted) - .frame(width: 24, height: 34) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(attachments.count >= kMaxChatAttachments) - .help("Attach files") - - // Same trigger the composer and floating bar already click, so Home - // enters the one PushToTalkManager turn instead of a second mic path. - PushToTalkMicButton(diameter: 34) - - // Auto-growing input: `axis: .vertical` + `lineLimit(1...6)` grow the pill - // as text wraps (scrolls past six lines). Return submits, Shift+Return - // newlines — via onKeyPress, since a vertical field would otherwise insert - // a newline on Return and never fire onSubmit. - TextField( - "", - text: $text, - prompt: Text("Ask omi anything").foregroundColor(HomePalette.muted), - axis: .vertical - ) - .textFieldStyle(.plain) - .font(.system(size: 15)) - .foregroundStyle(HomePalette.ink) - .lineLimit(1...6) - .focused(focus) - .padding(.vertical, 7) - .onKeyPress(phases: .down) { press in - guard press.key == .return else { return .ignored } - // Shift+Return falls through to the field's newline handling. - if press.modifiers.contains(.shift) { return .ignored } - handleSubmit() - return .handled - } - - HomeAskBarTrailingControls( - controls: HomeAskBarControls.resolve( - isSending: isSending, isStopping: isStopping, hasText: hasText, isFocused: isFocused), - isConnectActive: isConnectActive, - onSend: handleSubmit, - onStop: onStop, - onConnect: onConnect - ) - } - .padding(.leading, OmiSpacing.lg) - .padding(.trailing, OmiSpacing.sm) - .padding(.vertical, 12) - .frame(minHeight: 58) - } - .background( - RoundedRectangle(cornerRadius: 29, style: .continuous) - .fill(HomeAskBarPalette.wellFill(isEngaged: isHovering || isFocused)) - ) - .overlay { - RoundedRectangle(cornerRadius: 29, style: .continuous) - .stroke( - HomeAskBarPalette.wellStroke(isFocused: isFocused, isDropTargeted: isDropTargeted), - lineWidth: isDropTargeted ? 1.5 : 1) - } - // Keep the composer visually separate without casting a large, opaque bezel - // into the transcript. These are intentionally only 10% of the old shadow. - .shadow(color: .black.opacity(isFocused ? 0.045 : 0.034), radius: 2.4, y: 1) - .contentShape(.rect(cornerRadius: 29)) - .onTapGesture { - onActivate() - focus.wrappedValue = true - } - .onHover { isHovering = $0 } - .onDrop(of: [UTType.fileURL], isTargeted: $isDropTargeted, perform: handleDrop) - .omiAnimation(.easeOut(duration: 0.16), value: isFocused) - .omiAnimation(.easeOut(duration: 0.16), value: canSend) - .omiAnimation(.easeOut(duration: 0.16), value: attachments.count) - } - - private func pickFiles() { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.canChooseDirectories = false - panel.allowsMultipleSelection = true - panel.allowedContentTypes = [ - .image, .jpeg, .png, .gif, .heic, .heif, .webP, .tiff, .bmp, - .pdf, .plainText, .json, .commaSeparatedText, .html, - .text, .content, - ] - if panel.runModal() == .OK { - let remaining = max(0, kMaxChatAttachments - attachments.count) - let urls = Array(panel.urls.prefix(remaining)) - if !urls.isEmpty { - onAttachmentsAdded(urls) - } - } - } - - private func handleDrop(providers: [NSItemProvider]) -> Bool { - ChatAttachmentDropHandler.collectURLs(from: providers) { [attachments] urls in - guard !urls.isEmpty else { return } - let remaining = max(0, kMaxChatAttachments - attachments.count) - let allowed = Array(urls.prefix(remaining)) - if !allowed.isEmpty { - onAttachmentsAdded(allowed) - } - } - } - - private func handleSubmit() { - if isSending { - onStop() - } else if canSend { - onSend() - } - } - -} - -/// One knows-list row: leading kind icon, single-line text, and either a -/// dismiss × (task/insight) or an ask ↗ (question) on the trailing edge. -private struct HomeKnowsRowView: View { - let row: HomeKnowsRow - let onOpen: () -> Void - let onDismiss: ((OmiAPI.TaskIntelligenceFeedbackReason?) -> Void)? - let onLater: (() -> Void)? - - @State private var isHovering = false - @State private var showDismissReasons = false - @State private var choseReason = false - - private var leadingIcon: String { - switch row.kind { - case .task: return "circle" - case .insight: return ProactiveNotificationBadge.insightSystemImage - case .question: return "bubble.left" - } - } - - var body: some View { - Button(action: onOpen) { - HStack(spacing: OmiSpacing.md) { - Image(systemName: leadingIcon) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundStyle(isHovering ? HomePalette.secondary : HomePalette.muted) - .frame(width: 18) - - Text(row.text) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundStyle(isHovering ? HomePalette.ink : HomePalette.secondary) - .lineLimit(1) - - Spacer(minLength: 8) - - trailingAccessory - } - .padding(.horizontal, OmiSpacing.lg) - .frame(height: 46) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .stroke(isHovering ? Ink.hairline : Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 13)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .contextMenu { - if let onLater { - Button("Later") { onLater() } - } - if onDismiss != nil { - Button("Dismiss") { handleDismissTap() } - } - } - .accessibilityLabel(row.text) - .accessibilityIdentifier("home-knows-\(row.id)") - } - - @ViewBuilder - private var trailingAccessory: some View { - if onDismiss != nil { - Button(action: handleDismissTap) { - Image(systemName: "xmark") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(isHovering ? HomePalette.secondary : HomePalette.faint) - .frame(width: 20, height: 20) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help("Dismiss") - .accessibilityLabel("Dismiss") - .popover(isPresented: $showDismissReasons) { - VStack(alignment: .leading, spacing: OmiSpacing.sm) { - Text("Optional reason") - .scaledFont(size: OmiType.caption, weight: .semibold) - ForEach(Self.reasonChoices, id: \.label) { choice in - Button(choice.label) { - choseReason = true - onDismiss?(choice.reason) - showDismissReasons = false - } - .buttonStyle(.bordered) - } - } - .padding(OmiSpacing.md) - .frame(width: 210) - } - .onChange(of: showDismissReasons) { wasShowing, isShowing in - guard wasShowing, !isShowing, !choseReason else { return } - onDismiss?(nil) - } - } else { - Image(systemName: "arrow.up.right") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(isHovering ? HomePalette.ink : HomePalette.faint) - } - } - - /// Insight dismissals offer the same optional feedback reasons the old - /// What-matters-now cards recorded; task rows just hide for the session. - private func handleDismissTap() { - if case .insight = row.kind { - choseReason = false - showDismissReasons = true - } else { - onDismiss?(nil) - } - } - - private static let reasonChoices: [(label: String, reason: OmiAPI.TaskIntelligenceFeedbackReason)] = [ - ("Already handled", .already_handled), - ("Not mine", .not_mine), - ("Not useful", .not_useful), - ] -} - -private struct HomePrimaryRouteButton: View { - let title: String - let brand: ConnectorBrand - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.sm) { - ConnectorBrandIcon(brand: brand, size: 20, cornerRadius: OmiChrome.badgeRadius) - - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .lineLimit(1) - } - .foregroundStyle(HomeAskBarPalette.primaryLabel) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .frame(minWidth: 118) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(HomeAskBarPalette.primaryFill.opacity(isHovering ? 0.88 : 1)) - ) - .shadow(color: .black.opacity(isHovering ? 0.12 : 0.08), radius: 10, y: 4) - .contentShape(.rect(cornerRadius: OmiChrome.smallControlRadius)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("Connect \(title)") - } -} - -private struct HomeInlineAction: View { - let title: String - let brand: ConnectorBrand? - let systemImage: String? - let action: () -> Void - - @State private var isHovering = false - - init(title: String, brand: ConnectorBrand, action: @escaping () -> Void) { - self.title = title - self.brand = brand - self.systemImage = nil - self.action = action - } - - init(title: String, systemImage: String, action: @escaping () -> Void) { - self.title = title - self.brand = nil - self.systemImage = systemImage - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.xs) { - icon - - Text(title) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - .lineLimit(1) - - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(HomePalette.faint) - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .background( - Capsule(style: .continuous) - .fill(isHovering ? HomePalette.tileHover : Ink.rowFill) - ) - .overlay( - Capsule(style: .continuous) - .stroke(isHovering ? HomePalette.green.opacity(0.3) : Ink.separator, lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - } - - @ViewBuilder - private var icon: some View { - if let brand { - ConnectorBrandIcon(brand: brand, size: 18, cornerRadius: OmiChrome.badgeRadius) - } else if let systemImage { - Image(systemName: systemImage) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - .frame(width: 18, height: 18) - } - } -} - -private struct HomeSourceIconTile: View { - let title: String - let brand: ConnectorBrand? - let systemImage: String? - let usesOmiDeviceImage: Bool - let isConnected: Bool - let isBrowse: Bool - let action: () -> Void - - @State private var isHovering = false - - init( - title: String, - brand: ConnectorBrand, - isConnected: Bool = false, - action: @escaping () -> Void - ) { - self.title = title - self.brand = brand - self.systemImage = nil - self.usesOmiDeviceImage = false - self.isConnected = isConnected - self.isBrowse = false - self.action = action - } - - init( - title: String, - systemImage: String, - isBrowse: Bool = false, - action: @escaping () -> Void - ) { - self.title = title - self.brand = nil - self.systemImage = systemImage - self.usesOmiDeviceImage = false - self.isConnected = false - self.isBrowse = isBrowse - self.action = action - } - - init( - title: String, - usesOmiDeviceImage: Bool, - isConnected: Bool = false, - action: @escaping () -> Void - ) { - self.title = title - self.brand = nil - self.systemImage = nil - self.usesOmiDeviceImage = usesOmiDeviceImage - self.isConnected = isConnected - self.isBrowse = false - self.action = action - } - - var body: some View { - Button(action: action) { - VStack(spacing: OmiSpacing.sm) { - ZStack(alignment: .topTrailing) { - icon - - if isConnected { - Circle() - .fill(HomePalette.green) - .frame(width: 9, height: 9) - .overlay(Circle().stroke(HomePalette.tile, lineWidth: 2)) - .offset(x: 2, y: -2) - } - } - - HStack(spacing: OmiSpacing.xxs) { - Text(title) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - .minimumScaleFactor(0.72) - - if isBrowse { - Image(systemName: "chevron.right") - .scaledFont(size: 8, weight: .bold) - .foregroundStyle(HomePalette.faint) - } - } - } - .frame(maxWidth: .infinity) - .frame(height: 92) - .background( - RoundedRectangle(cornerRadius: 17, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) - ) - .overlay( - RoundedRectangle(cornerRadius: 17, style: .continuous) - .stroke(isHovering ? Ink.hairline : Ink.separator, lineWidth: 1) - ) - .shadow(color: .black.opacity(isHovering ? 0.10 : 0), radius: 12, y: 4) - .contentShape(.rect(cornerRadius: 17)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel(title) - } - - @ViewBuilder - private var icon: some View { - if usesOmiDeviceImage { - HomeOmiDeviceIcon(size: 42, cornerRadius: OmiChrome.smallControlRadius) - } else if let brand { - ConnectorBrandIcon(brand: brand, size: 42, cornerRadius: OmiChrome.smallControlRadius) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(Ink.rowFill) - Image(systemName: systemImage) - .scaledFont(size: 19, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - } - .frame(width: 42, height: 42) - } - } -} - -private struct HomeOmiDeviceIcon: View { - let size: CGFloat - let cornerRadius: CGFloat - - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Ink.rowFill) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - - if let deviceImage = OmiDeviceImage.shared { - Image(nsImage: deviceImage) - .resizable() - .interpolation(.high) - .aspectRatio(contentMode: .fit) - .padding(size * 0.16) - } else { - Image(systemName: "wave.3.right.circle.fill") - .scaledFont(size: size * 0.45, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - } - } - .frame(width: size, height: size) - } -} - -private struct HomeDataSourceCard: View { - let title: String - let subtitle: String - let brand: ConnectorBrand? - let systemImage: String? - let actionTitle: String - let isConnected: Bool - let action: () -> Void - - @State private var isHovering = false - - init( - title: String, - subtitle: String, - brand: ConnectorBrand, - actionTitle: String, - isConnected: Bool = false, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = brand - self.systemImage = nil - self.actionTitle = actionTitle - self.isConnected = isConnected - self.action = action - } - - init( - title: String, - subtitle: String, - systemImage: String, - actionTitle: String, - isConnected: Bool = false, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = nil - self.systemImage = systemImage - self.actionTitle = actionTitle - self.isConnected = isConnected - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.md) { - icon - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - - Spacer(minLength: 10) - - HStack(spacing: OmiSpacing.xxs) { - if isConnected { - Circle() - .fill(HomePalette.green) - .frame(width: 5, height: 5) - } - - Text(actionTitle) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(isConnected ? HomePalette.green : HomePalette.secondary) - .lineLimit(1) - - if !isConnected && actionTitle == "Browse" { - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(HomePalette.faint) - } - } - .fixedSize(horizontal: true, vertical: false) - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - .frame(height: 64) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) - ) - .overlay( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .stroke(isHovering ? Ink.hairline : Ink.separator, lineWidth: 1) - ) - .shadow(color: .black.opacity(isHovering ? 0.08 : 0), radius: 10, y: 3) - .contentShape(.rect(cornerRadius: 15)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(subtitle), \(actionTitle)") - } - - @ViewBuilder - private var icon: some View { - if let brand { - ConnectorBrandIcon(brand: brand, size: 36, cornerRadius: OmiChrome.smallControlRadius) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(Ink.rowFill) - Image(systemName: systemImage) - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - } - .frame(width: 36, height: 36) - } - } -} - -private struct HomeAIChoiceButton: View { - let title: String - let brand: ConnectorBrand? - let systemImage: String? - let usesOmiMark: Bool - let isPrimary: Bool - let isConnected: Bool - let action: () -> Void - - @State private var isHovering = false - - init( - title: String, brand: ConnectorBrand, isPrimary: Bool = false, isConnected: Bool = false, - action: @escaping () -> Void - ) { - self.title = title - self.brand = brand - self.systemImage = nil - self.usesOmiMark = false - self.isPrimary = isPrimary - self.isConnected = isConnected - self.action = action - } - - init( - title: String, systemImage: String, isPrimary: Bool = false, isConnected: Bool = false, action: @escaping () -> Void - ) { - self.title = title - self.brand = nil - self.systemImage = systemImage - self.usesOmiMark = false - self.isPrimary = isPrimary - self.isConnected = isConnected - self.action = action - } - - init( - title: String, usesOmiMark: Bool, isPrimary: Bool = false, isConnected: Bool = false, action: @escaping () -> Void - ) { - self.title = title - self.brand = nil - self.systemImage = nil - self.usesOmiMark = usesOmiMark - self.isPrimary = isPrimary - self.isConnected = isConnected - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.sm) { - icon - - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - - Spacer(minLength: 8) - - if isConnected { - Text("Connected") - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.faint) - } - - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(HomePalette.faint) - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - .frame(height: 48) - .frame(maxWidth: .infinity) - .background(buttonBackground) - .overlay(buttonStroke) - .contentShape(.rect(cornerRadius: 15)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel(title) - } - - @ViewBuilder - private var icon: some View { - if usesOmiMark { - HomeOmiMarkIcon(size: 24, cornerRadius: 7) - } else if let brand { - ConnectorBrandIcon(brand: brand, size: 24, cornerRadius: 7) - } else if let systemImage { - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .bold) - .foregroundStyle(HomePalette.ink) - .frame(width: 24, height: 24) - } - } - - private var buttonBackground: some View { - RoundedRectangle(cornerRadius: 15, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) - } - - private var buttonStroke: some View { - RoundedRectangle(cornerRadius: 15, style: .continuous) - .stroke( - isHovering ? Ink.hairline : Ink.separator, - lineWidth: 1 - ) - } -} - -private struct HomeOmiMarkIcon: View { - let size: CGFloat - let cornerRadius: CGFloat - - private static let markImage: NSImage? = { - guard let url = Bundle.resourceBundle.url(forResource: "herologo", withExtension: "png") else { - return nil - } - return NSImage(contentsOf: url) - }() - - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Ink.rowFill) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - - if let image = Self.markImage { - Image(nsImage: image) - .resizable() - .interpolation(.high) - .aspectRatio(contentMode: .fit) - .padding(size * 0.18) - } else { - OmiDotRing() - .frame(width: size * 0.58, height: size * 0.58) - } - } - .frame(width: size, height: size) - } -} - -private struct OmiDotRing: View { - var body: some View { - ZStack { - ForEach(0..<8, id: \.self) { index in - Circle() - .fill(HomePalette.ink) - .frame(width: 3.5, height: 3.5) - .offset(y: -6) - .rotationEffect(.degrees(Double(index) * 45)) - } - } - } -} - -private struct HomeOrbitButton: View { - let title: String - let brand: ConnectorBrand - let badge: String? - let action: () -> Void - - @State private var isHovering = false - - init(title: String, brand: ConnectorBrand, badge: String? = nil, action: @escaping () -> Void) { - self.title = title - self.brand = brand - self.badge = badge - self.action = action - } - - var body: some View { - Button(action: action) { - VStack(spacing: OmiSpacing.xs) { - ZStack(alignment: .topTrailing) { - ConnectorBrandIcon(brand: brand, size: 44, cornerRadius: 13) - .shadow(color: .black.opacity(isHovering ? 0.16 : 0.08), radius: 9, y: 4) - - if let badge { - Text(badge) - .scaledFont(size: 8, weight: .bold) - .foregroundStyle(HomeAskBarPalette.primaryLabel) - .padding(.horizontal, OmiSpacing.xxs) - .padding(.vertical, OmiSpacing.hairline) - .background(Capsule(style: .continuous).fill(HomePalette.green)) - .offset(x: 8, y: -6) - } - } - - Text(title) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - .lineLimit(1) - } - .padding(OmiSpacing.sm) - .background( - RoundedRectangle(cornerRadius: OmiChrome.controlRadius, style: .continuous) - .fill(isHovering ? HomePalette.panel : Color.clear) - ) - .contentShape(.rect(cornerRadius: OmiChrome.controlRadius)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel(title) - } -} - -private struct HomeDestinationCapsule: View { - let title: String - let subtitle: String - let brand: ConnectorBrand - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.sm) { - ConnectorBrandIcon(brand: brand, size: 34, cornerRadius: 9) - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - - Spacer(minLength: 8) - - Image(systemName: "arrow.up.right") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(isHovering ? HomePalette.green : HomePalette.faint) - } - .padding(OmiSpacing.md) - .background( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .stroke(isHovering ? HomePalette.green.opacity(0.32) : Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 15)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(subtitle)") - } -} - -private struct HomeCommandCard: View { - let onChatGPT: () -> Void - let onClaude: () -> Void - let onAskOmi: () -> Void - - var body: some View { - VStack(spacing: 0) { - HStack(alignment: .top, spacing: OmiSpacing.md) { - Text("Connect Omi to ChatGPT, Claude, or ask Omi directly...") - .scaledFont(size: OmiType.subheading, weight: .regular) - .foregroundStyle(HomePalette.faint) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, OmiSpacing.hairline) - - Button(action: onAskOmi) { - Image(systemName: "arrow.up.circle") - .scaledFont(size: 24, weight: .regular) - .foregroundStyle(HomePalette.faint) - } - .buttonStyle(.plain) - .help("Ask Omi") - } - .padding(.horizontal, OmiSpacing.lg) - .padding(.top, OmiSpacing.lg) - .padding(.bottom, OmiSpacing.xxl) - - HStack(spacing: OmiSpacing.sm) { - Button(action: onChatGPT) { - HStack(spacing: OmiSpacing.sm) { - ConnectorBrandIcon(brand: .chatgpt, size: 22, cornerRadius: OmiChrome.badgeRadius) - Text("Connect ChatGPT") - .scaledFont(size: OmiType.body, weight: .semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, OmiSpacing.sm) - .foregroundStyle(HomeAskBarPalette.primaryLabel) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(HomeAskBarPalette.primaryFill) - ) - } - .buttonStyle(.plain) - - Button(action: onClaude) { - HStack(spacing: OmiSpacing.sm) { - ConnectorBrandIcon(brand: .claude, size: 22, cornerRadius: OmiChrome.badgeRadius) - Text("Claude") - .scaledFont(size: OmiType.body, weight: .semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, OmiSpacing.sm) - .foregroundStyle(HomePalette.secondary) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(HomePalette.tile) - ) - } - .buttonStyle(.plain) - } - .padding(.horizontal, OmiSpacing.md) - .padding(.bottom, OmiSpacing.md) - } - .background( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .fill(HomePalette.panel) - .shadow(color: .black.opacity(0.10), radius: 16, y: 8) - ) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - .frame(maxWidth: 720) - } -} - -private struct HomeSourceTile: View { - let title: String - let subtitle: String - let brand: ConnectorBrand? - let systemImage: String? - let status: HomeRowStatus - let action: () -> Void - - @State private var isHovering = false - - init( - title: String, - subtitle: String, - brand: ConnectorBrand, - status: HomeRowStatus = .connect, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = brand - self.systemImage = nil - self.status = status - self.action = action - } - - init( - title: String, - subtitle: String, - systemImage: String, - status: HomeRowStatus = .connect, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = nil - self.systemImage = systemImage - self.status = status - self.action = action - } - - var body: some View { - Button(action: action) { - VStack(alignment: .leading, spacing: OmiSpacing.sm) { - HStack(alignment: .top) { - iconView - Spacer() - statusView - } - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: OmiType.caption) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - } - .padding(OmiSpacing.sm) - .frame(minHeight: 78, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) - ) - .overlay( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .stroke(isHovering ? HomePalette.green.opacity(0.4) : Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 9)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(subtitle)") - } - - @ViewBuilder - private var iconView: some View { - if let brand { - ConnectorBrandIcon(brand: brand, size: 28, cornerRadius: 7) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(HomePalette.panel) - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - } - .frame(width: 28, height: 28) - } - } - - @ViewBuilder - private var statusView: some View { - switch status { - case .connect: - Image(systemName: "plus") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(HomePalette.secondary) - case .connected: - Image(systemName: "checkmark") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(HomePalette.green) - case .open: - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(HomePalette.secondary) - } - } -} - -private struct HomeMemoryMetricCard: View { - let title: String - let value: String - let systemImage: String - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.md) { - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(Ink.rowFill) - - Image(systemName: systemImage) - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundStyle(HomePalette.ink) - } - .frame(width: 42, height: 42) - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(value) - .font(.system(size: 21, weight: .medium, design: .serif)) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - .minimumScaleFactor(0.72) - - Text(title) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - - Spacer(minLength: 8) - - Image(systemName: "arrow.up.right") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(isHovering ? Ink.primary : Ink.secondary) - } - .padding(.horizontal, OmiSpacing.md) - .frame(height: 76) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 17, style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile) - ) - .overlay( - RoundedRectangle(cornerRadius: 17, style: .continuous) - .stroke(isHovering ? Ink.hairline : Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 17)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(value)") - } -} - -private struct HomeMetricPill: View { - let title: String - let value: String - let systemImage: String - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.xs) { - Image(systemName: systemImage) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - - Text(value) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(HomePalette.ink) - - Text(title) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .frame(maxWidth: .infinity) - .background( - Capsule(style: .continuous) - .fill(isHovering ? HomePalette.tileHover : HomePalette.panel) - ) - .overlay( - Capsule(style: .continuous) - .stroke(isHovering ? HomePalette.green.opacity(0.34) : Ink.separator, lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(value)") - } -} - -private struct HomeGlassPanel: View { - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - content - .padding(OmiSpacing.lg) - .frame(maxWidth: .infinity, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(HomePalette.panel) - ) - .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - .shadow(color: .black.opacity(0.08), radius: 14, y: 6) - } -} - -private struct HomeStageHeader: View { - let eyebrow: String - let title: String - let subtitle: String - - var body: some View { - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text(eyebrow.uppercased()) - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(HomePalette.green) - - Text(title) - .scaledFont(size: OmiType.heading, weight: .semibold) - .foregroundStyle(HomePalette.ink) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: OmiType.caption) - .foregroundStyle(HomePalette.muted) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(2) - } - } -} - -private struct HomeBridgeChevron: View { - var body: some View { - VStack(spacing: OmiSpacing.sm) { - Rectangle() - .fill( - LinearGradient( - colors: [.clear, Ink.separator, .clear], - startPoint: .top, - endPoint: .bottom - ) - ) - .frame(width: 1, height: 150) - - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.subheading, weight: .bold) - .foregroundStyle(Ink.secondary) - } - .frame(width: 22) - .accessibilityHidden(true) - } -} - -private struct HomeSourceRow: View { - let title: String - let subtitle: String - let brand: ConnectorBrand? - let systemImage: String? - let status: HomeRowStatus - let action: () -> Void - - @State private var isHovering = false - - init( - title: String, - subtitle: String, - brand: ConnectorBrand, - status: HomeRowStatus = .connect, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = brand - self.systemImage = nil - self.status = status - self.action = action - } - - init( - title: String, - subtitle: String, - systemImage: String, - status: HomeRowStatus = .connect, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = nil - self.systemImage = systemImage - self.status = status - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.sm) { - rowIcon - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(Ink.primary) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: OmiType.caption) - .foregroundStyle(Ink.secondary) - .lineLimit(1) - } - - Spacer(minLength: 8) - - statusView - } - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.sm) - .background( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .fill(isHovering ? Ink.rowFillHover : Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .stroke(isHovering ? Ink.listeningGreen.opacity(0.28) : Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: 13)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(subtitle)") - } - - @ViewBuilder - private var rowIcon: some View { - if let brand { - ConnectorBrandIcon(brand: brand, size: 32, cornerRadius: OmiChrome.elementRadius) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.elementRadius, style: .continuous) - .fill(Ink.rowFillHover) - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(Ink.secondary) - } - .frame(width: 32, height: 32) - } - } - - @ViewBuilder - private var statusView: some View { - switch status { - case .connect: - Image(systemName: "plus") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(Ink.listeningGreen) - case .connected: - Image(systemName: "checkmark") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(Ink.listeningGreen) - case .open: - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(Ink.listeningGreen) - } - } -} - -private struct HomeDestinationRow: View { - let title: String - let subtitle: String - let brand: ConnectorBrand? - let systemImage: String? - let prominence: HomeDestinationProminence - let action: () -> Void - - @State private var isHovering = false - - init( - title: String, - subtitle: String, - brand: ConnectorBrand, - prominence: HomeDestinationProminence = .primary, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = brand - self.systemImage = nil - self.prominence = prominence - self.action = action - } - - init( - title: String, - subtitle: String, - systemImage: String, - prominence: HomeDestinationProminence = .primary, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.brand = nil - self.systemImage = systemImage - self.prominence = prominence - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.sm) { - rowIcon - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(prominence == .primary ? HomePalette.ink : HomePalette.secondary) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: OmiType.caption) - .foregroundStyle(HomePalette.muted) - .lineLimit(1) - } - - Spacer(minLength: 8) - - Image(systemName: "arrow.up.right") - .scaledFont(size: OmiType.caption, weight: .bold) - .foregroundStyle(isHovering ? HomePalette.green : HomePalette.faint) - } - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.sm) - .background(rowBackground) - .overlay(rowStroke) - .contentShape(.rect(cornerRadius: OmiChrome.chipRadius)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(subtitle)") - } - - @ViewBuilder - private var rowIcon: some View { - if let brand { - ConnectorBrandIcon(brand: brand, size: 34, cornerRadius: 9) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(HomePalette.tile) - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - } - .frame(width: 34, height: 34) - } - } - - private var rowBackground: some View { - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .fill( - prominence == .primary - ? HomePalette.green.opacity(isHovering ? 0.20 : 0.12) - : (isHovering ? HomePalette.tileHover : HomePalette.tile) - ) - } - - private var rowStroke: some View { - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .stroke( - prominence == .primary - ? HomePalette.green.opacity(isHovering ? 0.42 : 0.24) - : isHovering ? Ink.hairline : Ink.separator, - lineWidth: 1 - ) - } -} - -private struct HomeMetricTile: View { - let title: String - let value: String - let systemImage: String - let accent: Color - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - VStack(alignment: .leading, spacing: OmiSpacing.xs) { - HStack { - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(accent) - - Spacer() - - Image(systemName: "arrow.up.right") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(isHovering ? accent : Ink.secondary) - } - - Text(value) - .scaledFont(size: OmiType.heading, weight: .semibold) - .foregroundStyle(Ink.primary) - .lineLimit(1) - - Text(title) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(Ink.secondary) - .lineLimit(1) - } - .padding(OmiSpacing.md) - .frame(minHeight: 86, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: OmiChrome.controlRadius, style: .continuous) - .fill(isHovering ? Ink.rowFillHover : Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.controlRadius, style: .continuous) - .stroke(isHovering ? accent.opacity(0.34) : Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: OmiChrome.controlRadius)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(value)") - } -} - -private struct HomeSectionHeader: View { - let title: String - let subtitle: String - - var body: some View { - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text(title) - .scaledFont(size: OmiType.heading, weight: .semibold) - .foregroundStyle(Ink.primary) - - Text(subtitle) - .scaledFont(size: OmiType.caption) - .foregroundStyle(Ink.secondary) - } - } -} - -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: OmiSpacing.sm) { - ZStack { - if isToggling { - ProgressView() - .controlSize(.small) - .scaleEffect(0.55) - } else { - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .semibold) - } - } - .frame(width: 18, height: 18) - - Text(title) - .scaledFont(size: OmiType.caption, weight: .semibold) - .lineLimit(1) - } - .foregroundStyle(status.isActive ? HomePalette.ink : (status.isBlocked ? status.indicator : HomePalette.muted)) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .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 ? Ink.rowFill : Color.clear - } - - 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 isHovering ? Ink.hairline : Ink.separator - } -} - -struct HomeListeningStatusButton: View { - let title: String - let systemImage: String - let status: HomeStatusState - let modeTitle: String - /// Only Meetings wait: the session is armed, the mic is paused, and a click turns - /// listening off. Help/VoiceOver must not reuse the "Off" sentence for that. - let isAwaitingMeeting: Bool - let isToggling: Bool - let action: () -> Void - - /// Hover / VoiceOver copy. An armed Only Meetings wait is inactive (mic paused) - /// but not off — a click turns listening off, it does not start it. - static func helpText( - status: HomeStatusState, modeTitle: String, isAwaitingMeeting: Bool - ) -> String { - if status == .inactive && isAwaitingMeeting { - return - "Listening: waiting for a call (\(modeTitle)). Nothing is being transcribed. Click to turn off." - } - return "Listening: \(status.text), \(modeTitle)" - } - - // Hover reveals the selected mode, but Settings owns the only picker. - @State private var isHovering = false - - var body: some View { - HStack(spacing: 0) { - Button(action: action) { - HStack(spacing: OmiSpacing.sm) { - ZStack { - if isToggling { - ProgressView() - .controlSize(.small) - .scaleEffect(0.55) - } else { - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .semibold) - } - } - .frame(width: 18, height: 18) - - VStack(alignment: .leading, spacing: 1) { - Text(title) - .scaledFont(size: OmiType.caption, 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, OmiSpacing.md) - .padding(.trailing, OmiSpacing.sm) - .frame(height: 34) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(isToggling) - .help(Self.helpText(status: status, modeTitle: modeTitle, isAwaitingMeeting: isAwaitingMeeting)) - .accessibilityLabel( - Self.helpText(status: status, modeTitle: modeTitle, isAwaitingMeeting: isAwaitingMeeting)) - } - .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 } - .omiAnimation(.easeInOut(duration: 0.14), value: isHovering) - } - - 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 ? Ink.rowFill : Color.clear - } - - 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 isHovering ? Ink.hairline : Ink.separator - } -} - -private struct HomeIconActionButton: View { - let title: String - let systemImage: String - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - Image(systemName: systemImage) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(isHovering ? HomePalette.ink : HomePalette.muted) - .frame(width: 34, height: 34) - .background( - Circle() - .fill(isHovering ? HomePalette.tileHover : HomePalette.panel) - ) - .overlay( - Circle() - .stroke(isHovering ? Ink.hairline : Ink.separator, lineWidth: 1) - ) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .help(title) - .accessibilityLabel(title) - } -} - -private struct HomeConnectorCard: View { - let title: String - let subtitle: String - let brand: ConnectorBrand - let actionTitle: String - let status: String? - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.md) { - ConnectorBrandIcon(brand: brand, size: 36, cornerRadius: 9) - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(Ink.primary) - .lineLimit(1) - - Text(subtitle) - .scaledFont(size: OmiType.caption) - .foregroundStyle(Ink.secondary) - .lineLimit(1) - } - - Spacer(minLength: 10) - - if let status { - HStack(spacing: OmiSpacing.xxs) { - Image(systemName: "checkmark") - .scaledFont(size: OmiType.micro, weight: .bold) - Text(status) - .scaledFont(size: OmiType.caption, weight: .semibold) - } - .foregroundStyle(Ink.listeningGreen) - .lineLimit(1) - } else { - HStack(spacing: OmiSpacing.xxs) { - Image(systemName: "plus") - .scaledFont(size: OmiType.micro, weight: .bold) - Text(actionTitle) - .scaledFont(size: OmiType.caption, weight: .semibold) - } - .foregroundStyle(Ink.listeningGreen) - .lineLimit(1) - } - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .frame(minHeight: 56) - .background(cardBackground) - .overlay(cardStroke) - .contentShape(.rect(cornerRadius: OmiChrome.smallControlRadius)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(status ?? actionTitle)") - } - - private var cardBackground: some View { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(isHovering ? Ink.rowFillHover : Ink.rowFill) - } - - private var cardStroke: some View { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .stroke( - isHovering ? Ink.listeningGreen.opacity(0.32) : Ink.separator, - lineWidth: 1 - ) - } -} - -private struct HomeMoreAppsCard: View { - let action: () -> Void - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.md) { - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(Ink.rowFillHover) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .stroke(Ink.separator, lineWidth: 1) - ) - - Image(systemName: "square.grid.2x2.fill") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundStyle(Ink.secondary) - } - .frame(width: 36, height: 36) - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text("Connect more") - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(Ink.primary) - - Text("Browse all apps") - .scaledFont(size: OmiType.caption) - .foregroundStyle(Ink.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(Ink.listeningGreen) - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .frame(minHeight: 56) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(isHovering ? Ink.rowFillHover : Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .stroke( - isHovering ? Ink.listeningGreen.opacity(0.32) : Ink.separator, - lineWidth: 1 - ) - ) - .contentShape(.rect(cornerRadius: OmiChrome.smallControlRadius)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - } -} - -private struct HomeFlowArrow: View { - var body: some View { - VStack(spacing: OmiSpacing.xxs) { - Rectangle() - .fill(Ink.separator) - .frame(width: 1, height: 14) - - Image(systemName: "chevron.down") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(Ink.secondary) - } - .frame(maxWidth: .infinity) - .accessibilityHidden(true) - } -} - -private struct HomeMetricCard: View { - let title: String - let value: String - let subtitle: String - let systemImage: String - let accent: Color - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.md) { - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(accent.opacity(0.16)) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .stroke(accent.opacity(0.28), lineWidth: 1) - ) - - Image(systemName: systemImage) - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundStyle(accent) - } - .frame(width: 38, height: 38) - - VStack(alignment: .leading, spacing: OmiSpacing.hairline) { - Text(value) - .scaledFont(size: OmiType.heading, weight: .semibold) - .foregroundStyle(Ink.primary) - .lineLimit(1) - - Text(title) - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundStyle(Ink.secondary) - .lineLimit(1) - } - - Spacer(minLength: 8) - - Image(systemName: "arrow.up.right") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(isHovering ? accent : Ink.secondary) - } - .padding(OmiSpacing.md) - .frame(minHeight: 64) - .background( - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .fill(isHovering ? Ink.rowFillHover : Ink.rowFill) - ) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .stroke(isHovering ? accent.opacity(0.34) : Ink.separator, lineWidth: 1) - ) - .contentShape(.rect(cornerRadius: OmiChrome.chipRadius)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel("\(title), \(value), \(subtitle)") - } -} - -private struct HomeAIButton: View { - let title: String - let brand: ConnectorBrand? - let systemImage: String? - let action: () -> Void - - @State private var isHovering = false - - init(title: String, brand: ConnectorBrand, action: @escaping () -> Void) { - self.title = title - self.brand = brand - self.systemImage = nil - self.action = action - } - - init(title: String, systemImage: String, action: @escaping () -> Void) { - self.title = title - self.brand = nil - self.systemImage = systemImage - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: OmiSpacing.sm) { - if let brand { - ConnectorBrandIcon(brand: brand, size: 26, cornerRadius: 7) - } else if let systemImage { - ZStack { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(Ink.rowFillHover) - Image(systemName: systemImage) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundStyle(Ink.secondary) - } - .frame(width: 26, height: 26) - } - - Text(title) - .scaledFont(size: OmiType.body, weight: .semibold) - .foregroundStyle(Ink.secondary) - .lineLimit(1) - - Image(systemName: "chevron.right") - .scaledFont(size: OmiType.micro, weight: .bold) - .foregroundStyle(isHovering ? Ink.listeningGreen : Ink.secondary) - } - .padding(.leading, OmiSpacing.sm) - .padding(.trailing, OmiSpacing.md) - .padding(.vertical, OmiSpacing.xs) - .background( - Capsule(style: .continuous) - .fill(isHovering ? Ink.rowFillHover : Ink.rowFill) - ) - .overlay( - Capsule(style: .continuous) - .stroke(isHovering ? Ink.listeningGreen.opacity(0.32) : Ink.separator, lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel(title) - } -} - -#if canImport(PreviewsMacros) - #Preview { - DashboardPage( - viewModel: DashboardViewModel(), - appState: AppState(), - appProvider: AppProvider(), - chatProvider: ChatProvider(), - memoriesViewModel: MemoriesViewModel(), - selectedIndex: .constant(0) - ) - .frame(width: 800, height: 600) - .inkGlassPanel() - } -#endif diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+Assistants.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+Assistants.swift index f1e29869ff8..bf6b3e03ab9 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+Assistants.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+Assistants.swift @@ -865,60 +865,6 @@ extension SettingsContentView { } } - settingsCard(settingId: "advanced.preferences.legacyhome") { - HStack(spacing: OmiSpacing.lg) { - Image(systemName: "rectangle.split.2x1") - .scaledFont(size: OmiType.subheading) - .foregroundColor(Ink.secondary) - .frame(width: 24, height: 24) - - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text("Use old Home design") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - - Text("Show the previous chat-first dashboard instead of the simplified Home") - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.secondary) - } - - Spacer() - - // Same card shape, same trailing slot, same kind of preference as the two rows it sits - // between — an AppKit checkbox here is a second switch vocabulary in one stack. - Toggle("", isOn: $useLegacyHomeDesign) - .toggleStyle(OmiToggleStyle()) - .labelsHidden() - } - } - - if useLegacyHomeDesign { - settingsCard(settingId: "advanced.preferences.oldesthome") { - HStack(spacing: OmiSpacing.lg) { - Image(systemName: "rectangle.stack") - .scaledFont(size: OmiType.subheading) - .foregroundColor(Ink.secondary) - .frame(width: 24, height: 24) - - VStack(alignment: .leading, spacing: OmiSpacing.xxs) { - Text("Use oldest Home theme") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.primary) - - Text("Show the original widgets-and-chat Home") - .scaledFont(size: OmiType.body) - .foregroundColor(Ink.secondary) - } - - Spacer() - - Toggle("", isOn: $useOldestHomeDesign) - .toggleStyle(OmiToggleStyle()) - .labelsHidden() - } - } - } - settingsCard(settingId: "advanced.preferences.speaknotifications") { HStack(spacing: OmiSpacing.lg) { Image(systemName: "speaker.wave.2") diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/SettingsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/SettingsPage.swift index dd26fdb3d70..2d64d05738a 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/SettingsPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/SettingsPage.swift @@ -361,8 +361,6 @@ struct SettingsContentView: View { // Multi-chat mode setting @AppStorage("multiChatEnabled") var multiChatEnabled = false @AppStorage("conversationsCompactView") var conversationsCompactView = true - @AppStorage("useLegacyHomeDesign") var useLegacyHomeDesign = false - @AppStorage("useOldestHomeDesign") var useOldestHomeDesign = false @AppStorage("speakNotificationsAloud") var speakNotificationsAloud = false @AppStorage(DefaultsKey.integrationNudgesEnabled.rawValue) var integrationNudgesEnabled = true diff --git a/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift b/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift index fed98199f51..db7ea4d19cd 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift @@ -17,9 +17,6 @@ // It paints **no background**. The window's ground is AppKit's (`ShellGlassGround`); the two panels // here wear the app's glass through `inkGlassPanel` and nothing else does. // -// The legacy hub is still here, behind the `useLegacyHomeDesign` setting that already gated it, so -// the change is reversible by the person it happened to rather than by a rebuild. -// // **This is the app's only chat destination**, which makes it the only place the controls of the // deleted standalone chat page can live (`6be26e85bc`; INV-NAV-1 forbids bringing that page back). // So this file also hosts: the chat overflow menu (copy / clear / AI settings), the way back into @@ -44,16 +41,10 @@ struct QueryShellHome: View { @ObservedObject var memoriesViewModel: MemoriesViewModel @ObservedObject private var tasksStore = TasksStore.shared var taskChatCoordinator: TaskChatCoordinator? = nil - /// The Chat-first shell keeps the existing modern Home presentation even when the reversible legacy - /// preference is enabled. This is presentation-only; capability sampling and rich-block access - /// remain owned by `ChatFirstShell`. - var forceModernPresentation: Bool = false - /// Non-nil only for the sampled Chat-first main-chat surface. It enables the existing inline entity - /// controls without creating another provider or transcript. - var chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil - @Binding var selectedIndex: Int - - @AppStorage("useLegacyHomeDesign") private var useLegacyHomeDesign = false + /// Typed navigation and the interactable content-block controls. Every Chat + /// surface has one; it creates no second provider or transcript. + let chatFirstRichBlockContext: ChatFirstRichBlockContext + @AppStorage(MemoryHubDestination.storageKey) private var memoryDestinationRawValue = MemoryHubDestination.memories.rawValue @@ -97,26 +88,8 @@ struct QueryShellHome: View { /// since given away — which is precisely the case the `didBecomeActive` claim below exists for. @State private var caretClaims = 0 - private var usesLegacyPresentation: Bool { - !HomeDesignPresentation.queryShellOwnsItsPanels( - useLegacyHomeDesign: useLegacyHomeDesign, - forceModernPresentation: forceModernPresentation) - } - var body: some View { - if usesLegacyPresentation { - DashboardPage( - viewModel: viewModel, - homeStatusStore: homeStatusStore, - appState: appState, - appProvider: appProvider, - chatProvider: chatProvider, - memoriesViewModel: memoriesViewModel, - taskChatCoordinator: taskChatCoordinator, - selectedIndex: $selectedIndex) - } else { - querySurface - } + querySurface } private var querySurface: some View { @@ -220,7 +193,6 @@ struct QueryShellHome: View { // search text, so an action that promises the conversation must clear it or its effect lands // hidden behind the results panel while the bridge reports success. .onReceive(NotificationCenter.default.publisher(for: .homeStageOpenChat)) { _ in - guard !usesLegacyPresentation else { return } searchText = HomeBridgeIntent.openChat.searchTextAfter(searchText) claimCaret() } @@ -228,18 +200,17 @@ struct QueryShellHome: View { // The bridge action posts this and reports success, so an unobserved notification here would // be the "bridge answered ok and nothing happened" defect this file's actions exist to avoid. .onReceive(NotificationCenter.default.publisher(for: .homeStageClose)) { _ in - guard !usesLegacyPresentation else { return } searchText = HomeBridgeIntent.closePanel.searchTextAfter(searchText) claimCaret() } .onReceive(NotificationCenter.default.publisher(for: .homeStageAsk)) { note in - guard !usesLegacyPresentation, let query = note.userInfo?["query"] as? String else { return } + guard let query = note.userInfo?["query"] as? String else { return } searchText = HomeBridgeIntent.ask.searchTextAfter(searchText) chatProvider.draftText = query ask() } .onReceive(NotificationCenter.default.publisher(for: .homeStageAttach)) { note in - guard !usesLegacyPresentation, let path = note.userInfo?["path"] as? String else { return } + guard let path = note.userInfo?["path"] as? String else { return } searchText = HomeBridgeIntent.attach.searchTextAfter(searchText) stageAttachments([URL(fileURLWithPath: path)]) } @@ -466,28 +437,15 @@ struct QueryShellHome: View { /// Opens the exact conversation a spine row is about. /// - /// The row carries the whole record, so the typed deep link can hand it straight to the - /// Conversations host. The id-only path below stays for the shell that has no typed navigation - /// owner, where this page mounts the Conversations host itself. + /// The row carries the whole record, so the typed deep link hands it straight to the + /// Conversations host rather than re-resolving it by id. private func openConversationRecord(_ conversation: ServerConversation) { - if let context = chatFirstRichBlockContext { - context.navigation.open(conversation: conversation) - return - } - openConversation(conversation.id) + chatFirstRichBlockContext.navigation.open(conversation: conversation) } - /// Opens the exact memory a spine row is about, on the same terms the Brain Map's citations use: - /// leave this surface only once the memory actually resolved. + /// Opens the exact memory a spine row is about, on the same terms the Brain Map's citations use. private func openMemory(_ memory: SpineMemory) { - if let context = chatFirstRichBlockContext { - context.navigation.open(focus: .memory(id: memory.id)) - return - } - Task { - await MemoryAtlasCitationOpen.open( - id: memory.id, in: memoriesViewModel, leave: { navigate(.memories) }) - } + chatFirstRichBlockContext.navigation.open(focus: .memory(id: memory.id)) } /// Opens the real Conversations page on the real conversation — never a copy of it here (INV-NAV-1). @@ -505,68 +463,25 @@ struct QueryShellHome: View { navigate(.conversation) } - private func openMemories() { - navigate(.memories) - } - /// Typed citation routing stays at the shell boundary. The inline renderer knows presentation; /// this root owns navigation and preserves exact entity identity where the destination supports it. private func openCitation(_ reference: ChatCitationReference) { guard reference.canOpen else { return } - if let context = chatFirstRichBlockContext { - switch reference.kind { - case .conversation: - let moment = reference.momentTimestampMs.map { TimeInterval($0) / 1_000 } - context.navigation.open(focus: .capture(id: reference.sourceID, momentTs: moment)) - case .memory: - context.navigation.open(focus: .memory(id: reference.sourceID)) - case .task: - context.navigation.open(focus: .task(id: reference.sourceID)) - case .goal: - context.navigation.open(focus: .goal(id: reference.sourceID)) - case .screenshot: - guard let id = RewindCitationFocusState.parseScreenshotID(reference.sourceID) else { return } - RewindCitationFocusState.shared.request(id) - context.navigation.selectMore(.rewind) - case .web: - if let url = reference.url { NSWorkspace.shared.open(url) } - case .unavailable: - break - } - return - } - + let navigation = chatFirstRichBlockContext.navigation switch reference.kind { case .conversation: - openConversation(reference.sourceID) + let moment = reference.momentTimestampMs.map { TimeInterval($0) / 1_000 } + navigation.open(focus: .capture(id: reference.sourceID, momentTs: moment)) case .memory: - Task { @MainActor in - guard await memoriesViewModel.openMemory(id: reference.sourceID) else { return } - openMemories() - } + navigation.open(focus: .memory(id: reference.sourceID)) case .task: - // TasksPage has a typed, owner-bound handoff. Resolve the exact task before changing pages; - // selecting the Tasks tab alone would silently discard the citation's identity. - Task { @MainActor in - guard let authorization = RuntimeOwnerIdentity.captureAuthorizationSnapshot(), - let task = try? await APIClient.shared.getActionItem( - id: reference.sourceID, - expectedOwnerId: authorization.ownerID, - authorizationSnapshot: authorization), - RuntimeOwnerIdentity.isAuthorizationCurrent(authorization) - else { return } - guard !task.isRetired else { return } - TaskNavigationRequestStore.shared.request(task: task) - selectedIndex = SidebarNavItem.tasks.rawValue - } + navigation.open(focus: .task(id: reference.sourceID)) case .goal: - // QueryAnswerThread marks this kind unavailable in the legacy shell before rendering. Keep - // the routing boundary fail-closed as defense in depth. - return + navigation.open(focus: .goal(id: reference.sourceID)) case .screenshot: guard let id = RewindCitationFocusState.parseScreenshotID(reference.sourceID) else { return } RewindCitationFocusState.shared.request(id) - openRewind() + navigation.selectMore(.rewind) case .web: if let url = reference.url { NSWorkspace.shared.open(url) } case .unavailable: @@ -589,10 +504,17 @@ struct QueryShellHome: View { /// `QueryShellRoute` rather than restating a rail index and a hub raw value at its own call site — /// which is how one of them ends up pointing somewhere the others do not. private func navigate(_ route: QueryShellRoute) { - if let hubView = route.memoryDestination { + let navigation = chatFirstRichBlockContext.navigation + OmiMotion.withGated(.easeOut(duration: 0.08)) { + guard let hubView = route.memoryDestination else { + navigation.selectLegacyDestination(route.navItem) + return + } + // Both halves of the hub state move together — the persisted view and the + // typed route that decides which host is mounted (see `ChatFirstShell`). memoryDestinationRawValue = hubView.rawValue + navigation.selectPrimary(MemoryHubSelectionPolicy.chatFirstRoute(for: hubView)) } - OmiMotion.withGated(.easeOut(duration: 0.08)) { selectedIndex = route.navItem.rawValue } } // MARK: - The corpus diff --git a/desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift b/desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift deleted file mode 100644 index 7ab2abc9d7d..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift +++ /dev/null @@ -1,1497 +0,0 @@ -@preconcurrency import AppKit -import OmiTheme -import SwiftUI - -// MARK: - Sidebar View -struct SidebarView: View { - @Binding var selectedIndex: Int - @Binding var isCollapsed: Bool - @Binding var memoryDestinationRawValue: Int - @ObservedObject var appState: AppState - @ObservedObject private var authState = AuthState.shared - @ObservedObject private var updaterViewModel = UpdaterViewModel.shared - - // Tier gating (0 = show all, 1-6 = sequential tiers) - @AppStorage("currentTierLevel") private var currentTierLevel = 0 - - // Toggle states for quick controls - @AppStorage("screenAnalysisEnabled") private var screenAnalysisEnabled = true - @AppStorage(AssistantSettings.audioRecordingModeDefaultsKey) private var audioRecordingModeRaw = - AssistantSettings.AudioRecordingMode.onlyMeetings.rawValue - @State private var isMonitoring = false - @State private var isTogglingMonitoring = false - @State private var isTogglingTranscription = false - @State private var monitoringAutoRestartAttempts = 0 - private let maxAutoRestartAttempts = 3 - - // Page loading states (show spinner in place of icon) - @State private var isRewindPageLoading = false - @State private var isConversationsPageLoading = false - @State private var isTasksPageLoading = false - @State private var isAppsPageLoading = false - - // Drag state - @State private var dragOffset: CGFloat = 0 - @GestureState private var isDragging = false - @State private var isProfileButtonHovered = false - - // Constants - private let expandedWidth: CGFloat = 260 - private let collapsedWidth: CGFloat = 64 - private let iconWidth: CGFloat = 20 // Fixed width for all icons - - private var currentWidth: CGFloat { - isCollapsed ? collapsedWidth : expandedWidth - } - - /// Whether a sidebar item is locked at the current tier level - private func isItemLocked(_ item: SidebarNavItem) -> Bool { - currentTierLevel != 0 && currentTierLevel < item.requiredTier - } - - /// Static version: items unlocked at a given tier (used by unlock celebration logic) - static func visibleItems(for tier: Int) -> [SidebarNavItem] { - if tier == 0 { - return SidebarNavItem.mainItems - } - return SidebarNavItem.mainItems.filter { $0.requiredTier <= tier } - } - - var body: some View { - ZStack(alignment: .trailing) { - VStack(alignment: .leading, spacing: 0) { - // Header: Logo + Collapse button on same row - headerSection - .padding(.top, OmiSpacing.md) - .padding(.horizontal, isCollapsed ? OmiSpacing.sm : OmiSpacing.lg) - - Spacer().frame(height: isCollapsed ? 8 : 16) - - // Main navigation section - VStack(alignment: .leading, spacing: 0) { - // Main navigation items - ForEach(SidebarNavItem.mainItems, id: \.rawValue) { item in - Group { - if item == .conversations { - // Conversations - icon shows audio activity when recording - // Audio levels wrapped in a separate view to avoid re-rendering the entire sidebar - AudioLevelNavItem( - icon: item.icon, - label: item.title, - isSelected: selectedIndex == item.rawValue, - isCollapsed: isCollapsed, - iconWidth: iconWidth, - isOn: appState.isTranscribing, - isToggling: isTogglingTranscription, - isPageLoading: isConversationsPageLoading, - onTap: { - // Show loading immediately when navigating to Conversations - if selectedIndex != item.rawValue { - isConversationsPageLoading = true - // Fallback timeout - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { - if isConversationsPageLoading { - isConversationsPageLoading = false - } - } - } - MemoryHubDestination.apply(item, to: &selectedIndex, hub: &memoryDestinationRawValue) - AnalyticsManager.shared.tabChanged(tabName: item.title) - }, - onToggle: { - toggleTranscription(enabled: !appState.isTranscribing) - } - ) - } else if item == .rewind { - // Rewind - shows pulsing recording icon when both audio and screen are active - NavItemWithStatusView( - icon: item.icon, - label: item.title, - isSelected: selectedIndex == item.rawValue, - isCollapsed: isCollapsed, - iconWidth: iconWidth, - isOn: isMonitoring || appState.isTranscribing, - isToggling: isTogglingMonitoring, - isPageLoading: isRewindPageLoading, - onTap: { - // Show loading immediately when navigating to Rewind - if selectedIndex != item.rawValue { - log("SIDEBAR: Rewind tapped, showing loading indicator") - isRewindPageLoading = true - // Fallback timeout in case page load notification never comes - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { - if isRewindPageLoading { - log("SIDEBAR: Rewind loading timeout, clearing indicator") - isRewindPageLoading = false - } - } - } - MemoryHubDestination.apply(item, to: &selectedIndex, hub: &memoryDestinationRawValue) - AnalyticsManager.shared.tabChanged(tabName: item.title) - }, - onToggle: { - // Toggle both — on if either is off, off if both are on - let isAnyOn = isMonitoring || appState.isTranscribing - toggleMonitoring(enabled: !isAnyOn) - }, - showRewindIcon: true - ) - } else { - let locked = isItemLocked(item) - NavItemView( - icon: item.icon, - label: item.title, - isSelected: !locked && selectedIndex == item.rawValue, - isCollapsed: isCollapsed, - iconWidth: iconWidth, - isLoading: pageLoadingState(for: item), - isLocked: locked, - lockTooltip: locked ? "Unlocks at Tier \(item.requiredTier)" : nil, - onUnlock: { - TierManager.shared.userDidSetTier(item.requiredTier) - setPageLoading(for: item, loading: true) - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { - setPageLoading(for: item, loading: false) - } - MemoryHubDestination.apply(item, to: &selectedIndex, hub: &memoryDestinationRawValue) - AnalyticsManager.shared.tabChanged(tabName: item.title) - }, - onTap: { - // Show loading immediately when navigating - if selectedIndex != item.rawValue { - setPageLoading(for: item, loading: true) - // Fallback timeout - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { - setPageLoading(for: item, loading: false) - } - } - MemoryHubDestination.apply(item, to: &selectedIndex, hub: &memoryDestinationRawValue) - AnalyticsManager.shared.tabChanged(tabName: item.title) - } - ) - } - } - } - - Spacer() - - // Subscription upgrade banner - // upgradeToPro - - // Update available widget (also surfaced in DesktopTopBar for chat-first) - if updaterViewModel.updateAvailable || updaterViewModel.updateSessionInProgress - || updaterViewModel.updateRestartImminent - || updaterViewModel.updateDeferredForActiveRecording - { - Spacer().frame(height: OmiSpacing.md) - DesktopUpdateStatusBanner(isCollapsed: isCollapsed, iconWidth: iconWidth) - .transition(.opacity) - } - - if hasVisibleSidebarStatuses { - Spacer().frame(height: OmiSpacing.lg) - permissionStatusSection - } - - Spacer().frame(height: OmiSpacing.lg) - Rectangle() - .fill(Ink.rowFillHover) - .frame(height: 1) - - Spacer().frame(height: OmiSpacing.md) - profileMenuButton - - Spacer().frame(height: OmiSpacing.sm) - } - .padding(.horizontal, isCollapsed ? OmiSpacing.sm : OmiSpacing.lg) - .frame(maxHeight: .infinity) - } - .frame(maxWidth: currentWidth + dragOffset, maxHeight: .infinity, alignment: .top) - .background(Color.clear) - .omiAnimation(.easeInOut(duration: 0.2), value: isCollapsed) - - // Drag handle - Rectangle() - .fill(Color.clear) - .frame(width: 8) - .contentShape(Rectangle()) - .gesture( - DragGesture() - .updating($isDragging) { _, state, _ in - state = true - } - .onChanged { value in - let newWidth = currentWidth + value.translation.width - if newWidth < (collapsedWidth + expandedWidth) / 2 { - if !isCollapsed { - OmiMotion.withGated(.easeInOut(duration: 0.2)) { - isCollapsed = true - } - } - } else { - if isCollapsed { - OmiMotion.withGated(.easeInOut(duration: 0.2)) { - isCollapsed = false - } - } - } - } - ) - .onHover { hovering in - if hovering { - NSCursor.resizeLeftRight.push() - } else { - NSCursor.pop() - } - } - } - .frame(width: currentWidth) - .onAppear { - syncMonitoringState() - appState.checkAllPermissions() - updatePermissionPulse(hasPermissionDenied) - } - .onChange(of: currentTierLevel) { _, newTier in - // Redirect if current page became locked after tier change - if let currentItem = SidebarNavItem(rawValue: selectedIndex), - newTier != 0 && newTier < currentItem.requiredTier, - selectedIndex != SidebarNavItem.settings.rawValue - && selectedIndex != SidebarNavItem.permissions.rawValue - { - selectedIndex = SidebarNavItem.dashboard.rawValue - } - } - .onChange(of: selectedIndex) { _, _ in - // Check tier eligibility on page navigation (at most once per day) - Task { - await TierManager.shared.checkTierIfNeeded() - } - } - .onChange(of: hasPermissionDenied) { _, denied in - updatePermissionPulse(denied) - } - .onReceive(NotificationCenter.default.publisher(for: .assistantMonitoringStateDidChange)) { - notification in - syncMonitoringState() - let isNowMonitoring = - (notification.userInfo?["isMonitoring"] as? Bool) - ?? ProactiveAssistantsPlugin.shared.isMonitoring - if isNowMonitoring { - // Reset retry counter on successful start - monitoringAutoRestartAttempts = 0 - } else if screenAnalysisEnabled && !isTogglingMonitoring - && monitoringAutoRestartAttempts < maxAutoRestartAttempts - { - // Auto-restart: monitoring stopped but user's setting says it should be on. - // Try to restart after a delay (handles transient failures, sleep/wake, etc.) - monitoringAutoRestartAttempts += 1 - let attempt = monitoringAutoRestartAttempts - DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - let plugin = ProactiveAssistantsPlugin.shared - guard !plugin.isMonitoring && screenAnalysisEnabled else { return } - plugin.refreshScreenRecordingPermission() - if plugin.hasScreenRecordingPermission { - log( - "SidebarView: Auto-restarting monitoring (attempt \(attempt)/\(maxAutoRestartAttempts))" - ) - plugin.startMonitoring { success, _ in - if !success { - log("SidebarView: Auto-restart attempt \(attempt) failed") - } - } - } - } - } - } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - // Refresh permissions when app becomes active (user may have changed them in System Settings) - appState.checkAllPermissions() - } - .onReceive(NotificationCenter.default.publisher(for: .rewindPageDidLoad)) { _ in - isRewindPageLoading = false - } - .onReceive(NotificationCenter.default.publisher(for: .conversationsPageDidLoad)) { _ in - isConversationsPageLoading = false - } - .onReceive(NotificationCenter.default.publisher(for: .tasksPageDidLoad)) { _ in - isTasksPageLoading = false - } - .onReceive(NotificationCenter.default.publisher(for: .appsPageDidLoad)) { _ in - isAppsPageLoading = false - } - } - - // MARK: - Header Section (Logo + Collapse Button on same row) - private var headerSection: some View { - HStack(spacing: OmiSpacing.md) { - // SBLogo resolves the packaged mark through signed-app, development, and - // preview layouts, and retains the eight-dot Omi silhouette if an asset - // host is incomplete. Never substitute a generic solid circle here. - SBLogo(size: iconWidth, tint: Ink.primary) - - if !isCollapsed { - // Brand name - Text(UpdateChannel.appDisplayName) - .scaledFont(size: OmiType.heading, weight: .bold) - .foregroundColor(Ink.primary) - .tracking(-0.5) - - Spacer() - - // Collapse button - Button(action: { - OmiMotion.withGated(.easeInOut(duration: 0.2)) { - isCollapsed.toggle() - } - }) { - Image(systemName: "sidebar.left") - .scaledFont(size: OmiType.subheading) - .foregroundColor(Ink.secondary) - } - .buttonStyle(.plain) - .help("Collapse sidebar") - } else { - // When collapsed, just show collapse button below logo - Spacer() - } - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - } - - // Collapse button for collapsed state (shown separately) - private var collapsedExpandButton: some View { - Button(action: { - OmiMotion.withGated(.easeInOut(duration: 0.2)) { - isCollapsed.toggle() - } - }) { - Image(systemName: "sidebar.left") - .scaledFont(size: OmiType.subheading) - .foregroundColor(Ink.secondary) - .frame(width: iconWidth) - } - .buttonStyle(.plain) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .help("Expand sidebar") - } - - private var proBadge: some View { - Text("Pro") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundColor(Ink.primary) - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.hairline) - .background( - RoundedRectangle(cornerRadius: OmiChrome.badgeRadius, style: .continuous) - .fill(Ink.rowFillHover) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.badgeRadius, style: .continuous) - .strokeBorder(Ink.separator, lineWidth: 1) - ) - ) - } - - // MARK: - Profile Menu - - private var shouldShowScreenRecordingStatus: Bool { - appState.hasScreenRecordingPermission || !appState.hasScreenRecordingPermission - || appState.isScreenCaptureKitBroken - || appState.isScreenRecordingStale - } - - private var shouldShowMicrophoneStatus: Bool { - appState.hasMicrophonePermission || !appState.hasMicrophonePermission - } - - private var shouldShowAccessibilityStatus: Bool { - !appState.hasAccessibilityPermission || appState.isAccessibilityBroken - } - - private var hasVisibleSidebarStatuses: Bool { - shouldShowScreenRecordingStatus || shouldShowMicrophoneStatus || shouldShowAccessibilityStatus - } - - private var profileDisplayName: String { - let displayName = AuthService.shared.displayName.trimmingCharacters(in: .whitespacesAndNewlines) - if !displayName.isEmpty { - return displayName - } - - if let email = authState.userEmail, !email.isEmpty { - return email.components(separatedBy: "@").first ?? email - } - - return "Profile" - } - - private var profileInitials: String { - let parts = - profileDisplayName - .split(separator: " ") - .prefix(2) - .compactMap { $0.first } - - let initials = String(parts) - if !initials.isEmpty { - return initials.uppercased() - } - - return "OM" - } - - private var profileMenuButton: some View { - Button { - // Straight to Settings — no intermediate menu popover. - selectedIndex = SidebarNavItem.settings.rawValue - } label: { - HStack(spacing: isCollapsed ? 0 : 10) { - ZStack { - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill(Ink.rowFillHover) - .frame(width: 34, height: 34) - - Image(systemName: "gearshape.fill") - .scaledFont(size: OmiType.subheading, weight: .semibold) - .foregroundColor(Ink.secondary) - } - - if !isCollapsed { - Text("Settings") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(Ink.primary) - .lineLimit(1) - - Spacer(minLength: 8) - } - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .frame(maxWidth: .infinity, alignment: isCollapsed ? .center : .leading) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) - .fill( - selectedIndex == SidebarNavItem.settings.rawValue || isProfileButtonHovered - ? Ink.rowFillHover : Color.clear - ) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in - isProfileButtonHovered = hovering - } - .help("Open Settings") - } - - // MARK: - Permission Warning Button - - // Check if any permission is specifically denied (not just missing) - private var hasPermissionDenied: Bool { - appState.isMicrophonePermissionDenied() || appState.isScreenRecordingPermissionDenied() - || appState.isAccessibilityPermissionDenied() - } - - @State private var permissionPulse = false - - private func updatePermissionPulse(_ denied: Bool) { - if denied { - OmiMotion.withGated(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { - permissionPulse = true - } - } else { - permissionPulse = false - } - } - - private var permissionStatusSection: some View { - VStack(spacing: OmiSpacing.xs) { - if shouldShowScreenRecordingStatus { - screenRecordingPermissionRow(isExpanded: !isCollapsed) - } - - if shouldShowMicrophoneStatus { - microphonePermissionRow(isExpanded: !isCollapsed) - } - - if shouldShowAccessibilityStatus { - accessibilityPermissionRow(isExpanded: !isCollapsed) - } - } - } - - @ViewBuilder - private func screenRecordingPermissionRow(isExpanded: Bool) -> some View { - let isDenied = appState.isScreenRecordingPermissionDenied() - let isBroken = appState.isScreenCaptureKitBroken // TCC yes but SCK no - let isStale = appState.isScreenRecordingStale // Developer signing changed - let isToggleable = appState.hasScreenRecordingPermission && !isBroken && !isStale - let isActive = screenAnalysisEnabled && isToggleable - let needsReset = isBroken // Show reset when broken (not stale — stale needs toggle off/on) - let color: Color = - isToggleable - ? Ink.secondary - : Ink.errorRed // Denied, broken or stale read as one failure. - let titleColor: Color = - isToggleable ? Ink.secondary : color - - let row = HStack(spacing: OmiSpacing.sm) { - Image( - systemName: (isDenied || isBroken || isStale) - ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle" - ) - .scaledFont(size: OmiType.subheading) - .foregroundColor(color) - .frame(width: iconWidth) - .scaleEffect(permissionPulse && (isDenied || isBroken || isStale) ? 1.1 : 1.0) - - if isExpanded { - Text("Screen Recording") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(titleColor) - .lineLimit(1) - - Spacer() - - if isToggleable { - statusAccessoryToggle(isOn: isActive) - } else { - Button(action: { - if isStale { - // Stale/corrupted TCC — navigate to Permissions page with full instructions - selectedIndex = SidebarNavItem.permissions.rawValue - } else if needsReset { - // Track reset button click - AnalyticsManager.shared.screenCaptureResetClicked(source: "sidebar_button") - // Reset and restart to fix broken ScreenCaptureKit state - ScreenCaptureService.resetScreenCapturePermissionAndRestart() - } else { - ScreenCaptureService.requestScreenRecordingAccessAndOpenSettings() - // Track attempt — if still not granted on next check, show recovery instructions - appState.screenRecordingGrantAttempts += 1 - } - }) { - Text(isStale ? "Fix" : (needsReset ? "Reset" : "Grant")) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundColor(.white) - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.xxs) - .background( - RoundedRectangle(cornerRadius: OmiChrome.badgeRadius) - .fill(color) - ) - } - .buttonStyle(.plain) - } - } - } - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.xs) - .frame(minHeight: 40) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill( - isToggleable - ? Color.clear - : color.opacity(permissionPulse && (isDenied || isBroken || isStale) ? 0.25 : 0.15) - ) - ) - .help( - isToggleable - ? (isActive - ? "Click to turn off Screen Recording monitoring" - : "Click to turn on Screen Recording monitoring") - : (isExpanded - ? "" - : (isStale - ? "Screen Recording needs re-enabling" - : (isBroken ? "Screen Recording needs reset" : "Screen Recording permission required"))) - ) - - if isToggleable { - Button(action: { - toggleMonitoring(enabled: !screenAnalysisEnabled) - }) { - row - } - .buttonStyle(.plain) - } else { - row - } - } - - @ViewBuilder - private func microphonePermissionRow(isExpanded: Bool) -> some View { - let isDenied = appState.isMicrophonePermissionDenied() - let isToggleable = appState.hasMicrophonePermission - let isActive = audioRecordingModeRaw != AssistantSettings.AudioRecordingMode.off.rawValue && isToggleable - let color: Color = - isToggleable - ? Ink.secondary - : Ink.errorRed - let titleColor: Color = - isToggleable ? Ink.secondary : color - - let row = HStack(spacing: OmiSpacing.sm) { - Image(systemName: isDenied ? "mic.slash.fill" : "mic.fill") - .scaledFont(size: OmiType.subheading) - .foregroundColor(color) - .frame(width: iconWidth) - .scaleEffect(permissionPulse && isDenied ? 1.1 : 1.0) - - if isExpanded { - Text("Microphone") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(titleColor) - .lineLimit(1) - - Spacer() - - if isToggleable { - statusAccessoryToggle(isOn: isActive) - } else { - Button(action: { - if isDenied { - // Go to permissions page for reset options - selectedIndex = SidebarNavItem.permissions.rawValue - } else { - // Request permission directly - appState.requestMicrophonePermission() - } - }) { - Text(isDenied ? "Fix" : "Grant") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundColor(.white) - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.xxs) - .background( - RoundedRectangle(cornerRadius: OmiChrome.badgeRadius) - .fill(color) - ) - } - .buttonStyle(.plain) - } - } - } - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.xs) - .frame(minHeight: 40) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill( - isToggleable - ? Color.clear - : color.opacity(permissionPulse && isDenied ? 0.25 : 0.15) - ) - ) - .help( - isToggleable - ? (isActive - ? "Click to turn off Microphone transcription" - : "Click to turn on Microphone transcription") - : (isExpanded ? "" : "Microphone permission required") - ) - - if isToggleable { - Button(action: { - toggleTranscription(enabled: audioRecordingModeRaw == AssistantSettings.AudioRecordingMode.off.rawValue) - }) { - row - } - .buttonStyle(.plain) - } else { - row - } - } - - private func accessibilityPermissionRow(isExpanded: Bool) -> some View { - let isDenied = appState.isAccessibilityPermissionDenied() - let isBroken = appState.isAccessibilityBroken // TCC yes but AX calls fail - let needsReset = isBroken // Show reset when broken - let color: Color = Ink.errorRed - - return HStack(spacing: OmiSpacing.sm) { - Image(systemName: (isDenied || isBroken) ? "hand.raised.slash.fill" : "hand.raised.fill") - .scaledFont(size: OmiType.subheading) - .foregroundColor(color) - .frame(width: iconWidth) - .scaleEffect(permissionPulse && (isDenied || isBroken) ? 1.1 : 1.0) - - if isExpanded { - Text("Accessibility") - .scaledFont(size: OmiType.body, weight: .medium) - .foregroundColor(color) - .lineLimit(1) - - Spacer() - - Button(action: { - if needsReset { - // Reset and restart to fix broken accessibility state - appState.resetAccessibilityPermissionAndRestart() - } else { - // Trigger the permission request, which will also open settings - appState.triggerAccessibilityPermission() - } - }) { - Text(needsReset ? "Reset" : (isDenied ? "Fix" : "Grant")) - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundColor(.white) - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.xxs) - .background( - RoundedRectangle(cornerRadius: OmiChrome.badgeRadius) - .fill(color) - ) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, OmiSpacing.sm) - .padding(.vertical, OmiSpacing.xs) - .frame(minHeight: 40) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(color.opacity(permissionPulse && (isDenied || isBroken) ? 0.25 : 0.15)) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .stroke(color.opacity(0.3), lineWidth: (isDenied || isBroken) ? 2 : 1) - ) - ) - .help( - isExpanded - ? "" : (isBroken ? "Accessibility needs reset" : "Accessibility permission required")) - } - - private func statusAccessoryToggle(isOn: Bool) -> some View { - ZStack(alignment: isOn ? .trailing : .leading) { - Capsule() - .fill( - isOn - ? Ink.listeningGreen.opacity(0.9) : Ink.rowFillHover - ) - .frame(width: 30, height: 18) - - Circle() - .fill(.white.opacity(isOn ? 0.98 : 0.92)) - .frame(width: 14, height: 14) - .padding(OmiSpacing.hairline) - } - .padding(.trailing, OmiSpacing.hairline) - .omiAnimation(.easeInOut(duration: 0.16), value: isOn) - .accessibilityHidden(true) - } - - // MARK: - Toggle Handlers - - private func toggleTranscription(enabled: Bool) { - // Check microphone permission - if enabled && !appState.hasMicrophonePermission { - return - } - - // Show loading immediately - isTogglingTranscription = true - - // Track setting change - AnalyticsManager.shared.settingToggled(setting: "transcription", enabled: enabled) - - let mode: AssistantSettings.AudioRecordingMode = enabled ? .onlyMeetings : .off - audioRecordingModeRaw = mode.rawValue - AssistantSettings.shared.audioRecordingMode = mode - - // Small delay to show the loading state visually - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isTogglingTranscription = false - } - } - - private func toggleMonitoring(enabled: Bool) { - if enabled { - // Refresh permission cache before checking (may be stale after user granted access) - ProactiveAssistantsPlugin.shared.refreshScreenRecordingPermission() - } - - if enabled && !ProactiveAssistantsPlugin.shared.hasScreenRecordingPermission { - isMonitoring = false - ScreenCaptureService.requestScreenRecordingAccessAndOpenSettings() - return - } - - // Show loading immediately and update state optimistically - isTogglingMonitoring = true - isMonitoring = enabled - - // Track setting change - AnalyticsManager.shared.settingToggled(setting: "monitoring", enabled: enabled) - - // Persist the setting - screenAnalysisEnabled = enabled - AssistantSettings.shared.screenAnalysisEnabled = enabled - - if enabled { - ProactiveAssistantsPlugin.shared.startMonitoring { success, _ in - DispatchQueue.main.async { - isTogglingMonitoring = false - if !success { - // Revert on failure including persistent setting - isMonitoring = false - screenAnalysisEnabled = false - AssistantSettings.shared.screenAnalysisEnabled = false - } - } - } - } else { - ProactiveAssistantsPlugin.shared.stopMonitoring() - // Small delay to show the loading state visually - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isTogglingMonitoring = false - } - } - } - - private func syncMonitoringState() { - let pluginState = ProactiveAssistantsPlugin.shared.isMonitoring - isMonitoring = pluginState - // Don't touch screenAnalysisEnabled here — it represents the user's preference, - // not the current monitoring state. Auto-restart below will handle recovery. - } - - // MARK: - Page Loading Helpers - - private func pageLoadingState(for item: SidebarNavItem) -> Bool { - switch item { - case .tasks: return isTasksPageLoading - case .apps: return isAppsPageLoading - default: return false - } - } - - private func setPageLoading(for item: SidebarNavItem, loading: Bool) { - switch item { - case .tasks: isTasksPageLoading = loading - case .apps: isAppsPageLoading = loading - default: break - } - } - - // MARK: - Tier Unlock Animation - -} - -// MARK: - Nav Item View -struct NavItemView: View { - let icon: String - let label: String - let isSelected: Bool - let isCollapsed: Bool - let iconWidth: CGFloat - var statusColor: Color? = nil - var isLoading: Bool = false - var isLocked: Bool = false - var lockTooltip: String? = nil - var onUnlock: (() -> Void)? = nil - let onTap: () -> Void - - @State private var isHovered = false - @State private var isLockHovered = false - - /// Foreground color for icon and text when locked - private var lockedColor: Color { Ink.secondary.opacity(0.45) } - - var body: some View { - HStack(spacing: OmiSpacing.md) { - ZStack(alignment: .topTrailing) { - if isLoading && !isLocked { - ProgressView() - .scaleEffect(0.5) - .frame(width: iconWidth, height: 17) - } else { - Image(systemName: icon) - .scaledFont(size: OmiType.subheading) - .foregroundColor( - isLocked ? lockedColor : (isSelected ? Ink.primary : Ink.secondary) - ) - .frame(width: iconWidth) - } - - // Status indicator when collapsed, hidden when locked - if isCollapsed, let color = statusColor, !isLocked { - Circle() - .fill(color) - .frame(width: 8, height: 8) - .offset(x: 4, y: -4) - } - - // Lock badge when collapsed — clickable - if isCollapsed && isLocked { - lockIcon(size: 8) - .offset(x: 4, y: -4) - } - } - - if !isCollapsed { - Text(label) - .scaledFont(size: OmiType.body, weight: isSelected ? .medium : .regular) - .foregroundColor( - isLocked ? lockedColor : (isSelected ? Ink.primary : Ink.secondary)) - - Spacer() - - if isLocked { - // Clickable lock icon - lockIcon(size: 10) - } else { - // Status indicator when expanded (for Focus) - if let color = statusColor { - Circle() - .fill(color) - .frame(width: 8, height: 8) - } - - // Badge count now shown on icon (see ZStack above) - } - } - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .fill( - isLocked - ? Color.clear - : (isSelected - ? Ink.rowFillHover - : (isHovered ? Ink.rowFill : Color.clear)) - ) - ) - .onTapGesture { - guard !isLocked else { return } - log("SIDEBAR: NavItem '\(label)' tapped at mouse position: \(NSEvent.mouseLocation)") - onTap() - } - .onHover { hovering in - isHovered = isLocked ? false : hovering - } - .padding(.bottom, OmiSpacing.hairline) - .help(isCollapsed ? label : "") - .accessibilityLabel(label) - .accessibilityAddTraits(.isButton) - .accessibilityIdentifier( - "sidebar_\(label.lowercased().replacingOccurrences(of: " ", with: "_"))") - } - - /// Lock icon that reacts on hover and unlocks on click - private func lockIcon(size: CGFloat) -> some View { - Image(systemName: isLockHovered ? "lock.open.fill" : "lock.fill") - .scaledFont(size: size) - .foregroundColor(isLockHovered ? Ink.primary : lockedColor) - .padding(OmiSpacing.xxs) - .contentShape(Rectangle()) - .onHover { hovering in - OmiMotion.withGated(.easeInOut(duration: 0.15)) { - isLockHovered = hovering - } - } - .onTapGesture { - onUnlock?() - } - .help("Click to unlock") - } -} - -// MARK: - Nav Item With Status Icon View -/// Navigation item that shows status via icon color/animation instead of a toggle -struct NavItemWithStatusView: View { - let icon: String - let label: String - let isSelected: Bool - let isCollapsed: Bool - let iconWidth: CGFloat - let isOn: Bool - let isToggling: Bool - var isPageLoading: Bool = false - let onTap: () -> Void - let onToggle: () -> Void - - // Optional audio levels for conversations - var micLevel: Float = 0 - var systemLevel: Float = 0 - var showAudioBars: Bool = false - - // Optional Rewind pulsing icon - var showRewindIcon: Bool = false - - @State private var isHovered = false - - /// Icon color based on state - private var iconColor: Color { - if isOn { - return isSelected ? Ink.primary : Ink.secondary - } else { - return Ink.errorRed - } - } - - var body: some View { - HStack(spacing: OmiSpacing.md) { - // Icon area - tappable to toggle - ZStack(alignment: .topTrailing) { - // Show loading spinner in place of icon when loading - if isToggling || isPageLoading { - ProgressView() - .scaleEffect(0.5) - .frame(width: iconWidth, height: 17) - } else if showAudioBars && isOn { - // Show audio bars when active and enabled for conversations - SidebarAudioLevelIcon( - micLevel: micLevel, - systemLevel: systemLevel, - isActive: true - ) - .frame(width: iconWidth) - } else if showRewindIcon { - // Show pulsing Rewind icon - SidebarRewindIcon(isActive: isOn) - .frame(width: iconWidth) - } else { - Image(systemName: icon) - .scaledFont(size: OmiType.subheading) - .foregroundColor(iconColor) - .frame(width: iconWidth) - } - - // Status indicator when collapsed and off - if isCollapsed && !isOn && !isToggling && !isPageLoading { - Circle() - .fill(Ink.errorRed) - .frame(width: 6, height: 6) - .offset(x: 3, y: -3) - } - } - .contentShape(Rectangle()) - .onTapGesture { - if !isToggling { - onToggle() - } - } - - if !isCollapsed { - Text(label) - .scaledFont(size: OmiType.body, weight: isSelected ? .medium : .regular) - .foregroundColor(isSelected ? Ink.primary : Ink.secondary) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - - Spacer(minLength: 4) - } - } - .padding(.leading, OmiSpacing.md) - .padding(.trailing, isCollapsed ? OmiSpacing.md : OmiSpacing.sm) - .padding(.vertical, OmiSpacing.md) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: OmiChrome.chipRadius, style: .continuous) - .fill( - isSelected - ? Ink.rowFillHover - : (isHovered ? Ink.rowFill : Color.clear) - ) - ) - .onTapGesture { - log( - "SIDEBAR: NavItemWithStatus '\(label)' row tapped at mouse position: \(NSEvent.mouseLocation)" - ) - onTap() - } - .onHover { hovering in - isHovered = hovering - } - .padding(.bottom, OmiSpacing.hairline) - .help( - isCollapsed - ? "\(label) (\(isOn ? "On" : "Off")) - Click icon to toggle" : "Click icon to toggle" - ) - .accessibilityLabel("\(label) (\(isOn ? "On" : "Off"))") - .accessibilityAddTraits(.isButton) - .accessibilityIdentifier( - "sidebar_\(label.lowercased().replacingOccurrences(of: " ", with: "_"))") - } -} - -// MARK: - Custom Sidebar Toggle -struct SidebarToggle: View { - @Binding var isOn: Bool - - private let width: CGFloat = 36 - private let height: CGFloat = 20 - private let circleSize: CGFloat = 16 - private let padding: CGFloat = 2 - - var body: some View { - ZStack(alignment: isOn ? .trailing : .leading) { - // Track — primary ink on, outlined wash off: "off" rests, it does not fail. - Capsule() - .fill(isOn ? Ink.primary : Ink.rowFillHover) - .overlay(Capsule().strokeBorder(isOn ? Color.clear : Ink.hairline, lineWidth: 1)) - .frame(width: width, height: height) - - // Thumb — the label ladder inverted on the filled track, the ink itself off it. - Circle() - .fill(isOn ? Ink.surface : Ink.primary) - .frame(width: circleSize, height: circleSize) - .padding(padding) - } - .omiAnimation(.easeInOut(duration: 0.15), value: isOn) - .onTapGesture { - isOn.toggle() - } - } -} - -// MARK: - Sidebar Audio Level Icon -/// Compact audio level indicator that fits in the sidebar icon space -struct SidebarAudioLevelIcon: View { - let micLevel: Float - let systemLevel: Float - let isActive: Bool - - private let barCount = 4 - private let iconSize: CGFloat = 17 - - /// Combined audio level (max of mic and system) - private var combinedLevel: Float { - max(micLevel, systemLevel) - } - - var body: some View { - HStack(spacing: OmiSpacing.hairline) { - ForEach(0.. 0.5 { - return Ink.primary - } else if boostedLevel > 0.15 { - return Ink.primary - } else if boostedLevel > 0.02 { - return Ink.secondary - } - return Ink.secondary - } - - var body: some View { - RoundedRectangle(cornerRadius: 1) - .fill(barColor) - .frame(width: barWidth, height: barHeight) - } -} - -// MARK: - Sidebar Rewind Icon -/// Animated recording indicator for Rewind when capturing -struct SidebarRewindIcon: View { - let isActive: Bool - - private let iconSize: CGFloat = 17 - - @State private var isPulsing = false - - var body: some View { - ZStack { - // Outer pulsing ring when active - if isActive { - Circle() - .stroke(Ink.primary.opacity(0.3), lineWidth: 2) - .frame(width: iconSize, height: iconSize) - .scaleEffect(isPulsing ? 1.4 : 1.0) - .opacity(isPulsing ? 0 : 0.8) - } - - // Inner recording dot - Circle() - .fill(isActive ? Ink.primary : Ink.errorRed) - .frame(width: isActive ? 10 : 8, height: isActive ? 10 : 8) - } - .frame(width: iconSize, height: iconSize) - .onAppear { - if isActive { - startPulsing() - } - } - .onChange(of: isActive) { _, newValue in - if newValue { - startPulsing() - } else { - isPulsing = false - } - } - } - - private func startPulsing() { - OmiMotion.withGated(.easeOut(duration: 1.0).repeatForever(autoreverses: false)) { - isPulsing = true - } - } -} - -// MARK: - Bottom Nav Item View -struct BottomNavItemView: View { - let icon: String - let label: String - let isCollapsed: Bool - let iconWidth: CGFloat - let onTap: () -> Void - - @State private var isHovered = false - - var body: some View { - HStack(spacing: OmiSpacing.md) { - Image(systemName: icon) - .scaledFont(size: OmiType.subheading) - .foregroundColor(Ink.secondary) - .frame(width: iconWidth) - - if !isCollapsed { - Text(label) - .scaledFont(size: OmiType.body, weight: .regular) - .foregroundColor(Ink.secondary) - - Spacer() - } - } - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.md) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius) - .fill(isHovered ? Ink.rowFillHover : Color.clear) - ) - .onTapGesture { - log("SIDEBAR: BottomNavItem '\(label)' tapped at mouse position: \(NSEvent.mouseLocation)") - onTap() - } - .onHover { hovering in - isHovered = hovering - } - .padding(.bottom, OmiSpacing.hairline) - .help(isCollapsed ? label : "") - .accessibilityLabel(label) - .accessibilityAddTraits(.isButton) - .accessibilityIdentifier( - "sidebar_\(label.lowercased().replacingOccurrences(of: " ", with: "_"))") - } -} - -// MARK: - Audio Level Nav Item Wrapper - -/// Isolates AudioLevelMonitor observation so audio level changes -/// only re-render this small wrapper, not the entire SidebarView. -private struct AudioLevelNavItem: View { - let icon: String - let label: String - let isSelected: Bool - let isCollapsed: Bool - let iconWidth: CGFloat - let isOn: Bool - let isToggling: Bool - var isPageLoading: Bool = false - let onTap: () -> Void - let onToggle: () -> Void - - @ObservedObject private var audioLevels = AudioLevelMonitor.shared - - var body: some View { - NavItemWithStatusView( - icon: icon, - label: label, - isSelected: isSelected, - isCollapsed: isCollapsed, - iconWidth: iconWidth, - isOn: isOn, - isToggling: isToggling, - isPageLoading: isPageLoading, - onTap: onTap, - onToggle: onToggle, - micLevel: audioLevels.microphoneLevel, - systemLevel: audioLevels.systemLevel, - showAudioBars: true - ) - } -} - -// MARK: - Cached Omi Device Image - -/// Cache the Omi device WebP image so it's decoded once, not on every SwiftUI body evaluation. -/// The original 1383x1383 WebP was being re-decoded by CoreAnimation every render frame. -enum OmiDeviceImage { - @MainActor static let shared: NSImage? = { - guard - let url = Bundle.resourceBundle.url( - forResource: "omi-with-rope-no-padding", withExtension: "webp") - else { - return nil - } - return NSImage(contentsOf: url) - }() -} - -// MARK: - App Nav Rail (Second Brain) - -/// The thin, always-present left navigation rail for the redesigned app shell. -/// Lives beside every page (not just Home) so you can move between Home, the -/// memory/task surfaces and Apps without bouncing back through Home. Settings -/// sits at the foot. Styled with the SB ink system so it matches the sign-in / -/// onboarding aesthetic. -struct AppNavRail: View { - @Binding var selectedIndex: Int - @State private var isExpanded = false - - /// Rail width at rest (icons only) and expanded (icons + labels). - static let restWidth: CGFloat = 60 - static let expandedWidth: CGFloat = 216 - - private struct RailItem: Hashable { - let index: Int - let title: String - let icon: String - } - - /// Simplified, merged navigation: "Memory" folds in Conversations + Memories, - /// and Rewind moved off the rail (it opens from a right-click on Capture). - /// Each entry drives selectedIndex. - private var items: [RailItem] { - [ - RailItem(index: SidebarNavItem.dashboard.rawValue, title: "Home", icon: "house.fill"), - RailItem(index: SidebarNavItem.conversations.rawValue, title: "Memory", icon: "brain"), - RailItem(index: SidebarNavItem.tasks.rawValue, title: "Tasks", icon: "checklist"), - RailItem(index: SidebarNavItem.apps.rawValue, title: "Apps", icon: "puzzlepiece.fill"), - ] - } - - var body: some View { - VStack(spacing: 4) { - ForEach(items, id: \.self) { item in - AppNavRailButton( - icon: item.icon, - title: item.title, - isSelected: selectedIndex == item.index, - isExpanded: isExpanded, - action: { select(item.index, title: item.title) } - ) - } - - Spacer(minLength: 12) - - AppNavRailButton( - icon: SidebarNavItem.settings.icon, - title: SidebarNavItem.settings.title, - isSelected: selectedIndex == SidebarNavItem.settings.rawValue, - isExpanded: isExpanded, - action: { select(SidebarNavItem.settings.rawValue, title: "Settings") } - ) - } - .padding(.vertical, 16) - .padding(.horizontal, 10) - .frame(width: isExpanded ? Self.expandedWidth : Self.restWidth, alignment: .leading) - .frame(maxHeight: .infinity, alignment: .top) - // No ground of its own — the shell's glass is under this. Expanded, the rail - // covers content, so it becomes real glass with the one ambient shadow. - .background { if isExpanded { Color.clear.glassFloatingBar(cornerRadius: PageGlass.cardRadius) } } - .overlay(alignment: .trailing) { - Rectangle().fill(Ink.separator).frame(width: 1).opacity(isExpanded ? 0 : 1) - } - .contentShape(Rectangle()) - .onHover { hovering in - withAnimation(.easeOut(duration: 0.18)) { isExpanded = hovering } - } - } - - private func select(_ index: Int, title: String) { - guard selectedIndex != index else { return } - selectedIndex = index - AnalyticsManager.shared.tabChanged(tabName: title) - } -} - -private struct AppNavRailButton: View { - let icon: String - let title: String - let isSelected: Bool - let isExpanded: Bool - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 12) { - Image(systemName: icon) - .font(.system(size: 15, weight: .medium)) - .frame(width: 40, height: 40) - - if isExpanded { - Text(title) - .geist(size: 14, weight: isSelected ? .medium : .regular) - .lineLimit(1) - .fixedSize() - } - } - .foregroundStyle(isSelected || isHovering ? Ink.primary : Ink.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 40) - // A stadium, like every other pressable thing in this system. - .background(GlassPillBackground(isSelected: isSelected, isHovering: isHovering)) - .contentShape(Capsule(style: .continuous)) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .help(title) - .accessibilityLabel(title) - } -} diff --git a/desktop/macos/Desktop/Sources/ViewExporter.swift b/desktop/macos/Desktop/Sources/ViewExporter.swift index 4a13eb521fc..54fed1f2579 100644 --- a/desktop/macos/Desktop/Sources/ViewExporter.swift +++ b/desktop/macos/Desktop/Sources/ViewExporter.swift @@ -51,21 +51,6 @@ enum ViewExporter { CGSize(width: 900, height: 600) ), - ( - "02-dashboard", - { - AnyView( - DashboardPage( - viewModel: DashboardViewModel(), - appState: AppState(), - appProvider: AppProvider(), - chatProvider: previewChatProvider(), - memoriesViewModel: MemoriesViewModel(), - selectedIndex: .constant(0))) - }, - CGSize(width: 900, height: 700) - ), - ( "04-conversations", { AnyView(ConversationsPage(appState: AppState(), selectedConversation: .constant(nil))) }, @@ -102,12 +87,6 @@ enum ViewExporter { CGSize(width: 900, height: 600) ), - ( - "13-daily-score", - { AnyView(DailyScoreWidget(dailyScore: nil)) }, - CGSize(width: 400, height: 350) - ), - ( "14-chat-sessions", { AnyView(ChatSessionsSidebar(chatProvider: ChatProvider())) }, @@ -278,19 +257,6 @@ enum ViewExporter { static func fullPageViewAt(_ index: Int) -> (String, AnyView, CGSize)? { // Pages that can be shown with the sidebar let pages: [(String, Int, () -> AnyView)] = [ - ( - "full-dashboard", 0, - { - AnyView( - DashboardPage( - viewModel: DashboardViewModel(), - appState: AppState(), - appProvider: AppProvider(), - chatProvider: ChatProvider(), - memoriesViewModel: previewMemoriesViewModel(), - selectedIndex: .constant(0))) - } - ), ( "full-conversations", 1, { diff --git a/desktop/macos/Desktop/Sources/ViewModelContainer.swift b/desktop/macos/Desktop/Sources/ViewModelContainer.swift index a486d51216f..e7c885491d3 100644 --- a/desktop/macos/Desktop/Sources/ViewModelContainer.swift +++ b/desktop/macos/Desktop/Sources/ViewModelContainer.swift @@ -8,7 +8,7 @@ class ViewModelContainer: ObservableObject { let tasksStore = TasksStore.shared /// Universal canonical goal projection. It is injected only by the /// capability-gated chat-first shell. - let canonicalGoalsStore = CanonicalGoalsStore() + let canonicalGoalsStore = CanonicalGoalsStore.shared /// Process-launch anchor for startup warmups. Captured at container init /// (≈ app launch) so post-onboarding / late main-content appearance does /// not re-pay launch-protection delays. From 176608a01e66e09f9a81f451c8ab2aa06be53cbb Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 02:58:42 -0400 Subject: [PATCH 02/29] feat(desktop): render every content block as an interactable component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six of the journal's block kinds — question card, task card, goal link, capture link, conversation link, memory link — were dropped on the floor by every Chat surface except one. `ContentBlockGroup.group` skipped them unless `richBlockRenderingEnabled`, and `ChatBubble.blockView` returned `EmptyView` for each of them again. A turn whose whole content was a task card therefore read as an empty assistant reply in the task panel and in the notch, and as a card you could tick off in the main window. `ChatFirstRichBlockContext` is now non-optional on `QueryShellHome`, `QueryAnswerThread`, `ChatMessagesView` and `ChatBubble`; the task panel and the floating/notch renderers bind the shell's process-wide owners through `.auxiliary`, so a card tapped in the notch summons the main window and routes the one shell. `ChatFirstRichBlockGroupView` is the single renderer all three hosts share. Capability-off degrades rather than disappears: cards render, task check-off works (it binds `TasksStore`, not the projection), links navigate, and a question card shows its options dimmed and unpressable with an explicit "Answering is unavailable right now" line, instead of a question with no visible answers. Co-Authored-By: Claude Fable 5.1 --- .../FloatingControlBar/AIResponseView.swift | 15 ++- .../FloatingControlBarView.swift | 14 +- .../Blocks/ChatFirstContentBlockViews.swift | 19 ++- .../Blocks/ChatFirstRichBlockContext.swift | 10 ++ .../Blocks/ChatFirstRichBlockGroupView.swift | 86 +++++++++++++ .../MainWindow/Components/ChatBubble.swift | 120 +++--------------- .../Components/ChatMessagesView.swift | 16 +-- .../MainWindow/Components/TaskChatPanel.swift | 3 + .../QueryShell/QueryAnswerThread.swift | 42 +----- .../TaskAgent/TaskChatCoordinator.swift | 5 +- .../Sources/Providers/ChatProvider.swift | 9 ++ 11 files changed, 188 insertions(+), 151 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockGroupView.swift diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift index 5c82d8286df..294e3a2aa37 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift @@ -217,10 +217,19 @@ struct AIResponseView: View { case .discoveryCard(_, let title, let summary, let fullText): DiscoveryCard(title: title, summary: summary, fullText: fullText) .frame(maxWidth: .infinity, alignment: .leading) - // The floating/notch surface never opts into rich chat-first controls. - // Keep journaled blocks inert if an older runtime projects them here. + // The notch projects the same journal as the main window, so it renders + // the same interactable cards. Taps route the one shell and summon the + // main window (`ChatFirstRichBlockContext.auxiliary`). case .questionCard, .taskCard, .goalLink, .captureLink, .conversationLink, .memoryLink: - EmptyView() + if let context = ChatFirstRichBlockContext.floatingSurface { + ChatFirstRichBlockGroupView( + group: group, + messageID: message.id, + context: context + ) + .environment(\.colorScheme, .light) + .frame(maxWidth: .infinity, alignment: .leading) + } case .agentSpawn( _, let pillId, let sessionId, let runId, let title, let objective, let provider ): diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift index 6afca454471..f7c294952d6 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift @@ -2172,9 +2172,19 @@ private struct AgentMainChatView: View { case .discoveryCard(_, let title, let summary, let fullText): DiscoveryCard(title: title, summary: summary, fullText: fullText) .frame(maxWidth: .infinity, alignment: .leading) - // Rich controls are main-chat-only; floating/notch stays passive. + // The notch projects the same journal as the main window, so it + // renders the same interactable cards. Taps route the one shell and + // summon the main window (`ChatFirstRichBlockContext.auxiliary`). case .questionCard, .taskCard, .goalLink, .captureLink, .conversationLink, .memoryLink: - EmptyView() + if let context = ChatFirstRichBlockContext.floatingSurface { + ChatFirstRichBlockGroupView( + group: group, + messageID: message.id, + context: context + ) + .environment(\.colorScheme, .light) + .frame(maxWidth: .infinity, alignment: .leading) + } case .agentSpawn( _, let pillId, let sessionId, let runId, let title, let objective, let provider ): diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift index a8523d16d56..b385adc7a8b 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift @@ -30,6 +30,10 @@ struct QuestionCardView: View { let options: [[String: Any]] let selectedOptionID: String? let isActionable: Bool + /// False while the server-owned capability has not resolved, or for an account + /// it does not cover. The options still render — a question with its answers + /// hidden reads as a question nobody asked — but they cannot be pressed. + let isCapabilityAvailable: Bool let onSelect: (String, Bool) -> Void private var validOptions: [Option] { options.compactMap(Option.init) } @@ -48,7 +52,11 @@ struct QuestionCardView: View { // A completed question remains useful transcript context, but its // suggestions disappear as soon as an answer exists or another bubble // has taken the tail. We never leave stale chips that look tappable. - if isActionable, selectedOptionID == nil, !validOptions.isEmpty { + // + // Capability-off is the one case that shows the chips *without* making + // them pressable: the question is real and its answers are the only thing + // that explains it, so they are dimmed rather than deleted. + if selectedOptionID == nil, !validOptions.isEmpty, isActionable || !isCapabilityAvailable { FlowLayout(spacing: OmiSpacing.sm) { ForEach(validOptions) { option in Button { @@ -62,10 +70,19 @@ struct QuestionCardView: View { .glassChip() } .buttonStyle(.plain) + .disabled(!isActionable) + .opacity(isActionable ? 1 : 0.45) .accessibilityLabel("Send suggestion: \(option.label)") .accessibilityIdentifier("chat-first-question-\(questionID)-option-\(option.id)") } } + + if !isActionable { + Text("Answering is unavailable right now") + .scaledFont(size: OmiType.caption) + .foregroundStyle(Ink.secondary) + .accessibilityIdentifier("chat-first-question-\(questionID)-unavailable") + } } } .padding(.horizontal, OmiSpacing.md) diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift index 030d9c37b11..3ddaf77ac75 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift @@ -34,6 +34,16 @@ extension ChatFirstRichBlockContext { /// panel and the floating/notch renderers. They own no navigation or goal /// state, so they bind the shell's process-wide owners: a card tapped in the /// notch routes the main window instead of a private copy of it. + /// The auxiliary context for a surface that has no `ChatProvider` in hand — + /// the floating bar and the notch, which render over `ChatProvider.mainInstance` + /// (INV-6: there is no second provider to fall back to). Nil only before the + /// main window has created it, which is also the only moment those surfaces + /// have no transcript to project. + static var floatingSurface: ChatFirstRichBlockContext? { + guard let provider = ChatProvider.mainInstance else { return nil } + return auxiliary(chatProvider: provider) + } + static func auxiliary(chatProvider: ChatProvider) -> ChatFirstRichBlockContext { ChatFirstRichBlockContext( navigation: .shared, diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockGroupView.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockGroupView.swift new file mode 100644 index 00000000000..d9f3dd5322c --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockGroupView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +/// The one renderer for the six interactable content-block kinds. +/// +/// Main chat, the task panel, and the floating/notch surfaces all project the +/// same journal, so they all render the same cards. This view is what keeps +/// that literal: each host hands it a grouped block plus the message it came +/// from, and gets the same control back. A host that "does not opt into rich +/// controls" used to mean a card silently became `EmptyView` — a turn that read +/// as an empty reply on one surface and a task you could tick off on another. +struct ChatFirstRichBlockGroupView: View { + let group: ContentBlockGroup + /// Identity of the message the block belongs to. `isQuestionCardActionable` + /// is a tail-of-transcript question, so it needs the row, not just the block. + let messageID: String + let context: ChatFirstRichBlockContext + + var body: some View { + switch group { + case .questionCard(_, let questionID, let text, let options, let selectedOptionID): + QuestionCardView( + questionID: questionID, + text: text, + options: options, + selectedOptionID: selectedOptionID, + isActionable: context.chatProvider.isQuestionCardActionable( + messageID: messageID, + questionID: questionID, + selectedOptionID: selectedOptionID + ), + isCapabilityAvailable: context.chatProvider.hasChatFirstMainChatCapability(), + onSelect: { optionID, isDeferral in + Task { @MainActor in + AnalyticsManager.shared.chatFirst( + .question(lifecycle: isDeferral ? .deferred : .answered) + ) + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .questionCard, outcome: .acted, action: .select) + ) + await context.chatProvider.selectQuestionCardOption( + questionID: questionID, + optionID: optionID + ) + } + } + ) + case .taskCard(_, let taskID): + TaskCardView( + taskID: taskID, + tasksStore: context.tasksStore, + navigation: context.navigation + ) + case .goalLink(_, let goalID, let summary): + GoalLinkView( + goalID: goalID, + summary: summary, + navigation: context.navigation, + goalsStore: context.canonicalGoalsStore + ) + case .captureLink(_, let conversationID, let momentTimestampMs, let summary): + CaptureLinkView( + conversationID: conversationID, + momentTimestampMs: momentTimestampMs, + summary: summary, + navigation: context.navigation + ) + case .conversationLink(_, let conversationID, let summary, let recommendedActionItems): + ConversationLinkView( + conversationID: conversationID, + summary: summary, + recommendedActionItems: recommendedActionItems, + navigation: context.navigation + ) + case .memoryLink(_, let memoryID, let summary): + MemoryLinkView( + memoryID: memoryID, + summary: summary, + navigation: context.navigation + ) + case .text, .commentary, .toolCalls, .thinking, .discoveryCard, .agentSpawn, .agentCompletion: + // Not this view's kinds. Exhaustive rather than a `default` so a block + // added later has to state its answer here instead of vanishing. + EmptyView() + } + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift index 3dd1b8565aa..bf6fbe99cf4 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift @@ -64,9 +64,10 @@ struct ChatBubble: View { var onCancelTurn: (() -> Void)? = nil var onOpenAgent: ((UUID, @escaping (Bool) -> Void) -> Void)? = nil var onOpenAgentRef: ((AgentTimelineRef, @escaping (Bool) -> Void) -> Void)? = nil - /// Nil for all existing Chat surfaces. Rich blocks are transcript data, but - /// only the capability-gated main shell is allowed to turn them into controls. - var chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil + /// The owners a content block needs to become an interactable control. Every + /// Chat surface has one — a rendered card is transcript data either way, and a + /// card the reader cannot act on is worse than no card at all. + let chatFirstRichBlockContext: ChatFirstRichBlockContext var metadataRevealOverrideForTesting: Bool? = nil @State private var metadataHoverState = ChatBubbleMetadataHoverState() @State private var isExpanded = false @@ -90,7 +91,7 @@ struct ChatBubble: View { onCancelTurn: (() -> Void)? = nil, onOpenAgent: ((UUID, @escaping (Bool) -> Void) -> Void)? = nil, onOpenAgentRef: ((AgentTimelineRef, @escaping (Bool) -> Void) -> Void)? = nil, - chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil + chatFirstRichBlockContext: ChatFirstRichBlockContext ) { self.message = message self.app = app @@ -173,8 +174,7 @@ struct ChatBubble: View { } else { let groupedBlocks = ContentBlockGroup.visibleChatGroups( message.contentBlocks, - isStreaming: message.isStreaming, - richBlockRenderingEnabled: chatFirstRichBlockContext != nil + isStreaming: message.isStreaming ) HStack(alignment: .top, spacing: OmiSpacing.md) { @@ -369,14 +369,10 @@ struct ChatBubble: View { ChatResourceActions.open(resource) return } - if let chatFirstRichBlockContext { - let moment = reference.momentTimestampMs.map { TimeInterval($0) / 1_000 } - chatFirstRichBlockContext.navigation.open( - focus: .capture(id: reference.sourceID, momentTs: moment) - ) - return - } - onOpenInlineCitation?(reference.navigationReference) + let moment = reference.momentTimestampMs.map { TimeInterval($0) / 1_000 } + chatFirstRichBlockContext.navigation.open( + focus: .capture(id: reference.sourceID, momentTs: moment) + ) } private var presentation: ChatRowPresentation { ChatRowPresentation.of(message) } @@ -468,81 +464,13 @@ struct ChatBubble: View { return AnyView(EmptyView()) case .discoveryCard(_, let title, let summary, let fullText): return AnyView(DiscoveryCard(title: title, summary: summary, fullText: fullText)) - case .questionCard(_, let questionID, let text, let options, let selectedOptionID): - guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } - return AnyView( - QuestionCardView( - questionID: questionID, - text: text, - options: options, - selectedOptionID: selectedOptionID, - isActionable: chatFirstRichBlockContext.chatProvider.isQuestionCardActionable( - messageID: message.id, - questionID: questionID, - selectedOptionID: selectedOptionID - ), - onSelect: { optionID, isDeferral in - Task { @MainActor in - AnalyticsManager.shared.chatFirst( - .question(lifecycle: isDeferral ? .deferred : .answered) - ) - AnalyticsManager.shared.chatFirst( - .richBlock(kind: .questionCard, outcome: .acted, action: .select) - ) - await chatFirstRichBlockContext.chatProvider.selectQuestionCardOption( - questionID: questionID, - optionID: optionID - ) - } - } - ) - ) - case .taskCard(_, let taskID): - guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } + case .questionCard, .taskCard, .goalLink, .captureLink, .conversationLink, .memoryLink: + // One renderer for all six, shared with the task panel and the notch. return AnyView( - TaskCardView( - taskID: taskID, - tasksStore: chatFirstRichBlockContext.tasksStore, - navigation: chatFirstRichBlockContext.navigation - ) - ) - case .goalLink(_, let goalID, let summary): - guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } - return AnyView( - GoalLinkView( - goalID: goalID, - summary: summary, - navigation: chatFirstRichBlockContext.navigation, - goalsStore: chatFirstRichBlockContext.canonicalGoalsStore - ) - ) - case .captureLink(_, let conversationID, let momentTimestampMs, let summary): - guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } - return AnyView( - CaptureLinkView( - conversationID: conversationID, - momentTimestampMs: momentTimestampMs, - summary: summary, - navigation: chatFirstRichBlockContext.navigation - ) - ) - case .conversationLink(_, let conversationID, let summary, let recommendedActionItems): - guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } - return AnyView( - ConversationLinkView( - conversationID: conversationID, - summary: summary, - recommendedActionItems: recommendedActionItems, - navigation: chatFirstRichBlockContext.navigation - ) - ) - case .memoryLink(_, let memoryID, let summary): - guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } - return AnyView( - MemoryLinkView( - memoryID: memoryID, - summary: summary, - navigation: chatFirstRichBlockContext.navigation + ChatFirstRichBlockGroupView( + group: group, + messageID: message.id, + context: chatFirstRichBlockContext ) ) case .agentSpawn( @@ -1144,10 +1072,7 @@ enum ContentBlockGroup: Identifiable { } /// Groups consecutive `.toolCall` blocks together; passes other blocks through - static func group( - _ blocks: [ChatContentBlock], - richBlockRenderingEnabled: Bool = false - ) -> [ContentBlockGroup] { + static func group(_ blocks: [ChatContentBlock]) -> [ContentBlockGroup] { var groups: [ContentBlockGroup] = [] var pendingToolCalls: [ChatContentBlock] = [] @@ -1173,21 +1098,17 @@ enum ContentBlockGroup: Identifiable { groups.append(.discoveryCard(id: id, title: title, summary: summary, fullText: fullText)) case .questionCard(let id, let questionID, let text, _, _, let options, let selectedOptionID): flushToolCalls() - guard richBlockRenderingEnabled else { continue } groups.append( .questionCard( id: id, questionID: questionID, text: text, options: options, selectedOptionID: selectedOptionID)) case .taskCard(let id, let taskID): flushToolCalls() - guard richBlockRenderingEnabled else { continue } groups.append(.taskCard(id: id, taskID: taskID)) case .goalLink(let id, let goalID, let summary): flushToolCalls() - guard richBlockRenderingEnabled else { continue } groups.append(.goalLink(id: id, goalID: goalID, summary: summary)) case .captureLink(let id, let conversationID, let momentTimestampMs, let summary): flushToolCalls() - guard richBlockRenderingEnabled else { continue } groups.append( .captureLink( id: id, @@ -1198,7 +1119,6 @@ enum ContentBlockGroup: Identifiable { ) case .conversationLink(let id, let conversationID, let summary, let recommendedActionItems): flushToolCalls() - guard richBlockRenderingEnabled else { continue } groups.append( .conversationLink( id: id, @@ -1207,7 +1127,6 @@ enum ContentBlockGroup: Identifiable { recommendedActionItems: recommendedActionItems)) case .memoryLink(let id, let memoryID, let summary): flushToolCalls() - guard richBlockRenderingEnabled else { continue } groups.append(.memoryLink(id: id, memoryID: memoryID, summary: summary)) case .citation: // Answer-level provenance is rendered by OmiMarkdown at the inline marker. @@ -1253,8 +1172,7 @@ enum ContentBlockGroup: Identifiable { /// A structured `.agentSpawn` replaces only its duplicate raw spawn call (INV-6 structured identity). static func visibleChatGroups( _ blocks: [ChatContentBlock], - isStreaming: Bool, - richBlockRenderingEnabled: Bool = false + isStreaming: Bool ) -> [ContentBlockGroup] { // The display projection turns a persisted spawn into its terminal card. // Both structured forms are therefore authoritative evidence that the @@ -1279,7 +1197,7 @@ enum ContentBlockGroup: Identifiable { return trimmedRun.isEmpty ? nil : "run:\(trimmedRun)" } ) - let grouped = group(blocks, richBlockRenderingEnabled: richBlockRenderingEnabled) + let grouped = group(blocks) let lastToolIndex = grouped.lastIndex { group in if case .toolCalls = group { return true } return false diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift index 72bff031230..9899ba83687 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift @@ -359,13 +359,14 @@ struct ChatMessagesView: View { /// Horizontal inset of the message column. Home passes 0 so bubbles align /// exactly with the ask bar's edges; other surfaces keep the default gutter. var horizontalContentPadding: CGFloat = ChatComposerLayout.transcriptEdgeInset - /// Explicitly enables chat-first controls only in the Chat-first shell's main - /// Chat route. Nil keeps shared transcript projections safe elsewhere. - var chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil + /// The owners a content block needs to become an interactable control. Every + /// host supplies one; there is no inert projection of the transcript. + let chatFirstRichBlockContext: ChatFirstRichBlockContext /// Optional transcript-window override for callers with a smaller initial - /// mount budget. When omitted, the existing 500-row default is preserved; - /// the existing Home-only rich-block capability selects the compact Home - /// policy automatically. + /// mount budget. When omitted, the 500-row default is preserved. Main chat + /// (`QueryAnswerThread`) passes `.compactHome` explicitly; this used to be + /// derived from "has a rich-block context", which every host now has, so the + /// derivation would have silently shrunk the task panel's window too. var transcriptWindowPolicy: ChatTranscriptWindow.Policy? = nil /// Vertical transcript inset. Home uses a tighter value because its page /// shell already provides the breathing room beneath the floating top bar. @@ -495,8 +496,7 @@ struct ChatMessagesView: View { } private var effectiveTranscriptWindowPolicy: ChatTranscriptWindow.Policy { - transcriptWindowPolicy - ?? (chatFirstRichBlockContext == nil ? .standard : .compactHome) + transcriptWindowPolicy ?? .standard } /// A direct timeline choice leaves live-follow mode and places the selected diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/TaskChatPanel.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/TaskChatPanel.swift index 0f82f080693..d508ef11b61 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/TaskChatPanel.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/TaskChatPanel.swift @@ -64,6 +64,9 @@ struct TaskChatPanel: View { onLoadMore: {}, onRate: { _, _ in }, localSendToken: taskState.localSendToken, + // The task panel renders the same interactable content blocks as the + // main window; taps route the one shell (`ChatFirstRichBlockContext.auxiliary`). + chatFirstRichBlockContext: .auxiliary(chatProvider: coordinator.chatProvider), enablesPromptTimeline: false, welcomeContent: { taskWelcome } ) diff --git a/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryAnswerThread.swift b/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryAnswerThread.swift index dcadae9bbf4..9f08e6208a8 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryAnswerThread.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryAnswerThread.swift @@ -27,16 +27,16 @@ struct QueryAnswerThread: View { /// Re-sends the question that failed, through the host's one send — never a second send path. The /// host holds that question, because the composer is emptied by the send that failed. let onRetry: () -> Void - /// Enables the sampled Chat-first inline entity controls without giving this thread a second - /// provider, transcript, or lifecycle owner. - var chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil + /// The inline entity controls' owners. It gives this thread no second provider, transcript, or + /// lifecycle owner. + let chatFirstRichBlockContext: ChatFirstRichBlockContext @State private var didReportChatFirstTranscriptPage = false var body: some View { VStack(alignment: .leading, spacing: OmiSpacing.sm) { ChatMessagesView( - messages: citationSafeMessages, + messages: chatProvider.messages, conversationIdentity: chatProvider.currentSessionId ?? ChatConversationIdentity.mainChatDefault, isSending: chatProvider.isSending, @@ -127,45 +127,17 @@ struct QueryAnswerThread: View { } .onDisappear { didReportChatFirstTranscriptPage = false - chatFirstRichBlockContext?.promptMaterializationCoordinator.chatTranscriptDidDisappear() + chatFirstRichBlockContext.promptMaterializationCoordinator.chatTranscriptDidDisappear() } } /// Prompt materialization is visible-chat gated: the coordinator may run only after the one /// mounted transcript has its first page, and leaving answer mode immediately makes it inert. private func reportChatFirstTranscriptPageIfReady() { - guard !didReportChatFirstTranscriptPage, - chatFirstRichBlockContext != nil, - chatProvider.isMainChatJournalFirstPageReady + guard !didReportChatFirstTranscriptPage, chatProvider.isMainChatJournalFirstPageReady else { return } didReportChatFirstTranscriptPage = true - chatFirstRichBlockContext?.promptMaterializationCoordinator.chatTranscriptFirstPageDidLoad() - } - - /// The legacy shell has no exact goal destination. Preserve the historical source preview but - /// make its marker unavailable before it reaches the renderer, instead of presenting a button - /// whose action cannot honor the cited identity. Chat-first keeps its typed goal route. - private var citationSafeMessages: [ChatMessage] { - guard chatFirstRichBlockContext == nil else { return chatProvider.messages } - return chatProvider.messages.map { message in - var message = message - message.contentBlocks = message.contentBlocks.map { block in - guard case .citation(let id, let reference) = block, reference.kind == .goal else { - return block - } - return .citation( - id: id, - reference: ChatCitationReference( - ordinal: reference.ordinal, - kind: .unavailable, - sourceID: "", - title: reference.displayTitle, - preview: reference.preview, - createdAt: reference.createdAt, - appName: reference.appName)) - } - return message - } + chatFirstRichBlockContext.promptMaterializationCoordinator.chatTranscriptFirstPageDidLoad() } } diff --git a/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskAgent/TaskChatCoordinator.swift b/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskAgent/TaskChatCoordinator.swift index 8aedb524922..7067cacc528 100644 --- a/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskAgent/TaskChatCoordinator.swift +++ b/desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskAgent/TaskChatCoordinator.swift @@ -44,7 +44,10 @@ final class TaskChatCoordinator: ObservableObject { private var suppressUnreadPersistence = false private var isResettingOwnerProjection = false - private let chatProvider: ChatProvider + /// The one provider (INV-6). Exposed so the task panel can build the same + /// content-block context every other Chat surface uses, without a second + /// provider or transcript. + let chatProvider: ChatProvider private let workstreamAPI: any TaskWorkstreamAPI private let persistWorkstreamLink: @MainActor (String, String, String, LocalMutationAuthorization) async -> Void private let ownerIDProvider: @MainActor () -> String? diff --git a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift index ec6d8cf66a6..638303f3084 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift @@ -4092,6 +4092,15 @@ class ChatProvider: ObservableObject { /// Question-card controls are only live on a completed assistant turn at /// the conversation tail. A later user response retires its choices. + /// Whether the server-owned chat-first capability is currently projected for + /// main chat. A question card renders its options either way; this decides + /// whether they are pressable or dimmed (`QuestionCardView.isCapabilityAvailable`). + func hasChatFirstMainChatCapability() -> Bool { + guard let ownerID = runtimeOwnerId else { return false } + return chatFirstMainChatProjectionGate.capability( + for: mainChatSurfaceReference(), ownerID: ownerID) != nil + } + func isQuestionCardActionable( messageID: String, questionID: String, From 3572ee213478ba786ab273e085c19ee4c3fba9aa Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 03:01:35 -0400 Subject: [PATCH 03/29] fix(desktop): every proactive card says what it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `showNotification` took an optional `kind:` and `FloatingBarNotification` quietly filled it in from `assistantId`, whose default arm is `.general`. Five producers never passed one — trial messaging, onboarding permission help, both notch moments, and the whole generic proactive path — so their cards journaled a bare `notification:` continuity key and came back in the transcript badged "Notification" with a bell, a row that says nothing about what Omi actually noticed. `kind:` is now required and never derived inside the value type. The generic proactive path derives it once at the producer edge, from the same `from(assistantId:)` call the category gate already makes three lines earlier. `.trial` and `.onboarding` are new kinds and are excluded from journaling alongside the integration nudge: billing copy and permission help are not observations. `.functional` carries the system notices that used to ride on `.general`. `.general` survives as decode-only so historical bare keys keep reading back, and its badge arm stays for exactly those rows. Co-Authored-By: Claude Fable 5.1 --- .../Chat/ChatContinuityInvariants.swift | 30 ++++++++++++++++++- .../FloatingBarNotificationJournalCopy.swift | 2 +- .../FloatingControlBarState.swift | 7 +++-- .../FloatingControlBarWindow.swift | 11 +++++-- .../Interject/InterjectDisplayDuration.swift | 2 +- .../NotchMomentsCoordinator.swift | 3 +- .../IntegrationNudgeCoordinator.swift | 1 + .../Components/ChatBubbleSupport.swift | 9 ++++++ .../Onboarding/OnboardingChatView.swift | 3 +- .../Services/NotificationService.swift | 9 +++++- .../Desktop/Sources/TrialBannerService.swift | 3 +- 11 files changed, 69 insertions(+), 11 deletions(-) diff --git a/desktop/macos/Desktop/Sources/Chat/ChatContinuityInvariants.swift b/desktop/macos/Desktop/Sources/Chat/ChatContinuityInvariants.swift index 32ba62696b9..639434845c6 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatContinuityInvariants.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatContinuityInvariants.swift @@ -1,7 +1,20 @@ import Foundation enum ProactiveNotificationKind: String, Equatable, CaseIterable { + /// **Decode-only.** Historical rows were journaled under a bare + /// `notification:` key, which reads back as this. No producer may pass + /// it: `showNotification` requires an explicit kind, and a card with no + /// category of its own is `.functional`, not "Notification". case general + /// A system notice that is not a proactive observation — screen-recording + /// reset, a support reply, an onboarding test ping. It is ungated by the five + /// category toggles, exactly as `.general` was. + case functional + /// Trial/plan messaging. Never journaled: it is product copy about billing, + /// not something Omi observed. + case trial + /// First-run permission help. Never journaled, for the same reason. + case onboarding case suggestion case insight case task @@ -35,7 +48,20 @@ enum ProactiveNotificationKind: String, Equatable, CaseIterable { case "goals": return .goal case "meeting-notes": return .meetingNotes case "integration_connect": return .integration - default: return .general + case "trial": return .trial + case "onboarding": return .onboarding + default: return .functional + } + } + + /// Kinds whose cards are presentation only and must never enter the chat + /// journal. See `FloatingControlBarManager.persistNotificationMessageIfNeeded`. + var isJournaled: Bool { + switch self { + case .trial, .onboarding: return false + case .general, .functional, .suggestion, .insight, .task, .memory, .goal, .meetingNotes, + .resurface, .integration: + return true } } } @@ -58,6 +84,8 @@ enum ChatContinuityInvariants { } static func proactiveNotificationContinuityKey(id: UUID, kind: ProactiveNotificationKind) -> String { + // `.general` is decode-only and unreachable from a producer, so this branch + // exists to keep the historical bare key round-tripping, never to mint one. guard kind != .general else { return proactiveNotificationContinuityKey(id: id) } return "\(proactiveNotificationContinuityKeyPrefix)\(kind.rawValue):\(id.uuidString)" } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarNotificationJournalCopy.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarNotificationJournalCopy.swift index fc28f9352ed..2920185147f 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarNotificationJournalCopy.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarNotificationJournalCopy.swift @@ -97,7 +97,7 @@ enum ProactiveNotificationCopy { return ["memory", "memory saved"] case .integration: return ["integration"] - case .general: + case .general, .functional, .trial, .onboarding: return ["notification"] } } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift index 2b10e358f39..59e4a9fcda9 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift @@ -268,7 +268,7 @@ struct FloatingBarNotification: Identifiable, Equatable { title: String, message: String, assistantId: String, - kind: ProactiveNotificationKind? = nil, + kind: ProactiveNotificationKind, context: FloatingBarNotificationContext? = nil, action: FloatingBarNotificationAction? = nil, jitFeedbackContext: JITTriggerFeedbackContext? = nil, @@ -281,7 +281,10 @@ struct FloatingBarNotification: Identifiable, Equatable { self.title = title self.message = message self.assistantId = assistantId - self.kind = kind ?? ProactiveNotificationKind.from(assistantId: assistantId) + // Required, never derived here. Deriving it from `assistantId` meant every + // producer that forgot to say what its card was silently became `.general` + // and journaled a bare `notification:` row badged "Notification". + self.kind = kind self.context = context self.action = action self.jitFeedbackContext = jitFeedbackContext diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift index 3b16a3faed1..64fecec6b7c 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift @@ -3266,7 +3266,8 @@ class FloatingControlBarManager { ownerID: RuntimeOwnerIdentity.currentOwnerId() ?? "", title: "Couldn't reach Omi", message: message, - assistantId: "reach_error" + assistantId: "reach_error", + kind: .functional ) ) } @@ -3489,7 +3490,9 @@ class FloatingControlBarManager { message: String, assistantId: String, sound: NotificationSound, - kind: ProactiveNotificationKind? = nil, + /// Required: what this card *is*. There is no assistant-id fallback — see + /// `FloatingBarNotification.init`. + kind: ProactiveNotificationKind, context: FloatingBarNotificationContext? = nil, action: FloatingBarNotificationAction? = nil, jitFeedbackContext: JITTriggerFeedbackContext? = nil, @@ -4601,6 +4604,10 @@ class FloatingControlBarManager { // read your inbox…" into the user's conversation history as though it // were an observation is noise they cannot act on there. notification.assistantId != IntegrationNudgeCoordinator.assistantID, + // Trial and onboarding cards are product copy — billing state and + // permission help — not something Omi observed. Writing them into the + // transcript is the same noise the integration offer above is excluded for. + notification.kind.isJournaled, // The meeting summary share card must not journal either: the durable // Chat surface for a finished meeting is the conversation-link card the // backend already materializes, and journaling here would produce a diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/Interject/InterjectDisplayDuration.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/Interject/InterjectDisplayDuration.swift index aa8c87b9615..bb442b7902c 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/Interject/InterjectDisplayDuration.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/Interject/InterjectDisplayDuration.swift @@ -19,7 +19,7 @@ enum InterjectDisplayDuration { return 4 case .insight, .suggestion: return 5 - case .general, .memory, .goal, .meetingNotes, .integration: + case .general, .functional, .trial, .onboarding, .memory, .goal, .meetingNotes, .integration: return 5 } } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/NotchMomentsCoordinator.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/NotchMomentsCoordinator.swift index 3aaea2beea1..084f75054cb 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/NotchMomentsCoordinator.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/NotchMomentsCoordinator.swift @@ -167,6 +167,7 @@ final class NotchMomentsCoordinator { title: title, message: message, assistantId: assistantId, - sound: .none) + sound: .none, + kind: ProactiveNotificationKind.from(assistantId: assistantId)) } } diff --git a/desktop/macos/Desktop/Sources/IntegrationNudges/IntegrationNudgeCoordinator.swift b/desktop/macos/Desktop/Sources/IntegrationNudges/IntegrationNudgeCoordinator.swift index a017212433a..c69d934a10f 100644 --- a/desktop/macos/Desktop/Sources/IntegrationNudges/IntegrationNudgeCoordinator.swift +++ b/desktop/macos/Desktop/Sources/IntegrationNudges/IntegrationNudgeCoordinator.swift @@ -63,6 +63,7 @@ final class IntegrationNudgeCoordinator { message: match.entry.pitch, assistantId: IntegrationNudgeCoordinator.assistantID, sound: .none, + kind: .integration, action: .connectIntegration( telemetryID: match.entry.telemetryID, triggerID: match.trigger.id diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift index 30760c5413e..7e6d9c37ed0 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift @@ -271,8 +271,17 @@ struct ProactiveNotificationBadge: Equatable { (label, systemImage) = ("Memory", "brain.head.profile") case .integration: (label, systemImage) = ("Integration", "sparkles.rectangle.stack") + case .functional: + (label, systemImage) = ("Omi", "bell") case .general: + // Decode-only: rows journaled before proactive kinds were part of the + // continuity key. No producer can reach it (`showNotification` requires a + // kind), so this arm is history, not a category. (label, systemImage) = ("Notification", "bell") + case .trial, .onboarding: + // Never journaled, so never rendered as a transcript row. Kept exhaustive + // so a future decision to journal them has to state its badge here. + (label, systemImage) = ("Omi", "bell") } } } diff --git a/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift b/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift index bfdc636b034..c483c10763f 100644 --- a/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift +++ b/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift @@ -1224,7 +1224,8 @@ struct OnboardingChatView: View { title: "Need help with \(permLabel)?", message: helpMessage, assistantId: "onboarding", - sound: .none + sound: .none, + kind: .onboarding ) } } diff --git a/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/NotificationService.swift b/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/NotificationService.swift index aefe3354369..a2e056710c8 100644 --- a/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/NotificationService.swift +++ b/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/NotificationService.swift @@ -417,6 +417,9 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate { message: metadata.message, assistantId: metadata.assistantId, sound: .none, + // Explicit at the producer edge — the assistant's own declared category. + // `FloatingBarNotification` no longer derives one for a caller that omits it. + kind: ProactiveNotificationKind.from(assistantId: metadata.assistantId), context: metadata.context, jitFeedbackContext: feedbackContext, isPersistent: true, @@ -715,6 +718,8 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate { message: message, assistantId: assistantId, sound: sound, + // The same category this delivery was already gated on a few lines up. + kind: ProactiveNotificationKind.from(assistantId: assistantId), context: context, action: action, jitFeedbackContext: jitFeedbackContext, @@ -1051,7 +1056,9 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate { case .insight, .resurface, .goal: return insightEnabled case .memory: return memoryEnabled case .integration: return integrationEnabled - case .general: return true + // Functional system notices and the two never-journaled product cards sit + // outside the five-category taxonomy and are ungated by it. + case .general, .functional, .trial, .onboarding: return true } } diff --git a/desktop/macos/Desktop/Sources/TrialBannerService.swift b/desktop/macos/Desktop/Sources/TrialBannerService.swift index 2cc2da98d86..8445b74d1e9 100644 --- a/desktop/macos/Desktop/Sources/TrialBannerService.swift +++ b/desktop/macos/Desktop/Sources/TrialBannerService.swift @@ -39,7 +39,8 @@ final class TrialBannerService { title: title, message: message, assistantId: "trial", - sound: .default + sound: .default, + kind: .trial ) } ) { From 4e2dd7342bb20ef5f070666fd22fcbb0601d4268 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 03:24:52 -0400 Subject: [PATCH 04/29] test(desktop): pin one shell and six live blocks, and retire the second-shell flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tests that protected the old shape are the reason it would come back: `ChatFirstRichBlockTests` asserted that a caller without an explicit context got *nothing*, and three flows waited on `shellVariant: legacy`. - `OneChatShellRichBlockTests` builds one turn carrying prose plus all six interactable kinds and asserts the grouping keeps every one of them, in transcript order, on the same entry point the notch and task panel call. It then drives each link's typed navigation target through the real navigation owner, and pins capability-off to "options dimmed", never "options gone" — via a new `ChatFirstQuestionCardOptionsPolicy` that separates *answered* and *retired* (hide) from *capability-off* (disable). - `ProactiveNotificationKindTests` walks every assistant id a producer ships and proves none of them derives `.general`, so no producer can mint a bare `notification:` key; historical bare keys still decode. - `check-single-chat-shell.py` (+ manifest entries, with a self-test) is the tripwire for the vocabulary that made a second shell expressible. - `home-stage.yaml` and `dashboard.yaml` described `DashboardPage` and are deleted with it; `chat-first-capability-isolation.yaml` is repurposed to the assertion that now matters — capability-off mounts the same shell. Co-Authored-By: Claude Fable 5.1 --- .github/checks-manifest.yaml | 10 + .../Chat/ChatContinuityInvariants.swift | 4 +- .../Blocks/ChatFirstContentBlockViews.swift | 43 ++- .../MainWindow/ChatFirst/ChatFirstRoute.swift | 10 +- .../QueryShell/QueryAnswerThread.swift | 24 +- .../Tests/AgentPillLifecycleTests.swift | 3 +- .../Tests/ChatFirstRichBlockTests.swift | 48 +-- .../Desktop/Tests/ChatFirstShellTests.swift | 158 ++-------- .../Tests/ChatSurfaceTestSupport.swift | 46 +++ .../Tests/ChatTimelineContinuityTests.swift | 21 +- .../ChatTranscriptGestureHarnessTests.swift | 1 + .../Tests/DashboardCaptureStateTests.swift | 276 ----------------- .../Tests/DesktopChatDriftGuardTests.swift | 40 ++- .../FloatingBarNotificationGroundTests.swift | 3 +- ...ingBarNotificationPreviewPolicyTests.swift | 3 +- .../Tests/FloatingOwnerProjectionTests.swift | 4 +- .../Tests/GlassPanelHitRegionTests.swift | 28 +- .../Tests/HomeAskFocusPolicyTests.swift | 81 ----- .../Tests/HomeKnowsComposerTests.swift | 97 ------ .../Tests/HomeStageCloseSemanticsTests.swift | 93 ------ .../InsightAssistantTelemetryTests.swift | 1 + .../Desktop/Tests/InterjectWiringTests.swift | 1 + .../Tests/OneChatShellRichBlockTests.swift | 218 ++++++++++++++ .../ProactiveNotificationKindTests.swift | 108 +++++++ .../macos/Desktop/Tests/QueryShellTests.swift | 29 -- .../unreleased/20260902-one-chat-shell.json | 3 + desktop/macos/e2e/CORE_E2E.md | 2 - desktop/macos/e2e/feature-vector.md | 4 +- .../chat-first-capability-isolation.yaml | 24 +- .../macos/e2e/flows/chat-first-cohesive.yaml | 2 + .../macos/e2e/flows/dashboard.snapshot.json | 52 ---- desktop/macos/e2e/flows/dashboard.yaml | 58 ---- .../e2e/flows/floating-bar-functional.yaml | 2 + desktop/macos/e2e/flows/goals-dashboard.yaml | 1 - desktop/macos/e2e/flows/home-spine.yaml | 2 + desktop/macos/e2e/flows/home-stage.yaml | 119 -------- desktop/macos/e2e/flows/home.yaml | 5 +- desktop/macos/e2e/flows/navigation.yaml | 16 +- .../macos/scripts/check-single-chat-shell.py | 277 ++++++++++++++++++ .../scripts/check_desktop_test_quality.py | 2 +- desktop/macos/scripts/omi-ctl | 11 +- desktop/macos/scripts/omi-settings-seed.sh | 1 - 42 files changed, 854 insertions(+), 1077 deletions(-) create mode 100644 desktop/macos/Desktop/Tests/ChatSurfaceTestSupport.swift delete mode 100644 desktop/macos/Desktop/Tests/HomeAskFocusPolicyTests.swift delete mode 100644 desktop/macos/Desktop/Tests/HomeKnowsComposerTests.swift create mode 100644 desktop/macos/Desktop/Tests/OneChatShellRichBlockTests.swift create mode 100644 desktop/macos/Desktop/Tests/ProactiveNotificationKindTests.swift create mode 100644 desktop/macos/changelog/unreleased/20260902-one-chat-shell.json delete mode 100644 desktop/macos/e2e/flows/dashboard.snapshot.json delete mode 100644 desktop/macos/e2e/flows/dashboard.yaml delete mode 100644 desktop/macos/e2e/flows/home-stage.yaml create mode 100755 desktop/macos/scripts/check-single-chat-shell.py diff --git a/.github/checks-manifest.yaml b/.github/checks-manifest.yaml index 7c200238617..a98da02fe6c 100644 --- a/.github/checks-manifest.yaml +++ b/.github/checks-manifest.yaml @@ -264,6 +264,16 @@ checks: triggers: ["desktop/macos/Desktop/Sources/**/*.swift", "desktop/macos/scripts/check-grdb-insert-idiom.py", ".github/checks-manifest.yaml"] lanes: ["local", "ci"] reason: "#11204: a mutating didInsert never witnesses PersistableRecord's non-mutating insert, so a direct record.insert(db) drops the rowid silently onto an optional field; two shipped instances went undetected until unrelated work tripped over them" + - id: desktop-single-chat-shell-self-test + command: ["python3", "desktop/macos/scripts/check-single-chat-shell.py", "--self-test"] + triggers: ["desktop/macos/scripts/check-single-chat-shell.py", ".github/checks-manifest.yaml"] + lanes: ["local", "ci"] + reason: "the tripwire must keep failing on each shape it was written for, and keep passing on prose that merely names one" + - id: desktop-single-chat-shell + command: ["python3", "desktop/macos/scripts/check-single-chat-shell.py"] + triggers: ["desktop/macos/Desktop/Sources/**/*.swift", "desktop/macos/scripts/check-single-chat-shell.py", ".github/checks-manifest.yaml"] + lanes: ["local", "ci"] + reason: "#12598: the app mounted one of two shells behind a preference, and six content-block kinds rendered as controls on one and as nothing on the other; both grow back one symbol at a time" - id: brand-ui-ratchet-tests command: ["python3", ".github/scripts/test_check_brand_ui.py"] triggers: [".github/scripts/check_brand_ui.py", ".github/scripts/test_check_brand_ui.py", ".github/checks-manifest.yaml"] diff --git a/desktop/macos/Desktop/Sources/Chat/ChatContinuityInvariants.swift b/desktop/macos/Desktop/Sources/Chat/ChatContinuityInvariants.swift index 639434845c6..7946a2091f2 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatContinuityInvariants.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatContinuityInvariants.swift @@ -35,7 +35,9 @@ enum ProactiveNotificationKind: String, Equatable, CaseIterable { case "insight": return .insight case "task_candidate": return .task case "resurface": return .resurface - default: return .general + // An unrecognised director decision is a system notice, not an + // uncategorised observation: `.general` is decode-only. + default: return .functional } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift index b385adc7a8b..33a036405be 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift @@ -7,6 +7,35 @@ import SwiftUI /// Choices are controls only while the kernel-backed parent is the completed /// tail of Main Chat. The runtime remains authoritative at selection time; /// this view's gate simply avoids presenting obsolete choices as actionable. +/// Whether a question card's options are pressable, dimmed, or gone. +/// +/// Three different situations used to collapse into one boolean, and the losing +/// two both rendered as "no options at all": a question already answered (right), +/// a question whose turn is no longer the tail (right), and a question on an +/// account whose capability has not resolved (wrong — that reader saw a question +/// with no visible answers and no explanation). +enum ChatFirstQuestionCardOptionsPolicy: Equatable { + case hidden + case enabled + case disabled + + static func presentation( + isActionable: Bool, + isCapabilityAvailable: Bool, + hasSelection: Bool, + hasOptions: Bool + ) -> Self { + guard hasOptions, !hasSelection else { return .hidden } + if isActionable { return .enabled } + // Capability-off is the only reason to show unpressable options: the + // question is live, we simply cannot answer it yet. + return isCapabilityAvailable ? .hidden : .disabled + } + + var isVisible: Bool { self != .hidden } + var isPressable: Bool { self == .enabled } +} + struct QuestionCardView: View { private struct Option: Identifiable { let id: String @@ -56,7 +85,13 @@ struct QuestionCardView: View { // Capability-off is the one case that shows the chips *without* making // them pressable: the question is real and its answers are the only thing // that explains it, so they are dimmed rather than deleted. - if selectedOptionID == nil, !validOptions.isEmpty, isActionable || !isCapabilityAvailable { + let optionsPresentation = ChatFirstQuestionCardOptionsPolicy.presentation( + isActionable: isActionable, + isCapabilityAvailable: isCapabilityAvailable, + hasSelection: selectedOptionID != nil, + hasOptions: !validOptions.isEmpty + ) + if optionsPresentation.isVisible { FlowLayout(spacing: OmiSpacing.sm) { ForEach(validOptions) { option in Button { @@ -70,14 +105,14 @@ struct QuestionCardView: View { .glassChip() } .buttonStyle(.plain) - .disabled(!isActionable) - .opacity(isActionable ? 1 : 0.45) + .disabled(!optionsPresentation.isPressable) + .opacity(optionsPresentation.isPressable ? 1 : 0.45) .accessibilityLabel("Send suggestion: \(option.label)") .accessibilityIdentifier("chat-first-question-\(questionID)-option-\(option.id)") } } - if !isActionable { + if !optionsPresentation.isPressable { Text("Answering is unavailable right now") .scaledFont(size: OmiType.caption) .foregroundStyle(Ink.secondary) diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift index eee77e45f30..de5ba670b06 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift @@ -494,10 +494,12 @@ final class ChatFirstShellNavigation: ObservableObject { /// so the destination this call selects is actually on screen. Already-key is /// the common case and stays a no-op. private func presentMainWindowIfNeeded() { - guard let window = NSApp.mainWindow, window.isKeyWindow, window.isVisible else { - AppDelegate.summonWindowTarget()?.openMainAppWindow() - return - } + // `NSApp` is an implicitly unwrapped optional and is genuinely nil in a unit + // test host, so it is read through an explicit optional rather than touched. + let application: NSApplication? = NSApp + guard let application else { return } + if let window = application.mainWindow, window.isKeyWindow, window.isVisible { return } + AppDelegate.summonWindowTarget()?.openMainAppWindow() } private func persistNavigation() { diff --git a/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryAnswerThread.swift b/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryAnswerThread.swift index 9f08e6208a8..6108fae6c06 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryAnswerThread.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryAnswerThread.swift @@ -55,6 +55,18 @@ struct QueryAnswerThread: View { onRetry: { Task { await chatProvider.retryLoad() } }, localSendToken: chatProvider.localSendToken, onCancelTurn: { chatProvider.stopAgent(owner: .mainChat) }, + // A spawned-agent card in the main transcript opens the agent through the + // one resolver the notch uses; this used to be wired only on the deleted + // Dashboard chat, so Home's agent cards had no way in. + onOpenAgent: { agentID, completion in + FloatingControlBarManager.shared.openAgentChatFromTimeline( + agentID: agentID, completion: completion) + }, + onOpenAgentRef: { ref, completion in + FloatingControlBarManager.shared.openAgentChatFromTimeline( + ref: ref, completion: completion) + }, + // **Not zero.** The assistant's identity mark is drawn in an overlay offset // `ChatOmiMarkPlacement.markGutter` to the left of the message column, so a transcript with // no leading inset draws it outside the panel and clips it away — leaving omi's replies as @@ -65,12 +77,12 @@ struct QueryAnswerThread: View { // `ChatMessagesView` keeps its rows eagerly mounted on purpose — a lazy // stack re-estimates off-screen rich-Markdown heights and hands AppKit // the wrong anchor mid-gesture — so how many rows are mounted is the - // whole cost. It picks the compact window automatically for a caller - // that passes a chat-first block context; the ordinary QueryShellHome - // path has none, so it used to mount the 500-row default into a panel - // 460 pt tall: 910 ms and 607 native views for 400 messages, against - // 114 ms and 84 for the same transcript compact. `Show older messages` - // is already the way back to the rest of it. + // whole cost. The 500-row default in a panel 460 pt tall cost 910 ms and + // 607 native views for 400 messages, against 114 ms and 84 for the same + // transcript compact. `Show older messages` is already the way back to + // the rest of it. Passed explicitly: every host now carries a block + // context, so deriving the window from "has a context" would have + // silently shrunk the task panel's too. chatFirstRichBlockContext: chatFirstRichBlockContext, transcriptWindowPolicy: .compactHome, verticalContentPadding: OmiSpacing.sm, diff --git a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift index fbf1281b91b..7390bd12ceb 100644 --- a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift +++ b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift @@ -843,7 +843,8 @@ import XCTest ownerID: "owner", title: "Replacement notification", message: "Must remain visible", - assistantId: "test"), + assistantId: "test", + kind: .functional), animated: false) scheduler.fire() diff --git a/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift b/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift index c66a09ba2e1..e10eae4e494 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift @@ -255,7 +255,11 @@ final class ChatFirstRichBlockTests: XCTestCase { XCTAssertEqual(summary, "After") } - func testRichRendererSelectionRequiresExplicitChatFirstContext() { + /// Every Chat surface renders every rich block. This used to assert the + /// opposite — that a caller without an explicit context got nothing — which is + /// how a turn whose only content was a task card read as an empty assistant + /// reply in the task panel and in the notch. + func testEveryRichBlockSurvivesGroupingOnEveryChatSurface() { let blocks: [ChatContentBlock] = [ .questionCard( id: "question", questionId: "question-1", text: "Question", subjectKind: "goal", subjectId: "goal-1", @@ -264,45 +268,15 @@ final class ChatFirstRichBlockTests: XCTestCase { .taskCard(id: "task", taskId: "task-1"), .goalLink(id: "goal", goalId: "goal-1", summary: "Goal"), .captureLink(id: "capture", conversationId: "capture-1", momentTimestampMs: nil, summary: "Capture"), + .conversationLink( + id: "conversation", conversationId: "conversation-1", summary: "Conversation", + recommendedActionItems: []), .memoryLink(id: "memory", memoryId: "memory-1", summary: "Memory"), ] - XCTAssertTrue( - ContentBlockGroup.visibleChatGroups(blocks, isStreaming: false).isEmpty, - "legacy, floating, task, and onboarding call sites must keep rich blocks inert" - ) - - let enabled = ContentBlockGroup.visibleChatGroups( - blocks, - isStreaming: false, - richBlockRenderingEnabled: true - ) - XCTAssertEqual(enabled.count, 5) - XCTAssertTrue( - enabled.contains { - if case .questionCard = $0 { return true } - return false - }) - XCTAssertTrue( - enabled.contains { - if case .taskCard = $0 { return true } - return false - }) - XCTAssertTrue( - enabled.contains { - if case .goalLink = $0 { return true } - return false - }) - XCTAssertTrue( - enabled.contains { - if case .captureLink = $0 { return true } - return false - }) - XCTAssertTrue( - enabled.contains { - if case .memoryLink = $0 { return true } - return false - }) + let groups = ContentBlockGroup.visibleChatGroups(blocks, isStreaming: false) + XCTAssertEqual(groups.count, 6) + XCTAssertEqual(groups.map(\.id), blocks.map(\.id), "order is the transcript's, not the renderer's") } func testTaskAcknowledgementRequiresReconciledCompletedRecord() { diff --git a/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift index aaf7116befc..dbd6050d0db 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift @@ -47,23 +47,23 @@ final class ChatFirstShellTests: XCTestCase { ) } - func testSuccessfulSampleSelectsChatFirstAndCannotLiveSwap() throws { - var sample = ChatFirstShellCapabilitySample() + func testSuccessfulSampleResolvesCapabilityAndCannotLiveSwap() throws { + var sample = ChatFirstCapabilitySample() sample.resolve( control: enabledControl(), requestedOwnerID: "owner-a", ownerIsStillCurrent: true ) - XCTAssertEqual(sample.variant.projection?.controlGeneration, 7) - XCTAssertEqual(sample.variant.stableName, "chat_first") + XCTAssertEqual(sample.projection?.controlGeneration, 7) + XCTAssertTrue(sample.isResolved) sample.resolve( control: OmiAPI.TaskWorkflowControl(accountGeneration: 8, chatFirstUi: false, workflowMode: .off), requestedOwnerID: "owner-a", ownerIsStillCurrent: true ) - XCTAssertEqual(sample.variant.projection?.controlGeneration, 7) + XCTAssertEqual(sample.projection?.controlGeneration, 7) } func testLegacyWorkflowMetadataCannotSuppressDerivedChatFirstCapability() throws { @@ -79,38 +79,20 @@ final class ChatFirstShellTests: XCTestCase { XCTAssertEqual(projection.controlGeneration, 9) } - func testOnlyLegacyShellUsesThePostOnboardingFloatingPopup() { - var enabled = ChatFirstShellCapabilitySample() - enabled.resolve( - control: enabledControl(), - requestedOwnerID: "owner-a", - ownerIsStillCurrent: true - ) - - XCTAssertFalse( - DesktopShellPresentationPolicy.usesLegacyPostOnboardingPopup(false, enabled.variant), - "chat-first starter prompts belong to the main chat") - XCTAssertTrue( - DesktopShellPresentationPolicy.usesLegacyPostOnboardingPopup(false, .legacy), - "the server-selected legacy shell retains its floating prompt") - XCTAssertTrue( - DesktopShellPresentationPolicy.usesLegacyPostOnboardingPopup(true, enabled.variant), - "the explicit legacy preference remains authoritative") - } - - func testMissingStaleAndOwnerChangedSamplesFailClosed() { - var missing = ChatFirstShellCapabilitySample() + func testMissingStaleAndOwnerChangedSamplesFailClosedToCapabilityOff() { + var missing = ChatFirstCapabilitySample() missing.resolve(control: nil, requestedOwnerID: "owner-a", ownerIsStillCurrent: true) - XCTAssertEqual(missing.variant.stableName, "legacy") + XCTAssertNil(missing.projection) + XCTAssertTrue(missing.isResolved, "a failed read still resolves — it must not re-request forever") - var stale = ChatFirstShellCapabilitySample() + var stale = ChatFirstCapabilitySample() stale.resolve(control: enabledControl(), requestedOwnerID: "owner-a", ownerIsStillCurrent: false) - XCTAssertEqual(stale.variant.stableName, "legacy") + XCTAssertNil(stale.projection) - var ownerChanged = ChatFirstShellCapabilitySample() + var ownerChanged = ChatFirstCapabilitySample() ownerChanged.resolve(control: enabledControl(), requestedOwnerID: "owner-a", ownerIsStillCurrent: true) ownerChanged.ownerDidChange(to: "owner-b") - XCTAssertEqual(ownerChanged.variant.stableName, "legacy") + XCTAssertNil(ownerChanged.projection) } func testNavigationPersistsOnlyRouteAndCollapseAndRetainsFocusUntilAcknowledged() throws { @@ -493,32 +475,31 @@ final class ChatFirstShellTests: XCTestCase { XCTAssertEqual(ChatFirstRoute.automationVisibilityDestination(named: "settings"), .more(.settings)) XCTAssertEqual(ChatFirstRoute.automationVisibilityDestination(named: "home"), .chat) XCTAssertEqual(ChatFirstRoute.automationVisibilityDestination(named: "dashboard"), .chat) + // `navigate help` resolved a title no shell mounted and then timed out. + XCTAssertEqual(ChatFirstRoute.automationVisibilityDestination(named: "help"), .more(.settings)) + XCTAssertTrue(ChatFirstRoute.isHelpAutomationTarget("HELP")) + XCTAssertFalse(ChatFirstRoute.isHelpAutomationTarget("settings")) XCTAssertTrue( DesktopAutomationNavigationVisibilityPolicy.isTargetVisible( - shellVariant: "chat_first", - selectedTab: nil, + shellVariant: DesktopAutomationSnapshot.singleShellVariant, visibleChatFirstRoute: "tasks", - expectedChatFirstRoute: "tasks", - expectedLegacyTitle: "Tasks" + expectedChatFirstRoute: "tasks" ) ) - XCTAssertTrue( + XCTAssertFalse( DesktopAutomationNavigationVisibilityPolicy.isTargetVisible( - shellVariant: "legacy", - selectedTab: "Tasks", - visibleChatFirstRoute: nil, - expectedChatFirstRoute: "tasks", - expectedLegacyTitle: "Tasks" + shellVariant: DesktopAutomationSnapshot.singleShellVariant, + visibleChatFirstRoute: "chat", + expectedChatFirstRoute: "tasks" ) ) + // No shell has reported state yet: a target cannot be "visible" on nothing. XCTAssertFalse( DesktopAutomationNavigationVisibilityPolicy.isTargetVisible( - shellVariant: "loading", - selectedTab: "Tasks", - visibleChatFirstRoute: nil, - expectedChatFirstRoute: "tasks", - expectedLegacyTitle: "Tasks" + shellVariant: nil, + visibleChatFirstRoute: "tasks", + expectedChatFirstRoute: "tasks" ) ) } @@ -569,69 +550,6 @@ final class ChatFirstShellTests: XCTestCase { ) } - func testExplicitLegacyDesignIsTheOnlyPathThatMountsTheSidebarShell() throws { - var sample = ChatFirstShellCapabilitySample() - sample.resolve( - control: enabledControl(), - requestedOwnerID: "owner-a", - ownerIsStillCurrent: true - ) - - XCTAssertTrue( - DesktopShellPresentationPolicy.usesChatFirst(false, sample.variant) - ) - XCTAssertFalse( - DesktopShellPresentationPolicy.usesChatFirst(true, sample.variant) - ) - XCTAssertFalse( - DesktopShellPresentationPolicy.usesChatFirst(false, .legacy) - ) - } - - /// **The legacy shell has no Home stage, and must not claim one.** Its Home is the query surface; - /// the only branch there that still mounts `DashboardPage` needs `useLegacyHomeDesign`, which - /// renders `legacyHome`. So no value of any input can make the legacy shell report a stage mode. - /// - /// The bug this replaces reported `hub` for exactly this shell, forever, because the guard was - /// written when the non-legacy legacy-shell Home *was* `DashboardPage`. It never read as broken: - /// `hub` is a legitimate mode, so `/state` looked healthy while describing a surface that was not - /// mounted, and a flow waiting for `chat` waited for a transition nothing could produce. - func testTheLegacyShellReportsNoHomeStageModeWhateverItWasLastTold() { - for route in [ChatFirstRoute.chat, .more(.dashboard), .tasks] { - XCTAssertNil( - HomeStageAutomationPolicy.reportedHomeMode( - usesChatFirstShell: false, - chatFirstRoute: route, - lastPublishedMode: "hub"), - "the legacy shell renders no stage, so it may not report one even with a route in hand") - } - XCTAssertNil( - HomeStageAutomationPolicy.reportedHomeMode( - usesChatFirstShell: false, - chatFirstRoute: nil, - lastPublishedMode: "connect")) - } - - /// On the shell that *does* mount `DashboardPage`, the field carries what that page published — - /// unchanged, and `nil` until it has published anything. The shell is a courier here, not a source: - /// substituting a default is what turned a missing reading into a false one. - func testTheChatFirstShellCarriesTheStageOwnersValueWithoutInventingOne() { - for mode in ["hub", "chat", "connect"] { - XCTAssertEqual( - HomeStageAutomationPolicy.reportedHomeMode( - usesChatFirstShell: true, - chatFirstRoute: .chat, - lastPublishedMode: mode), - mode) - } - XCTAssertNil( - HomeStageAutomationPolicy.reportedHomeMode( - usesChatFirstShell: true, - chatFirstRoute: .chat, - lastPublishedMode: nil), - "before DashboardPage reports, the honest answer is 'not known', not 'hub'") - } - func testChatFirstGlassBoundaryWrapsOnlyRoutesWithoutTheirOwnPanels() { let wrapped: [ChatFirstRoute] = [ .goals, @@ -733,28 +651,6 @@ final class ChatFirstShellTests: XCTestCase { } } - /// Only the two routes that mount `DashboardPage` have a stage. Navigating away publishes `nil` - /// rather than leaving the last mode standing, which is how the field stops describing a page that - /// is no longer on screen. - func testOnlyTheRoutesThatMountDashboardPageReportAStage() { - XCTAssertTrue(HomeStageAutomationPolicy.mountsHomeStage(.chat)) - XCTAssertTrue(HomeStageAutomationPolicy.mountsHomeStage(.more(.dashboard))) - - for route: ChatFirstRoute in [ - .conversations, .tasks, .goals, .memories, - .more(.apps), .more(.rewind), .more(.settings), .more(.permissions), - ] { - XCTAssertFalse( - HomeStageAutomationPolicy.mountsHomeStage(route), - "\(route.stableName) does not render the stage") - XCTAssertNil( - HomeStageAutomationPolicy.reportedHomeMode( - usesChatFirstShell: true, - chatFirstRoute: route, - lastPublishedMode: "connect"), - "\(route.stableName) must not keep reporting the mode the stage had before we left it") - } - } } private final class ChatFirstGlassFrameRecorder: @unchecked Sendable { diff --git a/desktop/macos/Desktop/Tests/ChatSurfaceTestSupport.swift b/desktop/macos/Desktop/Tests/ChatSurfaceTestSupport.swift new file mode 100644 index 00000000000..3da14f0e031 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatSurfaceTestSupport.swift @@ -0,0 +1,46 @@ +import SwiftUI + +@testable import Omi_Computer + +/// Test seam for the two Chat views whose content-block context is now required. +/// +/// Most of these assertions are about row shape, hover regions, or view +/// identity, not about content blocks. They bind the same auxiliary context the +/// task panel and the notch use, so a test never asserts on a projection no +/// production surface has. +@MainActor +enum ChatSurfaceTestContext { + static func make(chatProvider: ChatProvider? = nil) -> ChatFirstRichBlockContext { + .auxiliary(chatProvider: chatProvider ?? ChatProvider.mainInstance ?? ChatProvider()) + } +} + +@MainActor +extension ChatBubble { + init( + message: ChatMessage, + app: OmiApp?, + showsOmiMark: Bool, + onRate: @escaping (Int?) -> Void, + onCitationTap: ((Citation) -> Void)? = nil, + onOpenInlineCitation: ((ChatCitationReference) -> Void)? = nil, + isDuplicate: Bool = false, + onCancelTurn: (() -> Void)? = nil, + onOpenAgent: ((UUID, @escaping (Bool) -> Void) -> Void)? = nil, + onOpenAgentRef: ((AgentTimelineRef, @escaping (Bool) -> Void) -> Void)? = nil + ) { + self.init( + message: message, + app: app, + showsOmiMark: showsOmiMark, + onRate: onRate, + onCitationTap: onCitationTap, + onOpenInlineCitation: onOpenInlineCitation, + isDuplicate: isDuplicate, + onCancelTurn: onCancelTurn, + onOpenAgent: onOpenAgent, + onOpenAgentRef: onOpenAgentRef, + chatFirstRichBlockContext: ChatSurfaceTestContext.make() + ) + } +} diff --git a/desktop/macos/Desktop/Tests/ChatTimelineContinuityTests.swift b/desktop/macos/Desktop/Tests/ChatTimelineContinuityTests.swift index b31886cec93..c82b98de551 100644 --- a/desktop/macos/Desktop/Tests/ChatTimelineContinuityTests.swift +++ b/desktop/macos/Desktop/Tests/ChatTimelineContinuityTests.swift @@ -1531,30 +1531,29 @@ final class ChatTimelineContinuityTests: XCTestCase { // Home is the only main-window chat surface now, so the assertions the // standalone chat page used to carry move onto it rather than retiring. - let dashboard = try String( - contentsOf: root.appendingPathComponent("Sources/MainWindow/Pages/DashboardPage.swift"), + // `QueryAnswerThread` is that surface (`DashboardPage` and its inline chat + // are gone), and it now binds the provider directly — the goal-citation + // rewrite that used to sit between them belonged to the deleted shell. + let answerThread = try String( + contentsOf: root.appendingPathComponent("Sources/MainWindow/QueryShell/QueryAnswerThread.swift"), encoding: .utf8) XCTAssertGreaterThanOrEqual( - dashboard.components(separatedBy: "messages: chatProvider.messages,").count - 1, - 2, + answerThread.components(separatedBy: "messages: chatProvider.messages,").count - 1, + 1, "Home chat surfaces must bind the shared ChatProvider timeline" ) XCTAssertFalse( - dashboard.contains("transcriptMessages"), + answerThread.contains("transcriptMessages"), "Home chat must not filter notch/PTT turns out of history" ) XCTAssertTrue( - dashboard.contains("openAgentChatFromTimeline(agentID: agentID, completion: completion)"), + answerThread.contains("openAgentChatFromTimeline(\n agentID: agentID, completion: completion)"), "Home chat must open spawned-agent links from the timeline with open result feedback" ) XCTAssertTrue( - dashboard.contains("openAgentChatFromTimeline(ref: ref, completion: completion)"), + answerThread.contains("openAgentChatFromTimeline(\n ref: ref, completion: completion)"), "Home chat must open structured agent refs with open result feedback" ) - XCTAssertFalse( - dashboard.contains("transcriptMessages"), - "Home chat must not filter notch/PTT turns out of history" - ) let floatingState = try String( contentsOf: root.appendingPathComponent("Sources/FloatingControlBar/FloatingControlBarState.swift"), diff --git a/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift b/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift index 5e2845d7c31..85b48cc9c42 100644 --- a/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift +++ b/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift @@ -867,6 +867,7 @@ struct HarnessChatHost: View { onRate: { _, _ in }, localSendToken: model.localSendToken, horizontalContentPadding: 0, + chatFirstRichBlockContext: ChatSurfaceTestContext.make(), transcriptWindowPolicy: model.transcriptWindowPolicy, welcomeContent: { EmptyView() } ) diff --git a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift index b932b2591f1..4a13676cbb9 100644 --- a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift +++ b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift @@ -30,20 +30,6 @@ final class DashboardCaptureStateTests: XCTestCase { XCTAssertEqual(CaptureListeningLogic.listeningStatus(appState: appState), .active) } - @MainActor - func testHomeListeningHelpDoesNotClaimOffWhileAwaitingAMeeting() { - let help = HomeListeningStatusButton.helpText( - status: .inactive, modeTitle: "Only Meetings", isAwaitingMeeting: true) - XCTAssertTrue(help.contains("waiting for a call")) - XCTAssertTrue(help.contains("Only Meetings")) - XCTAssertTrue(help.contains("Click to turn off")) - XCTAssertFalse(help.contains("Off")) - XCTAssertEqual( - HomeListeningStatusButton.helpText( - status: .inactive, modeTitle: "Always On", isAwaitingMeeting: false), - "Listening: Off, Always On") - } - @MainActor func testListeningModeTitlePreservesOakleyMetaName() { let appState = AppState() @@ -55,217 +41,6 @@ final class DashboardCaptureStateTests: XCTestCase { "Oakley Meta Vanguard") } - func testDashboardCaptureStatusUsesLiveMonitoringState() throws { - let source = try dashboardSource() - let logic = try captureLogicSource() - - // The header derives capture status from the shared CaptureListeningLogic… - XCTAssertTrue( - source.contains( - "CaptureListeningLogic.captureStatus(appState: appState, isCaptureMonitoring: isCaptureMonitoring)"), - "DashboardPage should derive capture status from the shared CaptureListeningLogic" - ) - // …which lights up from the LIVE monitor, never stale persisted intent. - XCTAssertTrue( - logic.contains("return isCaptureLive(isCaptureMonitoring: isCaptureMonitoring) ? .active : .inactive"), - "Capture status should light up when monitoring is live, even if persisted intent is stale" - ) - XCTAssertTrue( - logic.contains("isCaptureMonitoring || ProactiveAssistantsPlugin.shared.isMonitoring"), - "Live capture state must reflect the running monitor" - ) - XCTAssertFalse( - logic.contains("if screenAnalysisEnabled && isCaptureMonitoring {\n return .active\n }"), - "Capture status must not require persisted intent to match the live monitor" - ) - } - - func testDashboardCaptureToggleDerivesFromLiveState() throws { - let source = try dashboardSource() - let logic = try captureLogicSource() - - XCTAssertTrue( - source.contains("CaptureListeningLogic.toggleCapture("), - "DashboardPage's capture toggle should route through the shared CaptureListeningLogic" - ) - XCTAssertTrue( - logic.contains( - "syncCaptureState(screenAnalysisEnabled: screenAnalysisEnabled, isCaptureMonitoring: isCaptureMonitoring)"), - "Capture toggles should reconcile the live monitor before deciding whether the click starts or stops capture" - ) - XCTAssertTrue( - logic.contains("let enabled = !isCaptureLive(isCaptureMonitoring: isCaptureMonitoring.wrappedValue)"), - "Capture toggles should derive the next state from the live monitor" - ) - XCTAssertFalse( - logic.contains("let enabled = !screenAnalysisEnabled"), - "Capture toggles should not derive from stale persisted intent" - ) - } - - func testListeningPillReflectsTheUnifiedAudioRecordingMode() throws { - let source = try dashboardSource() - let logic = try captureLogicSource() - - XCTAssertTrue(source.contains("@AppStorage(AssistantSettings.audioRecordingModeDefaultsKey)")) - XCTAssertTrue(source.contains("private var listeningModeTitle: String")) - XCTAssertTrue(logic.contains("return appState.isAwaitingMeeting ? \"Only Meetings\" : \"In Meeting\"")) - XCTAssertTrue(source.contains("HomeListeningStatusButton(")) - XCTAssertFalse(source.contains("modeAction: toggleListeningMode")) - XCTAssertFalse(logic.contains("toggleListeningMode")) - XCTAssertTrue(source.contains(".frame(height: 34)")) - XCTAssertFalse(source.contains("Circle()\n .fill(status.indicator)")) - XCTAssertFalse(source.contains("OmiColors.purplePrimary")) - } - - func testListeningStatusIsSharedAndLiveTranscriptExpandReplacesThePage() throws { - let dashboard = try dashboardSource() - let logic = try captureLogicSource() - let conversations = try source(named: "ConversationsPage.swift") - let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - let shellURL = - testsURL - .deletingLastPathComponent() - .appendingPathComponent("Sources/MainWindow/QueryShell/ShellStatusIcons.swift") - // omi-test-quality: source-inspection -- static contract: which predicate the Live card and listening dot name is not observable from a running view without a window server - let shell = try String(contentsOf: shellURL, encoding: .utf8) - - XCTAssertTrue(logic.contains("return appState.isLiveCapturing ? .active : .inactive")) - XCTAssertTrue(dashboard.contains("CaptureListeningLogic.listeningStatus(appState: appState)")) - XCTAssertTrue(dashboard.contains("isAwaitingMeeting: appState.isAwaitingMeeting")) - XCTAssertTrue(shell.contains("CaptureListeningLogic.listeningStatus(appState: appState)")) - XCTAssertTrue(conversations.contains("if appState.isLiveCapturing {")) - XCTAssertTrue(conversations.contains("if isLiveTranscriptExpanded && appState.isLiveCapturing")) - XCTAssertFalse( - conversations.contains(".overlay {\n if isLiveTranscriptExpanded"), - "Expanding the live transcript must replace the Conversations page body, not overlay it.") - } - - func testRedesignedHomeUsesResponsiveStageSizing() throws { - let source = try dashboardSource() - - XCTAssertTrue(source.contains("private static let homeStageMaxWidth: CGFloat = 1360")) - XCTAssertTrue(source.contains("private static let homeAskBarMinWidth: CGFloat = 560")) - XCTAssertTrue(source.contains("private static let homeStagePanelMaxWidth: CGFloat = 1280")) - XCTAssertTrue(source.contains("private func homeStageSideInset(for stageWidth: CGFloat) -> CGFloat")) - XCTAssertTrue(source.contains("private func homeHubAskBarWidth(for stageWidth: CGFloat, draft: String) -> CGFloat")) - XCTAssertTrue( - source.contains("(text as NSString).size(withAttributes: [.font: NSFont.systemFont(ofSize: 15)]).width")) - XCTAssertTrue(source.contains("private func homeHubStage(stageWidth: CGFloat) -> some View")) - XCTAssertTrue(source.contains("private var homeHubHeadline: some View")) - XCTAssertFalse(source.contains(".frame(width: 304)")) - XCTAssertFalse(source.contains(".frame(maxWidth: Self.homeAskBarMaxWidth)")) - XCTAssertFalse(source.contains(".frame(maxWidth: Self.homeStagePanelMaxWidth)")) - } - - func testHomeAskBarRefocusesAfterOpeningChatStage() throws { - let source = try dashboardSource() - let openChat = try methodBody(named: "openHomeChat", in: source) - - XCTAssertTrue(source.contains("private func openHomeChat(focusInput: Bool = true)")) - XCTAssertTrue(source.contains("focusHomeAskFieldAfterStageTransition()")) - XCTAssertTrue(source.contains("await Task.yield()")) - XCTAssertTrue(source.contains("homeAskFieldFocused = true")) - XCTAssertTrue(source.contains("openHomeChat(focusInput: false)")) - // omi-test-quality: source-inspection -- static contract: the SwiftUI focus - // state and navigation method are private view wiring, so the hotkey's - // already-visible-chat path cannot be driven from the test host. - XCTAssertTrue( - openChat.contains("if homeMode != .chat {"), - "Opening an already-visible chat must still continue to the input-focus request") - XCTAssertFalse( - openChat.contains("guard homeMode != .chat else { return }"), - "An early return drops the hotkey's input focus when chat is already visible") - } - - func testSecondaryHomePagesReturnHomeOnEscape() { - for item in [SidebarNavItem.conversations, .memories, .tasks, .rewind] { - XCTAssertTrue( - DesktopHomeEscapeNavigation.shouldNavigateHome( - selectedIndex: item.rawValue, - usesLegacyHomeDesign: false - )) - } - // `.chat` was removed from `SidebarNavItem` when the standalone chat page was deleted. Escape on - // Home itself still must not navigate home, so the case moves to the destination Home now is. - XCTAssertFalse( - DesktopHomeEscapeNavigation.shouldNavigateHome( - selectedIndex: SidebarNavItem.dashboard.rawValue, - usesLegacyHomeDesign: false - )) - XCTAssertFalse( - DesktopHomeEscapeNavigation.shouldNavigateHome( - selectedIndex: SidebarNavItem.tasks.rawValue, - usesLegacyHomeDesign: true - )) - } - - func testHomeConnectorButtonsOpenSheetsDirectly() throws { - let source = try dashboardSource() - let importMethod = try methodBody(named: "openImportConnector", in: source) - let exportMethod = try methodBody(named: "openExportDestination", in: source) - - XCTAssertTrue(source.contains("@State private var selectedImportConnector: ImportConnector?")) - XCTAssertTrue(source.contains("@State private var selectedExportDestination: MemoryExportDestination?")) - XCTAssertFalse(source.contains(".dismissableSheet(item: $selectedImportConnector)")) - XCTAssertFalse(source.contains(".dismissableSheet(item: $selectedExportDestination)")) - XCTAssertTrue(importMethod.contains("presentImportConnector(connector)")) - XCTAssertTrue(exportMethod.contains("presentExportDestination(destination)")) - XCTAssertFalse(importMethod.contains("navigate(to: .apps)")) - XCTAssertFalse(exportMethod.contains("navigate(to: .apps)")) - } - - func testHomeMoreUsesTheCanonicalAppsPage() throws { - let source = try dashboardSource() - let openAppsMethod = try methodBody(named: "openAppsPage", in: source) - - XCTAssertTrue( - source.contains( - "HomeAIChoiceButton(title: \"More\", systemImage: \"plus\") {\n openAppsPage()" - )) - XCTAssertFalse(source.contains("private func appsPopupOverlay(")) - XCTAssertFalse(source.contains("@State private var isShowingAppsPopup")) - XCTAssertFalse(source.contains("\n AppsPage(")) - XCTAssertTrue(openAppsMethod.contains("appProvider.clearFilters()")) - XCTAssertTrue(openAppsMethod.contains("navigate(to: .apps)")) - } - - func testHomeConnectSheetsUseHomeScopedPresentation() throws { - let source = try dashboardSource() - let normalizedSource = normalizedWhitespace(source) - - XCTAssertTrue(source.contains("private var homeConnectSheetIsPresented: Bool")) - XCTAssertTrue(source.contains("private var legacySelectedCatalogApp: Binding")) - XCTAssertTrue(source.contains("private var legacySelectedImportConnector: Binding")) - XCTAssertTrue(source.contains("private var legacySelectedExportDestination: Binding")) - XCTAssertTrue(source.contains("homeConnectSheetOverlay(\n contentWidth: proxy.size.width")) - XCTAssertTrue( - source.contains("let sheetSize = homeConnectSheetSize(panelWidth: panelWidth, panelHeight: panelHeight)")) - XCTAssertTrue(source.contains(".position(x: contentWidth / 2, y: panelTop + panelHeight / 2)")) - // omi-test-quality: source-inspection -- static contract: wiring for the contextual connector - // sheet, which is unreachable for the same reason — - // `selectedImportConnector` and its siblings are private `@State`. The click that runs it is - // behavioural in `ShellModalScrimDismissTests`. - XCTAssertTrue( - normalizedSource.contains("ShellModalScrim(onTap: dismissHomeConnectSheet)"), - "The dim behind the Home connect sheet must carry its dismiss action, or clicking outside the " - + "sheet stops closing it") - XCTAssertFalse(source.contains("homeConnectSheetHasKeyboardFocus")) - XCTAssertTrue(source.contains("private func dismissHomeConnectSheet()")) - } - - func testHomeOverlaysStopHitTestingWhenDismissStarts() throws { - let source = try dashboardSource() - let connectDismissMethod = try methodBody(named: "dismissHomeConnectSheet", in: source) - - XCTAssertTrue(source.contains("@State private var homeConnectSheetAcceptsInput = false")) - XCTAssertTrue(source.contains(".allowsHitTesting(homeConnectSheetAcceptsInput)")) - XCTAssertTrue(source.contains("if homeConnectSheetAcceptsInput")) - XCTAssertTrue(connectDismissMethod.contains("homeConnectSheetAcceptsInput = false")) - XCTAssertTrue(connectDismissMethod.contains("selectedImportConnector = nil")) - XCTAssertTrue(connectDismissMethod.contains("selectedExportDestination = nil")) - } - func testConnectorRowsUseStatusConnectionForConnectedState() throws { let destinationSheet = try source(named: "MemoryExportDestinationSheet.swift") let groupedSheet = try source(named: "AgentConnectPickerSheet.swift") @@ -332,57 +107,6 @@ final class DashboardCaptureStateTests: XCTestCase { } } - func testHomeOverlaysBehaveLikeModals() throws { - let dashboard = try dashboardSource() - let apps = try appsSource() - // The `dismissableSheet` modifiers are the shared presentation primitive - // both Home overlays and the pages mount; they live beside the pages that - // use them rather than inside any one of them. - let dismissableSheet = try source(named: "DismissableSheet.swift") - let escapeKeyHandler = try escapeKeyHandlerSource() - let normalizedDashboard = normalizedWhitespace(dashboard) - - // Esc must dismiss the topmost overlay. Custom ZStack overlays are not - // NSWindow sheets, so Esc comes from the shared catcher's window-scoped - // key monitor — onExitCommand never fires (the overlays are never - // focused) and hidden cancel-shortcut buttons get culled from dispatch. - XCTAssertTrue(escapeKeyHandler.contains("struct OverlayModalEscapeCatcher: View")) - XCTAssertTrue(escapeKeyHandler.contains("struct EscapeKeyHandler: NSViewRepresentable")) - XCTAssertTrue(escapeKeyHandler.contains("NSEvent.addLocalMonitorForEvents(matching: .keyDown)")) - XCTAssertTrue(escapeKeyHandler.contains("registration.window === window")) - XCTAssertTrue( - normalizedDashboard.contains("OverlayModalEscapeCatcher { dismissHomeConnectSheet()")) - XCTAssertFalse( - dashboard.contains(".onExitCommand"), - "Home overlays must not rely on onExitCommand — it requires focus the overlays never receive" - ) - XCTAssertTrue( - dismissableSheet.contains( - "OverlayModalEscapeCatcher {\n log(\"DISMISSABLE_SHEET: Escape pressed")) - - // While an overlay is up, the content underneath must be hidden from - // VoiceOver / Full Keyboard Access and the panel marked as modal. - XCTAssertTrue(dashboard.contains("private var isHomeModalPresented: Bool")) - XCTAssertTrue(dashboard.contains(".accessibilityHidden(isHomeModalPresented)")) - XCTAssertTrue(dashboard.contains(".accessibilityAddTraits(.isModal)")) - XCTAssertTrue(dismissableSheet.contains(".accessibilityHidden(isPresented)")) - XCTAssertTrue(dismissableSheet.contains(".accessibilityHidden(item != nil)")) - XCTAssertTrue(dismissableSheet.contains(".accessibilityAddTraits(.isModal)")) - - // The close control must be a real, labeled button — not a tap gesture. - XCTAssertTrue(apps.contains("var accessibilityLabel: String = \"Close\"")) - XCTAssertTrue(apps.contains(".accessibilityLabel(accessibilityLabel)")) - } - - private func dashboardSource() throws -> String { - let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - let dashboardURL = - testsURL - .deletingLastPathComponent() - .appendingPathComponent("Sources/MainWindow/Pages/DashboardPage.swift") - return try String(contentsOf: dashboardURL, encoding: .utf8) - } - private func captureLogicSource() throws -> String { let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() let logicURL = diff --git a/desktop/macos/Desktop/Tests/DesktopChatDriftGuardTests.swift b/desktop/macos/Desktop/Tests/DesktopChatDriftGuardTests.swift index c526fbab43c..d2793660e29 100644 --- a/desktop/macos/Desktop/Tests/DesktopChatDriftGuardTests.swift +++ b/desktop/macos/Desktop/Tests/DesktopChatDriftGuardTests.swift @@ -178,35 +178,33 @@ final class DesktopChatDriftGuardTests: XCTestCase { XCTAssertTrue(messagesSource.contains("ChatScrollLiveEdge.canResumeFollowing")) } - func testChatFirstShellUsesModernTopNavigation() throws { + func testEveryMainWindowChatSurfaceSharesOneRendererAndOneContext() throws { let shellSource = try sourceFile("MainWindow/ChatFirst/ChatFirstShell.swift") let queryHomeSource = try sourceFile("MainWindow/QueryShell/QueryShellHome.swift") let answerThreadSource = try sourceFile("MainWindow/QueryShell/QueryAnswerThread.swift") - let dashboardSource = try sourceFile("MainWindow/Pages/DashboardPage.swift") + let bubbleSource = try sourceFile("MainWindow/Components/ChatBubble.swift") + let taskPanelSource = try sourceFile("MainWindow/Components/TaskChatPanel.swift") - // omi-test-quality: source-inspection -- static contract: the Chat-first shell must share the modern - // top-navigation and the single QueryShellHome chat surface, while rich-block capability and - // visible-transcript lifecycle remain threaded through the shared answer view. + // omi-test-quality: source-inspection -- static contract: there is one shell, one chat + // destination, and one content-block context threaded through every host. Behavioural coverage + // of what the blocks then do lives in `OneChatShellRichBlockTests`. XCTAssertTrue(shellSource.contains("DesktopTopBar(")) - // The shell keeps the modern chat surface in one shared destination so - // the legacy Dashboard alias cannot drift into a second implementation. XCTAssertTrue(shellSource.contains("case .chat, .more(.dashboard):")) XCTAssertTrue(shellSource.contains("private var chatDestination: some View")) - XCTAssertFalse(shellSource.contains("case .chat:\n DashboardPage(")) - XCTAssertTrue(shellSource.contains("forceModernPresentation: true")) XCTAssertTrue(shellSource.contains("chatFirstRichBlockContext: richBlockContext")) - XCTAssertTrue(queryHomeSource.contains("forceModernPresentation")) - XCTAssertTrue(queryHomeSource.contains("chatFirstRichBlockContext: chatFirstRichBlockContext")) - XCTAssertTrue(answerThreadSource.contains("chatFirstRichBlockContext: chatFirstRichBlockContext")) XCTAssertTrue(answerThreadSource.contains("chatTranscriptFirstPageDidLoad()")) XCTAssertTrue(answerThreadSource.contains("chatTranscriptDidDisappear()")) XCTAssertFalse(shellSource.contains("ChatFirstSidebar(")) XCTAssertFalse(shellSource.contains("\n ChatPage(")) - XCTAssertTrue(dashboardSource.contains("chatFirstRichBlockContext: chatFirstRichBlockContext")) - XCTAssertTrue(dashboardSource.contains("chatTranscriptFirstPageDidLoad()")) - let homeSource = try sourceFile("MainWindow/DesktopHomeView.swift") - XCTAssertTrue(homeSource.contains("if usesChatFirstShell,")) + // The context is a required binding on every host, not an optional capability. + XCTAssertTrue( + queryHomeSource.contains("let chatFirstRichBlockContext: ChatFirstRichBlockContext")) + XCTAssertTrue( + answerThreadSource.contains("let chatFirstRichBlockContext: ChatFirstRichBlockContext")) + XCTAssertTrue( + bubbleSource.contains("let chatFirstRichBlockContext: ChatFirstRichBlockContext")) + XCTAssertTrue(taskPanelSource.contains("chatFirstRichBlockContext: .auxiliary(")) } /// The fade is the notch's alone now that the standalone chat page is gone. @@ -215,7 +213,7 @@ final class DesktopChatDriftGuardTests: XCTestCase { /// an incoming reply — so this pins the notch and pins Home's abstention. func testTheTranscriptFadeIsTheNotchsAloneAndHomeAbstains() throws { let notchChat = try sourceFile("FloatingControlBar/AIResponseView.swift") - let home = try sourceFile("MainWindow/Pages/DashboardPage.swift") + let home = try sourceFile("MainWindow/QueryShell/QueryAnswerThread.swift") XCTAssertTrue(notchChat.contains(".overlay(alignment: .bottom) {\n ChatComposerFade()")) XCTAssertFalse( @@ -224,9 +222,9 @@ final class DesktopChatDriftGuardTests: XCTestCase { } func testChatTranscriptLoaderIgnoresSessionListRefreshes() throws { - let dashboardPage = try sourceFile("MainWindow/Pages/DashboardPage.swift") + let answerThread = try sourceFile("MainWindow/QueryShell/QueryAnswerThread.swift") - for source in [dashboardPage] { + for source in [answerThread] { XCTAssertFalse( source.contains("isLoadingInitial: (chatProvider.isLoading || chatProvider.isLoadingSessions)"), "Session-list refreshes must not hide a non-empty transcript behind the initial message-history loader." @@ -238,9 +236,9 @@ final class DesktopChatDriftGuardTests: XCTestCase { } XCTAssertEqual( - dashboardPage.components(separatedBy: "isLoadingInitial: chatProvider.isLoading && !chatProvider.isClearing") + answerThread.components(separatedBy: "isLoadingInitial: chatProvider.isLoading && !chatProvider.isClearing") .count - 1, - 2 + 1 ) } diff --git a/desktop/macos/Desktop/Tests/FloatingBarNotificationGroundTests.swift b/desktop/macos/Desktop/Tests/FloatingBarNotificationGroundTests.swift index 77a3f1b328d..08887af462c 100644 --- a/desktop/macos/Desktop/Tests/FloatingBarNotificationGroundTests.swift +++ b/desktop/macos/Desktop/Tests/FloatingBarNotificationGroundTests.swift @@ -47,7 +47,8 @@ final class FloatingBarNotificationGroundTests: XCTestCase { ownerID: "test-owner", title: "Couldn't reach Omi", message: "Error 502", - assistantId: assistantID) + assistantId: assistantID, + kind: ProactiveNotificationKind.from(assistantId: assistantID)) return state } diff --git a/desktop/macos/Desktop/Tests/FloatingBarNotificationPreviewPolicyTests.swift b/desktop/macos/Desktop/Tests/FloatingBarNotificationPreviewPolicyTests.swift index d1efeff6c90..ed2c576f9f2 100644 --- a/desktop/macos/Desktop/Tests/FloatingBarNotificationPreviewPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/FloatingBarNotificationPreviewPolicyTests.swift @@ -450,11 +450,12 @@ final class FloatingBarNotificationPreviewPolicyTests: XCTestCase { @MainActor func testNotificationsAreNotPersistentByDefault() { let plain = FloatingBarNotification( - ownerID: "owner", title: "t", message: "m", assistantId: "default") + ownerID: "owner", title: "t", message: "m", assistantId: "default", kind: .functional) XCTAssertFalse(plain.isPersistent) let share = FloatingBarNotification( ownerID: "owner", title: "t", message: "m", assistantId: MeetingActionItemBannerPolicy.assistantID, + kind: .meetingNotes, action: .meetingSummaryShare(conversationID: "c1", recipients: []), isPersistent: true) XCTAssertTrue(share.isPersistent) diff --git a/desktop/macos/Desktop/Tests/FloatingOwnerProjectionTests.swift b/desktop/macos/Desktop/Tests/FloatingOwnerProjectionTests.swift index a0f82292852..07a75920d14 100644 --- a/desktop/macos/Desktop/Tests/FloatingOwnerProjectionTests.swift +++ b/desktop/macos/Desktop/Tests/FloatingOwnerProjectionTests.swift @@ -61,7 +61,8 @@ final class FloatingOwnerProjectionTests: XCTestCase { title: "owner A private title", message: "owner A private content", assistantId: "insight", - sound: .none) + sound: .none, + kind: .insight) } await gate.waitUntilStarted() defaults.set("owner-b", forKey: .authUserId) @@ -99,6 +100,7 @@ final class FloatingOwnerProjectionTests: XCTestCase { message: "must not mark delivered", assistantId: "context-director", sound: .none, + kind: .insight, onPresented: { presentedCount += 1 }, onDropped: { droppedCount += 1 }) } diff --git a/desktop/macos/Desktop/Tests/GlassPanelHitRegionTests.swift b/desktop/macos/Desktop/Tests/GlassPanelHitRegionTests.swift index 9a71ce6658c..bc763bfd569 100644 --- a/desktop/macos/Desktop/Tests/GlassPanelHitRegionTests.swift +++ b/desktop/macos/Desktop/Tests/GlassPanelHitRegionTests.swift @@ -94,10 +94,10 @@ final class GlassPanelHitRegionTests: XCTestCase { "the band above the panel is air and must not swallow a click aimed at another app") } - /// Legacy Home hosts both its primary navigation and Settings menu beside `PageGlassLane`. The - /// slot must give either menu the real shared glass and its matching hit region, while the modern - /// panel-hosted Settings menu must inherit the page panel instead of adding a second material. - func testLegacySidebarSlotOwnsGlassAndHitsForBothMenus() throws { + /// The panel-hosted Settings menu inherits the page panel rather than adding a second material, + /// so it owns no standalone glass surface and claims no hit region of its own. (The legacy + /// sidebar shell this used to compare against is gone.) + func testPanelHostedSettingsMenuAddsNoSecondSurface() throws { defer { teardownWindow() } for host in SidebarHost.allCases { @@ -166,13 +166,9 @@ final class GlassPanelHitRegionTests: XCTestCase { private enum SidebarHost: CaseIterable { case panelSettings - case legacySettings - case legacyNavigation - var expectsSurface: Bool { self != .panelSettings } - var width: CGFloat { - self == .legacyNavigation ? 64 : SettingsSidebarMetrics.expandedWidth - } + var expectsSurface: Bool { false } + var width: CGFloat { SettingsSidebarMetrics.expandedWidth } } private func mountSidebar(_ host: SidebarHost) -> NSRect { @@ -190,18 +186,6 @@ final class GlassPanelHitRegionTests: XCTestCase { switch host { case .panelSettings: sidebar = AnyView(settingsSidebar) - case .legacySettings: - sidebar = AnyView(LegacySidebarSurface(reduceTransparency: false) { settingsSidebar }) - case .legacyNavigation: - sidebar = AnyView( - LegacySidebarSurface(reduceTransparency: false) { - SidebarView( - selectedIndex: .constant(SidebarNavItem.dashboard.rawValue), - isCollapsed: .constant(true), - memoryDestinationRawValue: .constant(MemoryHubDestination.memories.rawValue), - appState: AppState() - ) - }) } let root = HStack(spacing: 0) { diff --git a/desktop/macos/Desktop/Tests/HomeAskFocusPolicyTests.swift b/desktop/macos/Desktop/Tests/HomeAskFocusPolicyTests.swift deleted file mode 100644 index 8f8b079b712..00000000000 --- a/desktop/macos/Desktop/Tests/HomeAskFocusPolicyTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -import XCTest - -@testable import Omi_Computer - -/// Deterministic policy test for the stale deferred Home ask-field focus fix -/// (home-stage S6 regression). Exercises the real production policy directly — -/// no run loop, no sleeps. -final class HomeAskFocusPolicyTests: XCTestCase { - func testFreshTokenMatchesCurrentGeneration() { - let policy = HomeAskFocusPolicy() - let token = policy.currentToken() - XCTAssertTrue(policy.isCurrent(token)) - XCTAssertEqual(token.generation, 0) - } - - func testInvalidateStalesEveryPriorToken() { - let policy = HomeAskFocusPolicy() - let token = policy.currentToken() - - policy.invalidate() - - XCTAssertFalse( - policy.isCurrent(token), - "A token captured before an invalidate must no longer be current") - } - - func testTokenCapturedAfterInvalidateIsCurrent() { - let policy = HomeAskFocusPolicy() - let stale = policy.currentToken() - policy.invalidate() - let fresh = policy.currentToken() - - XCTAssertFalse(policy.isCurrent(stale)) - XCTAssertTrue(policy.isCurrent(fresh)) - } - - func testGenerationIsStrictlyMonotonic() { - let policy = HomeAskFocusPolicy() - let first = policy.currentToken() - - XCTAssertEqual(policy.invalidate(), 1) - XCTAssertEqual(policy.invalidate(), 2) - XCTAssertEqual(policy.invalidate(), 3) - - XCTAssertFalse(policy.isCurrent(first)) - XCTAssertTrue(policy.isCurrent(policy.currentToken())) - XCTAssertEqual(policy.generation, 3) - } - - /// The exact regression: `openHomeChat` schedules a deferred focus, then a - /// connect / collapse / close lands before the yielded focus resumes. The - /// deferred focus must be dropped, not applied (applying it would set the ask - /// field focused while not in chat, and the focus observer would reopen chat). - func testStaleDeferredFocusIsDroppedAfterCollapseOrClose() { - let policy = HomeAskFocusPolicy() - - // openHomeChat(focusInput: true) captures the generation it scheduled against. - let scheduledToken = policy.currentToken() - - // Before the yielded focus resumes, the user collapses (Esc / click-outside - // / connect ×) or the automation bridge closes — every one of these - // invalidates outstanding deferred focus. - policy.invalidate() - - // The deferred focus resumes and re-checks its generation: stale → skip. - XCTAssertFalse( - policy.isCurrent(scheduledToken), - "A deferred focus scheduled before a collapse/connect/close must be dropped") - } - - /// An unrelated later open must still be able to focus: invalidation only - /// kills the superseded generation, not subsequent ones. - func testSubsequentOpenCanStillFocusAfterAnInvalidate() { - let policy = HomeAskFocusPolicy() - _ = policy.currentToken() - policy.invalidate() - - let reopenedToken = policy.currentToken() - XCTAssertTrue(policy.isCurrent(reopenedToken)) - } -} diff --git a/desktop/macos/Desktop/Tests/HomeKnowsComposerTests.swift b/desktop/macos/Desktop/Tests/HomeKnowsComposerTests.swift deleted file mode 100644 index 8614951b52b..00000000000 --- a/desktop/macos/Desktop/Tests/HomeKnowsComposerTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import XCTest - -@testable import Omi_Computer - -final class HomeKnowsComposerTests: XCTestCase { - private let tasks = [ - HomeKnowsTaskCandidate(id: "t1", text: "Submit the Design PR by 7pm"), - HomeKnowsTaskCandidate(id: "t2", text: "Reply to Sarah"), - ] - private let insights = [ - HomeKnowsInsightCandidate(id: "i1", text: "Deepgram spend is pacing 18% over last week"), - HomeKnowsInsightCandidate(id: "i2", text: "Two meetings overlap on Thursday"), - ] - private let questions = ["What should I do today?", "What did I spend my time on this week?"] - - func testComposePicksTaskInsightTaskQuestionWhenAllAvailable() { - let rows = HomeKnowsListComposer.compose(tasks: tasks, insights: insights, questions: questions) - - // Diverse 4-slot brief: pressing task, one insight, a second task, then a prefilled ask. - XCTAssertEqual(rows.count, 4) - XCTAssertEqual(rows[0].kind, .task(id: "t1")) - XCTAssertEqual(rows[0].text, "Submit the Design PR by 7pm") - XCTAssertEqual(rows[1].kind, .insight(id: "i1")) - XCTAssertEqual(rows[2].kind, .task(id: "t2")) - XCTAssertEqual(rows[3].kind, .question) - XCTAssertEqual(rows[3].text, "What should I do today?") - } - - func testDismissedTaskFallsThroughToNextTask() { - let rows = HomeKnowsListComposer.compose( - tasks: tasks, insights: insights, questions: questions, dismissedTaskIDs: ["t1"]) - - XCTAssertEqual(rows[0].kind, .task(id: "t2")) - } - - func testAllTasksDismissedFillsWithOneInsightAndQuestion() { - let rows = HomeKnowsListComposer.compose( - tasks: tasks, insights: insights, questions: questions, dismissedTaskIDs: ["t1", "t2"]) - - // At most one insight (the tip slot); the ask fills the remaining slot. - XCTAssertEqual(rows.count, 2) - XCTAssertEqual(rows[0].kind, .insight(id: "i1")) - XCTAssertEqual(rows[1].kind, .question) - } - - func testSingleAskWhenNoTasksOrInsights() { - let rows = HomeKnowsListComposer.compose( - tasks: [], insights: [], questions: questions + ["Third question?"]) - - // Only one prefilled ask is ever surfaced — the list never collapses into all-questions. - XCTAssertEqual(rows.count, 1) - XCTAssertEqual(rows[0].kind, .question) - XCTAssertEqual(rows[0].text, "What should I do today?") - } - - func testSecondTaskFillsLastSlotWhenNoQuestionExists() { - let rows = HomeKnowsListComposer.compose(tasks: tasks, insights: insights, questions: []) - - // With no ask, the last slot goes to a second task — never a second insight. - XCTAssertEqual(rows.count, 3) - XCTAssertEqual(rows[0].kind, .task(id: "t1")) - XCTAssertEqual(rows[1].kind, .insight(id: "i1")) - XCTAssertEqual(rows[2].kind, .task(id: "t2")) - } - - func testEmptyAndWhitespaceEntriesAreSkipped() { - let rows = HomeKnowsListComposer.compose( - tasks: [HomeKnowsTaskCandidate(id: "t0", text: " ")], - insights: [HomeKnowsInsightCandidate(id: "i0", text: "")], - questions: [" ", "Real question?"]) - - XCTAssertEqual(rows.count, 1) - XCTAssertEqual(rows[0].kind, .question) - XCTAssertEqual(rows[0].text, "Real question?") - } - - func testEverythingEmptyProducesNoRows() { - XCTAssertTrue(HomeKnowsListComposer.compose(tasks: [], insights: [], questions: []).isEmpty) - } - - func testDuplicateQuestionsDoNotCollideAcrossQuestionRows() { - // Question rows derive their ForEach ID from the text, so a repeated - // suggestion must never surface twice. The redesign surfaces at most two - // question-kind rows (a composed tip in the second slot and a distinct ask - // in the last), so use a tip to exercise both and assert the repeat is - // dropped and the two IDs stay unique. - let rows = HomeKnowsListComposer.compose( - tasks: [], insights: [], - tip: "What should I do today?", - questions: ["What should I do today?", " What should I do today? ", "Second question?"]) - - XCTAssertEqual(rows.count, 2) - XCTAssertEqual(rows.map(\.kind), [.question, .question]) - XCTAssertEqual(rows.map(\.text), ["What should I do today?", "Second question?"]) - XCTAssertEqual(Set(rows.map(\.id)).count, rows.count) - } -} diff --git a/desktop/macos/Desktop/Tests/HomeStageCloseSemanticsTests.swift b/desktop/macos/Desktop/Tests/HomeStageCloseSemanticsTests.swift index 0c186c0a0c4..309bd70daec 100644 --- a/desktop/macos/Desktop/Tests/HomeStageCloseSemanticsTests.swift +++ b/desktop/macos/Desktop/Tests/HomeStageCloseSemanticsTests.swift @@ -97,97 +97,4 @@ final class HomeStageCloseSemanticsTests: XCTestCase { // MARK: Flow (static contract over DashboardPage wiring) - /// hub → chat → connect → close must collapse to the resting surface, and a - /// later `home_ask` must rest in chat — never force-jump to the hub. - func testAutomationCloseRoutesToUserCollapseNotHubJump() throws { - let source = try dashboardSource() - - XCTAssertFalse( - source.contains("closeHomeStagePanel"), - "The divergent hub-jump close path must stay gone; close routes through collapseHomeStagePanel") - - let closeHandler = try XCTUnwrap(source.range(of: ".homeStageClose")) - let handlerSlice = source[closeHandler.lowerBound...].prefix(300) - XCTAssertTrue( - handlerSlice.contains("collapseHomeStagePanel()"), - "home_close_panel must call the same collapse the on-screen controls call") - } - - /// The deferred focus must fence itself: capture a generation token, drop on - /// invalidate, and never land off the chat stage. Collapse and connect must - /// both invalidate it. - func testDeferredFocusFenceIsWired() throws { - let source = try dashboardSource() - - XCTAssertTrue( - source.contains("guard homeAskFocusPolicy.isCurrent(token), homeMode == .chat else { return }"), - "A deferred focus must drop itself if invalidated and never land on a non-chat stage") - - let invalidateCount = source.components(separatedBy: "homeAskFocusPolicy.invalidate()").count - 1 - XCTAssertEqual( - invalidateCount, 2, - "Both collapseHomeStagePanel and toggleHomeConnectPanel must invalidate deferred focus") - } - - /// Asking (via the ask bar) opens chat. After history restoration, the - /// resting surface follows the shared history-presentation policy. - func testHomeRestingModeFollowsLoadedHistoryPolicy() throws { - let source = try dashboardSource() - - let resting = try computedPropertyBody(named: "homeRestingMode", in: source) - XCTAssertTrue(resting.contains("HomeHistoryPresentationPolicy.restingMode(")) - XCTAssertTrue(resting.contains("isLoading: chatProvider.isLoading")) - XCTAssertTrue(resting.contains("messageCount: chatProvider.messages.count")) - - let ask = try methodBody(named: "sendFromHomeAskBar", in: source) - XCTAssertTrue( - ask.contains("openHomeChat(focusInput: false)"), - "Sending from the ask bar must open the chat surface, where it rests") - } - - // MARK: Helpers - - private func dashboardSource() throws -> String { - let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - let dashboardURL = - testsURL - .deletingLastPathComponent() - .appendingPathComponent("Sources/MainWindow/Pages/DashboardPage.swift") - // omi-test-quality: source-inspection -- static contract: DashboardPage stage-close and deferred-focus wiring lives in SwiftUI @State/@FocusState and cannot be driven without a booted view - return try String(contentsOf: dashboardURL, encoding: .utf8) - } - - private func methodBody(named name: String, in source: String) throws -> String { - guard let declaration = source.range(of: "private func \(name)(") else { - throw NSError(domain: "HomeStageCloseSemanticsTests", code: 1) - } - guard let openingBrace = source[declaration.upperBound...].firstIndex(of: "{") else { - throw NSError(domain: "HomeStageCloseSemanticsTests", code: 2) - } - - var depth = 0 - var cursor = openingBrace - while cursor < source.endIndex { - switch source[cursor] { - case "{": depth += 1 - case "}": - depth -= 1 - if depth == 0 { - return String(source[source.index(after: openingBrace).. String { - let pattern = #"private var \#(name): [^{]+\{([\s\S]*?)\n\s+\}"# - let regex = try NSRegularExpression(pattern: pattern) - let range = NSRange(source.startIndex.. UserDefaults { + let suiteName = "OneChatShellRichBlockTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + addTeardownBlock { defaults.removePersistentDomain(forName: suiteName) } + return defaults + } + + /// A single assistant turn carrying prose plus all six interactable kinds. + private var everyRichBlockMessage: ChatMessage { + var message = ChatMessage(id: "assistant-1", text: "", sender: .ai) + message.contentBlocks = [ + .text(id: "text", text: "Here is what I found."), + .questionCard( + id: "question", questionId: "question-1", text: "Which one?", + subjectKind: "goal", subjectId: "goal-1", + options: [["optionId": "a", "label": "The first"], ["optionId": "b", "label": "The second"]] + ), + .taskCard(id: "task", taskId: "task-1"), + .goalLink(id: "goal", goalId: "goal-1", summary: "Ship the shell"), + .captureLink( + id: "capture", conversationId: "capture-1", momentTimestampMs: 4_000, summary: "Standup"), + .conversationLink( + id: "conversation", conversationId: "conversation-1", summary: "Design review", + recommendedActionItems: []), + .memoryLink(id: "memory", memoryId: "memory-1", summary: "Prefers mornings"), + ] + return message + } + + func testOneTurnYieldsEveryTypedGroupInTranscriptOrder() { + let groups = ContentBlockGroup.visibleChatGroups( + everyRichBlockMessage.contentBlocks, isStreaming: false) + + XCTAssertEqual( + groups.map(\.id), + ["text", "question", "task", "goal", "capture", "conversation", "memory"], + "grouping must preserve the transcript's order and drop nothing") + + var kinds: [String] = [] + for group in groups { + switch group { + case .text: kinds.append("text") + case .questionCard: kinds.append("questionCard") + case .taskCard: kinds.append("taskCard") + case .goalLink: kinds.append("goalLink") + case .captureLink: kinds.append("captureLink") + case .conversationLink: kinds.append("conversationLink") + case .memoryLink: kinds.append("memoryLink") + default: kinds.append("unexpected") + } + } + XCTAssertEqual( + kinds, + ["text", "questionCard", "taskCard", "goalLink", "captureLink", "conversationLink", "memoryLink"]) + } + + /// The same turn, grouped through the projection the notch and task panel use. + /// They call the identical entry point, so "the notch renders fewer kinds" is + /// no longer expressible. + func testStreamingAndSettledProjectionsBothKeepEveryRichBlock() { + let blocks = everyRichBlockMessage.contentBlocks + XCTAssertEqual(ContentBlockGroup.visibleChatGroups(blocks, isStreaming: true).count, 7) + XCTAssertEqual(ContentBlockGroup.visibleChatGroups(blocks, isStreaming: false).count, 7) + } + + /// Each link block's tap lands on the typed focus its card promises, through + /// the context every host now carries. Recording is the navigation owner's own + /// published state, not a stubbed closure. + func testEveryLinkBlockActionReachesItsTypedNavigationTarget() throws { + let navigation = ChatFirstShellNavigation(defaults: try defaults()) + + navigation.open(focus: .task(id: "task-1")) + XCTAssertEqual(navigation.route, .tasks) + XCTAssertEqual(navigation.pendingFocus, .task(id: "task-1")) + + navigation.open(focus: .goal(id: "goal-1")) + XCTAssertEqual(navigation.route, .goals) + XCTAssertEqual(navigation.pendingFocus, .goal(id: "goal-1")) + + navigation.open(focus: .capture(id: "capture-1", momentTs: 4)) + XCTAssertEqual(navigation.route, .memories) + XCTAssertEqual(navigation.pendingFocus, .capture(id: "capture-1", momentTs: 4)) + + navigation.open(focus: .memory(id: "memory-1")) + XCTAssertEqual(navigation.route, .memories) + XCTAssertEqual(navigation.pendingFocus, .memory(id: "memory-1")) + + // The conversation link carries the exact fetched record rather than an id, + // and lands on the hub-owned Conversations destination. + let record = ChatFirstRichBlockTestConversation.make(id: "conversation-1") + navigation.open(conversation: record) + XCTAssertEqual(navigation.route, .memories) + XCTAssertEqual(navigation.pendingConversation?.id, "conversation-1") + XCTAssertNil(navigation.pendingFocus) + } + + /// A goal link resolves asynchronously; a newer link must win. This is the one + /// action with a fence, so it is asserted through the fence's own API. + func testGoalLinkResolutionFenceKeepsTheNewestRequest() throws { + let navigation = ChatFirstShellNavigation(defaults: try defaults()) + let stale = navigation.beginGoalLinkResolution() + let fresh = navigation.beginGoalLinkResolution() + + XCTAssertFalse(navigation.completeGoalLinkResolution(goalID: "goal-stale", generation: stale)) + XCTAssertNil(navigation.pendingFocus) + XCTAssertTrue(navigation.completeGoalLinkResolution(goalID: "goal-fresh", generation: fresh)) + XCTAssertEqual(navigation.pendingFocus, .goal(id: "goal-fresh")) + } + + // MARK: - Capability-off + + /// Capability-off must degrade, not disappear. The question card is the only + /// block whose *action* needs the server projection, so it is the only one that + /// changes — and it changes to "visible but unpressable", never to "hidden". + func testCapabilityOffDisablesQuestionOptionsWithoutHidingThem() { + let off = ChatFirstQuestionCardOptionsPolicy.presentation( + isActionable: false, isCapabilityAvailable: false, hasSelection: false, hasOptions: true) + XCTAssertEqual(off, .disabled) + XCTAssertTrue(off.isVisible, "a question with no visible answers explains nothing") + XCTAssertFalse(off.isPressable) + + let on = ChatFirstQuestionCardOptionsPolicy.presentation( + isActionable: true, isCapabilityAvailable: true, hasSelection: false, hasOptions: true) + XCTAssertEqual(on, .enabled) + XCTAssertTrue(on.isPressable) + } + + /// The two ways a live capability retires a question keep hiding its options: + /// it has been answered, or its turn is no longer the transcript's tail. + func testAnsweredOrRetiredQuestionStillHidesItsOptions() { + XCTAssertEqual( + ChatFirstQuestionCardOptionsPolicy.presentation( + isActionable: false, isCapabilityAvailable: true, hasSelection: true, hasOptions: true), + .hidden) + XCTAssertEqual( + ChatFirstQuestionCardOptionsPolicy.presentation( + isActionable: false, isCapabilityAvailable: true, hasSelection: false, hasOptions: true), + .hidden, + "a question that has lost the tail is history, not an offer") + XCTAssertEqual( + ChatFirstQuestionCardOptionsPolicy.presentation( + isActionable: true, isCapabilityAvailable: false, hasSelection: false, hasOptions: false), + .hidden) + } + + /// The task card is bound to `TasksStore`, so checking one off never consulted + /// the projection and must keep working with it absent. + func testTaskCardStaysActionableWithNoCapabilityProjection() { + var gate = ChatFirstMainChatProjectionGate() + XCTAssertTrue(gate.configure(sample: nil, ownerID: "owner-a")) + XCTAssertNil(gate.capability(for: .mainChat(chatId: nil), ownerID: "owner-a")) + + // Nothing in the task card's own presentation consults the gate: its display + // is derived from the store's record alone. + let task = TaskActionItem( + id: "task-1", description: "Ship it", completed: false, createdAt: Date(), + dueAt: nil, completedAt: nil, deleted: false) + XCTAssertEqual( + ChatFirstTaskCardPresentation.displayTask(liveTask: task, retainedCompletedTask: nil)?.id, + "task-1") + XCTAssertTrue( + ChatFirstTaskCardReconciliation.shouldShowCompletionAcknowledgement( + intendedCompletion: true, + reconciledTask: TaskActionItem( + id: "task-1", description: "Ship it", completed: true, createdAt: Date(), + dueAt: nil, completedAt: Date(), deleted: false))) + } +} + +/// A minimal server record for the conversation-link assertions above. +enum ChatFirstRichBlockTestConversation { + static func make(id: String) -> ServerConversation { + ServerConversation( + id: id, + createdAt: Date(timeIntervalSince1970: 1_000), + updatedAt: Date(timeIntervalSince1970: 1_001), + startedAt: Date(timeIntervalSince1970: 1_000), + finishedAt: Date(timeIntervalSince1970: 1_060), + structured: Structured( + title: "Design review", + overview: "Overview", + emoji: "", + category: "other", + actionItems: [], + events: [] + ), + transcriptSegments: [], + transcriptSegmentsIncluded: false, + geolocation: nil, + photos: [], + appsResults: [], + source: .desktop, + language: "en", + status: .completed, + discarded: false, + deleted: false, + isLocked: false, + starred: false, + folderId: nil, + inputDeviceName: nil, + deferred: false + ) + } +} diff --git a/desktop/macos/Desktop/Tests/ProactiveNotificationKindTests.swift b/desktop/macos/Desktop/Tests/ProactiveNotificationKindTests.swift new file mode 100644 index 00000000000..3340f63976d --- /dev/null +++ b/desktop/macos/Desktop/Tests/ProactiveNotificationKindTests.swift @@ -0,0 +1,108 @@ +import XCTest + +@testable import Omi_Computer + +/// Every proactive card must say what it is before it can reach the transcript. +/// +/// `showNotification` used to take an optional `kind:` and `FloatingBarNotification` +/// filled it in from `assistantId`, whose default arm was `.general`. Five +/// producers never passed one, so their rows journaled a bare +/// `notification:` key and came back badged "Notification" — a row that +/// says nothing about what Omi noticed. Nothing failed; the copy was simply +/// wrong forever. +final class ProactiveNotificationKindTests: XCTestCase { + /// Every assistant id a producer actually ships, mapped to what its card is. + /// `.general` is not reachable from any of them — it is decode-only. + private static let producerAssistantIDs = [ + "suggestion", "insight", "task", "memory-extraction", "goals", "meeting-notes", + "integration_connect", "context-director", "trial", "onboarding", + "notch_receipt", "notch_end", "reach_error", "unknown-future-assistant", + ] + + /// The transcript row a journaled notification comes back as. + private static func journaledRow(clientTurnId: String) -> ChatMessage { + ChatMessage(id: UUID().uuidString, clientTurnId: clientTurnId, text: "Body", sender: .ai) + } + + func testNoProducerAssistantIDDerivesTheDecodeOnlyGeneralKind() { + for assistantID in Self.producerAssistantIDs { + XCTAssertNotEqual( + ProactiveNotificationKind.from(assistantId: assistantID), .general, + "\(assistantID) would journal a bare notification: key badged \"Notification\"") + } + for decisionType in ["suggest", "focus_nudge", "insight", "task_candidate", "resurface", ""] { + XCTAssertNotEqual(ProactiveNotificationKind.from(decisionType: decisionType), .general) + } + } + + func testEveryProducerKindJournalsAKindedContinuityKey() { + let id = UUID() + let bare = ChatContinuityInvariants.proactiveNotificationContinuityKey(id: id) + + for kind in ProactiveNotificationKind.allCases where kind != .general { + let key = ChatContinuityInvariants.proactiveNotificationContinuityKey(id: id, kind: kind) + XCTAssertNotEqual(key, bare, "\(kind.rawValue) must not journal the bare historical key") + XCTAssertTrue(key.hasPrefix("notification:\(kind.rawValue):")) + XCTAssertEqual( + ChatContinuityInvariants.proactiveNotificationKind(Self.journaledRow(clientTurnId: key)), kind, + "the kind must survive the round trip that renders the badge") + } + } + + /// Historical rows carry the bare key. Decoding must keep working, and it must + /// keep resolving to `.general` — that arm is the only reason `.general` exists. + func testHistoricalBareKeysStillDecode() { + let id = UUID() + let bare = ChatContinuityInvariants.proactiveNotificationContinuityKey(id: id) + XCTAssertEqual(bare, "notification:\(id.uuidString)") + XCTAssertEqual( + ChatContinuityInvariants.proactiveNotificationKind(Self.journaledRow(clientTurnId: bare)), + .general) + XCTAssertEqual( + ChatContinuityInvariants.proactiveNotificationContinuityKey(id: id, kind: .general), bare, + "the bare form stays reachable for round-tripping history, never for minting") + } + + /// Trial messaging and onboarding permission help are cards, not observations. + /// They present and dismiss; they must never become chat rows. + func testProductCopyCardsAreExcludedFromJournaling() { + XCTAssertFalse(ProactiveNotificationKind.trial.isJournaled) + XCTAssertFalse(ProactiveNotificationKind.onboarding.isJournaled) + for kind in ProactiveNotificationKind.allCases where kind != .trial && kind != .onboarding { + XCTAssertTrue(kind.isJournaled, "\(kind.rawValue) is something Omi observed") + } + } + + /// The five category toggles gate the five proactive categories and nothing + /// else. Functional notices and the two never-journaled cards stay ungated. + func testCategoryTogglesGateOnlyTheFiveProactiveCategories() { + func allows(_ kind: ProactiveNotificationKind) -> Bool { + NotificationService.categoryToggleAllows( + kind: kind, + focusEnabled: false, + taskEnabled: false, + insightEnabled: false, + memoryEnabled: false, + integrationEnabled: false, + meetingSummaryEnabled: false) + } + for gated: ProactiveNotificationKind in [ + .suggestion, .task, .meetingNotes, .insight, .resurface, .goal, .memory, .integration, + ] { + XCTAssertFalse(allows(gated), "\(gated.rawValue) must honour its category toggle") + } + for ungated: ProactiveNotificationKind in [.general, .functional, .trial, .onboarding] { + XCTAssertTrue(allows(ungated), "\(ungated.rawValue) sits outside the five-category taxonomy") + } + } + + /// Every kind still presents as one of the five user-facing badges (or the two + /// neutral ones), so a new kind cannot reach the transcript unlabelled. + func testEveryKindHasABadge() { + for kind in ProactiveNotificationKind.allCases { + let badge = ProactiveNotificationBadge(kind: kind) + XCTAssertFalse(badge.label.isEmpty, kind.rawValue) + XCTAssertFalse(badge.systemImage.isEmpty, kind.rawValue) + } + } +} diff --git a/desktop/macos/Desktop/Tests/QueryShellTests.swift b/desktop/macos/Desktop/Tests/QueryShellTests.swift index 01a006c0fdf..5e1bff2a5e1 100644 --- a/desktop/macos/Desktop/Tests/QueryShellTests.swift +++ b/desktop/macos/Desktop/Tests/QueryShellTests.swift @@ -8,35 +8,6 @@ import XCTest @MainActor final class QueryShellTests: XCTestCase { - func testHomeDesignSwitchReachesAllThreeHomePresentations() { - XCTAssertEqual( - HomeDesignPresentation.resolve( - useLegacyHomeDesign: false, - useOldestHomeDesign: false, - forceModernPresentation: false), - .queryShell) - XCTAssertEqual( - HomeDesignPresentation.resolve( - useLegacyHomeDesign: true, - useOldestHomeDesign: false, - forceModernPresentation: false), - .redesignedHub) - XCTAssertEqual( - HomeDesignPresentation.resolve( - useLegacyHomeDesign: true, - useOldestHomeDesign: true, - forceModernPresentation: false), - .oldestLegacy) - XCTAssertEqual( - HomeDesignPresentation.resolve( - useLegacyHomeDesign: true, - useOldestHomeDesign: true, - forceModernPresentation: true), - .queryShell) - } - - // MARK: - The one key - /// **`⏎` sends. There is nothing else for it to mean.** /// /// The surface used to answer this question with "it depends": `⏎` searched and `⌘⏎` asked, which diff --git a/desktop/macos/changelog/unreleased/20260902-one-chat-shell.json b/desktop/macos/changelog/unreleased/20260902-one-chat-shell.json new file mode 100644 index 00000000000..4ebc500135a --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260902-one-chat-shell.json @@ -0,0 +1,3 @@ +{ + "change": "Every account now gets the same chat window, and every card in a reply — tasks, goals, conversations, memories, captures, questions — is something you can act on wherever you read it" +} diff --git a/desktop/macos/e2e/CORE_E2E.md b/desktop/macos/e2e/CORE_E2E.md index 6eef2c7b757..d4583f4db14 100644 --- a/desktop/macos/e2e/CORE_E2E.md +++ b/desktop/macos/e2e/CORE_E2E.md @@ -69,7 +69,6 @@ Local full T0 (includes backend preflight + pytest desktop contracts): | Rewind artifact persistence / recovery / privacy admission | T2 | | ChatProvider / agent runtime | T0 + T3 | | Sidebar / navigation | T1 | -| Home stage (hub/chat/connect), chat-first shell | T2 (`home-stage.yaml`, cohort bundle) | | Spatial overlay | T1 (`spatial-overlay-harness.sh`) | | Memories / tasks CRUD surfaces | T2 | | Secondary surfaces (detail, vocabulary, goals, billing, privacy mutations) | T2 + Live P2 for manual-only | @@ -93,7 +92,6 @@ Local T2 and fault suites remain available as engineering QA tools. They do not | `tasks` | v2 | typed bridge | 2 | Navigate + snapshot | | `settings-basic` | v2 | typed bridge | 2 | Settings sections + Advanced snapshot | | `dashboard` | v2 | typed bridge | 2 | Dashboard load + conversation list snapshot | -| `home-stage` | v2 | typed bridge | 2 | Home hub/chat/connect via `homeMode` assertions — chat-first bundle only (that shell mounts the stage) | | `chat-fault-5xx` | v2 | typed bridge | fault | Backend 5xx via `omi-fault-inject` (`--fault-suite`) | | `language` | v2 | typed bridge | 2 | Transcription language set + snapshot | | `tasks-crud` | v2 | typed bridge | 2 | Task create/toggle/delete via bridge | diff --git a/desktop/macos/e2e/feature-vector.md b/desktop/macos/e2e/feature-vector.md index db1dee50573..2abab145e67 100644 --- a/desktop/macos/e2e/feature-vector.md +++ b/desktop/macos/e2e/feature-vector.md @@ -37,10 +37,10 @@ Prioritized feature map to guide desktop E2E coverage. Uses the same two-dimensi | # | Feature | Layer | Priority | Bridge | Walker | Coverage Status | |---|---------|-------|----------|--------|--------|-----------------| -| 1 | Dashboard — conversations list, refresh | intelligence (3) | 9 | 2 | 2 | ✅ flow: `dashboard.yaml` (nav + `conversation_list_snapshot`) | +| 1 | Dashboard — conversations list, refresh | intelligence (3) | 9 | 2 | 2 | ✅ flow: `home.yaml` (nav + `conversation_list_snapshot`) | | 2 | Chat — send message, AI response | intelligence (3) | 9 | 2 | 2 | ✅ flow: `chat-hermetic.yaml` | | 3 | Sidebar navigation — all sections | retrieval-action (3) | 9 | 2 | 3 | ✅ flow: `navigation.yaml` | -| 4 | Home stage (hub / chat / connect) | intelligence (3) | 9 | 2 | 2 | ✅ flow: `home-stage.yaml` (chat-first bundle) | +| 4 | Home stage (hub / chat / connect) | intelligence (3) | 9 | 2 | 2 | ⛔️ retired: `DashboardPage` was the only view that rendered the stage and it is deleted (#12598); Home is `QueryShellHome` and reports no stage | | 5 | Capture lifecycle (hermetic transcript seam) | capture (5) | 15 | 2 | 1 | ✅ flow: `capture-lifecycle.yaml` | | 6 | Screen capture (Rewind) | capture (5) | 15 | 0 | 2 | ⚠️ manual: `rewind.yaml`, `screen-recording-permission.yaml` (TCC) | | 7 | Audio recording (desktop mic) | capture (5) | 15 | 0 | 1 | ⚠️ manual: `audio-recording.yaml` (mic permission); added `recording-finalization.yaml` | diff --git a/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml b/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml index c83ff5f7059..a31391984bc 100644 --- a/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml +++ b/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml @@ -1,7 +1,7 @@ version: 2 name: chat-first-capability-isolation tier: manual -description: "Manual local/offline capability-off assertion. Run it once for each isolated fixture case in the two-launch matrix below." +description: "Manual local/offline capability-off assertion: the one shell still mounts and its content blocks still render, non-actionable. Run it once for each isolated fixture case in the two-launch matrix below." app: non-prod # A harness invocation owns one automation port, hence one named app. It cannot # switch apps mid-run; prepare, launch, and run this *single-case* assertion @@ -31,11 +31,13 @@ app: non-prod covers: # Each case below drives this root's real sampled-control failure/disabled path. - desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift - - desktop/macos/Desktop/Sources/MainWindow/DesktopShellPresentationPolicy.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift - desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift - desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift - # S1 traverses the legacy half of the exact-route visibility wait; the - # enabled-control flow covers the Chat-first half without fabricating state. + # S1 traverses the exact-route visibility wait on a capability-off bundle: the + # shell mounts identically, so the wait must succeed on the same shellVariant. - desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift preconditions: - automation_bridge_ready @@ -45,15 +47,21 @@ preconditions: - its_fresh_named_non_production_bundle_launched_on_the_matching_port steps: + # Capability-off is no longer a different shell. It is the same shell with the + # kernel features dormant: cards still render, task check-off still works + # (it binds TasksStore, not the projection), links still navigate, and only the + # question card's options go dim. The old assertion here — `shellVariant: legacy` + # — described a shell that no longer exists. - id: S1 - name: Navigate the prepared capability-off bundle to its legacy chat surface (Home) + name: Navigate the prepared capability-off bundle to the one chat surface bridge.navigate: target: chat activateApp: false wait: - state.shellVariant: legacy + state.shellVariant: chat_first - id: S2 - name: Assert the sampled server capability remained off and the legacy Chat shell stayed usable + name: Assert the sampled server capability stayed off while the one shell stayed mounted and usable state.expect: - state.shellVariant: legacy + state.shellVariant: chat_first + state.visibleChatFirstRoute: chat diff --git a/desktop/macos/e2e/flows/chat-first-cohesive.yaml b/desktop/macos/e2e/flows/chat-first-cohesive.yaml index c5f438bdc7b..73b48c856c1 100644 --- a/desktop/macos/e2e/flows/chat-first-cohesive.yaml +++ b/desktop/macos/e2e/flows/chat-first-cohesive.yaml @@ -28,6 +28,8 @@ covers: # save_knowledge_graph discovery_text resolves through backend KG extract SSOT. - desktop/macos/Desktop/Sources/Services/KnowledgeGraphToolSupport.swift - desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift + # The one renderer every Chat surface hands a grouped rich block to. + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockGroupView.swift - desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift # S14-S22 prove a capture is staged as a typed, removable composer reference, # persists on the accepted user turn after the composer clears, and reopens diff --git a/desktop/macos/e2e/flows/dashboard.snapshot.json b/desktop/macos/e2e/flows/dashboard.snapshot.json deleted file mode 100644 index 134f1391791..00000000000 --- a/desktop/macos/e2e/flows/dashboard.snapshot.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "version": 1, - "flow": "dashboard", - "flowHash": "16f607bc98c082c2", - "device": { - "model": "unknown", - "resolution": "unknown" - }, - "createdAt": "2026-03-20T06:57:42.156Z", - "runId": "_iDoTIw", - "totalDurationMs": 498000, - "verifySteps": [ - "S1", - "S2", - "S3", - "S4", - "S5", - "S6" - ], - "steps": { - "S1": { - "kind": "action", - "waitAfterMs": 200, - "durationMs": 68000 - }, - "S2": { - "kind": "action", - "waitAfterMs": 200, - "durationMs": 187000 - }, - "S3": { - "kind": "verify", - "waitAfterMs": 200, - "durationMs": 78000 - }, - "S4": { - "kind": "verify", - "waitAfterMs": 200, - "durationMs": 17000 - }, - "S5": { - "kind": "action", - "waitAfterMs": 200, - "durationMs": 46000 - }, - "S6": { - "kind": "verify", - "waitAfterMs": 500, - "durationMs": 102000 - } - } -} \ No newline at end of file diff --git a/desktop/macos/e2e/flows/dashboard.yaml b/desktop/macos/e2e/flows/dashboard.yaml deleted file mode 100644 index d9dfcb7bae4..00000000000 --- a/desktop/macos/e2e/flows/dashboard.yaml +++ /dev/null @@ -1,58 +0,0 @@ -version: 2 -name: dashboard -tier: 2 -description: Dashboard load and refresh via automation bridge -app: non-prod -covers: - - desktop/macos/Desktop/Sources/Theme/InkGlassHitRegions.swift - - desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift - - desktop/macos/Desktop/Sources/AppState/AppState+DataLoading.swift - - desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshService.swift - - desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardIntelligenceStore.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/WhatMattersNowSection.swift - - desktop/macos/Desktop/Sources/MainWindow/Components/ConversationListView.swift - - desktop/macos/Desktop/Sources/MainWindow/Components/UserFacingErrorPresentation.swift - - desktop/macos/Desktop/Sources/MainWindow/Components/DailyScoreWidget.swift - - desktop/macos/Desktop/Sources/MainWindow/Components/RecentConversationsWidget.swift - - desktop/macos/Desktop/Sources/MainWindow/Components/TodaysTasksWidget.swift - - desktop/macos/Desktop/Sources/WhatsNewToast.swift - - desktop/macos/Desktop/Sources/Theme/Ink.swift - - desktop/macos/Desktop/Sources/Theme/InkGlass.swift - - desktop/macos/Desktop/Sources/Theme/InkType.swift - - desktop/macos/Desktop/Sources/Theme/WindowGlass.swift - - desktop/macos/Desktop/Sources/Theme/OmiChrome.swift - - desktop/macos/Desktop/Sources/Theme/OmiColors.swift - - desktop/macos/Desktop/Sources/Theme/OmiToggleStyle.swift - - desktop/macos/Desktop/Sources/MainWindow/GlassShellChrome.swift - - desktop/macos/Desktop/Sources/MainWindow/Components/GlassContentChrome.swift - - desktop/macos/Desktop/Sources/MainWindow/Pages/PersonaPage.swift -preconditions: - - automation_bridge_ready - -steps: - - id: S1 - name: Navigate to Dashboard - bridge.navigate: - target: dashboard - activateApp: false - wait: - state.selectedTabIndex: 0 - - - id: S2 - name: Refresh dashboard data - bridge.action: - name: refresh_all_data - - - id: S3 - name: Conversation list snapshot - bridge.action: - name: conversation_list_snapshot - expect: - ok: true - - - id: S4 - name: Logs clean - log.expect: - absent: - - "DesktopAutomationBridge: failed" diff --git a/desktop/macos/e2e/flows/floating-bar-functional.yaml b/desktop/macos/e2e/flows/floating-bar-functional.yaml index f646aa80d5d..8ea3f96b1e5 100644 --- a/desktop/macos/e2e/flows/floating-bar-functional.yaml +++ b/desktop/macos/e2e/flows/floating-bar-functional.yaml @@ -10,6 +10,8 @@ covers: - desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarNotificationCardLead.swift - desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarNotificationJournalCopy.swift - desktop/macos/Desktop/Sources/MainWindow/ClickThroughView.swift + # Where the notch's own agent-exit routing decision lives. + - desktop/macos/Desktop/Sources/MainWindow/DesktopShellPresentationPolicy.swift - desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift - desktop/macos/Desktop/Sources/FloatingControlBar/CursorScreenTracker.swift - desktop/macos/Desktop/Sources/FloatingControlBar/DelayedActionScheduler.swift diff --git a/desktop/macos/e2e/flows/goals-dashboard.yaml b/desktop/macos/e2e/flows/goals-dashboard.yaml index 072583af3b3..955232b6083 100644 --- a/desktop/macos/e2e/flows/goals-dashboard.yaml +++ b/desktop/macos/e2e/flows/goals-dashboard.yaml @@ -5,7 +5,6 @@ description: Hermetic dashboard goals create + snapshot via bridge actions app: non-prod covers: - desktop/macos/Desktop/Sources/Providers/ChatToolExecutor+CanonicalGoals.swift - - desktop/macos/Desktop/Sources/MainWindow/Components/GoalsWidget.swift - desktop/macos/Desktop/Sources/MainWindow/Pages/GoalsHistoryPage.swift - desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/Goals/GoalGenerationService.swift - desktop/macos/Desktop/Sources/ProactiveAssistants/Core/AssistantProtocol.swift diff --git a/desktop/macos/e2e/flows/home-spine.yaml b/desktop/macos/e2e/flows/home-spine.yaml index de97b89b378..9dcadf0426d 100644 --- a/desktop/macos/e2e/flows/home-spine.yaml +++ b/desktop/macos/e2e/flows/home-spine.yaml @@ -20,6 +20,8 @@ covers: - desktop/macos/Desktop/Sources/MainWindow/ServerPaging.swift - desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryVisibilityGuardrails.swift - desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift + # Home's corner counts and goal rows read this view model. + - desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardViewModel.swift preconditions: - auth_ready diff --git a/desktop/macos/e2e/flows/home-stage.yaml b/desktop/macos/e2e/flows/home-stage.yaml deleted file mode 100644 index 5592fcec9bb..00000000000 --- a/desktop/macos/e2e/flows/home-stage.yaml +++ /dev/null @@ -1,119 +0,0 @@ -version: 2 -name: home-stage -tier: 2 -description: >- - The Home stage (hub / chat / connect) driven through the automation bridge. The stage is rendered - by DashboardPage, which the chat-first shell mounts on its Chat route — hence the cohort - precondition. The legacy shell's Home is the query surface and has no stage at all, so this flow - asserts the shell before it asserts a mode. -app: non-prod -covers: - - desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift - - desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift - - desktop/macos/Desktop/Sources/MainWindow/Pages/HomeAskBarControls.swift - - desktop/macos/Desktop/Sources/MainWindow/Pages/HomePresentationTokens.swift - - desktop/macos/Desktop/Sources/MainWindow/Pages/HomeStagePresentation.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeSuggestionsStore.swift - - desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsComposer.swift - - desktop/macos/Desktop/Sources/MainWindow/MainChatNavigationRequest.swift - - desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift - # The five home_* actions every step below drives, lifted out of the registry. - - desktop/macos/Desktop/Sources/Automation/DesktopAutomationHomeStageActions.swift - # S1 is the guard: these decide whether a stage exists at all, and therefore whether - # `state.homeMode` is a reading or a fabrication. - - desktop/macos/Desktop/Sources/MainWindow/DesktopShellPresentationPolicy.swift - - desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift - - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift -preconditions: - - automation_bridge_ready - - auth_ready - # DashboardPage — and so the stage — is mounted only by the chat-first shell. Prepare the bundle - # the same way `chat-first.yaml` does; on a legacy bundle S1 fails immediately and says why. - - chat_first_cohort_named_bundle - -steps: - - id: S0 - name: Reset main chat for flow isolation - bridge.action: - name: reset_main_chat - expect: - result.detail.reset: "true" - - - id: S1 - name: Mount the shell that owns the stage - # Deliberately `chat`, not `dashboard`. `dashboard` resolves to the More/Dashboard route, where - # DashboardPage is handed an `onOpenPrimaryChat` and `home_open_chat` navigates away to the Chat - # route instead of opening the inline chat — S3 would then assert against a page it had left. - bridge.navigate: - target: chat - activateApp: false - wait: - state.shellVariant: chat_first - state.chatFirstRoute: chat - - - id: S2 - name: Collapse to the empty-history resting hub - bridge.action: - name: home_close_panel - wait: - state.homeMode: hub - - - id: S3 - name: Open inline chat - bridge.action: - name: home_open_chat - wait: - state.homeMode: chat - - - id: S4 - name: Home ask stub (no LLM wait) - bridge.action: - name: home_ask - params: - query: "[[MARKER:home-stage-stub]]" - expect: - result.detail.sent: "[[MARKER:home-stage-stub]]" - wait: - state.homeMode: chat - - - id: S5 - name: Toggle connect tray - bridge.action: - name: home_connect_toggle - wait: - state.homeMode: connect - - - id: S6 - name: Collapse back to loaded-history chat - bridge.action: - name: home_close_panel - wait: - state.homeMode: chat - - - id: S7 - name: Leaving the stage stops reporting a mode - # The field describes a mounted surface. Navigating away must clear it rather than leave the last - # mode standing — a stale `connect` here is the same class of lie as the `hub` the legacy shell - # used to report for a Home that had no stage. - bridge.navigate: - target: tasks - activateApp: false - wait: - state.chatFirstRoute: tasks - state.homeMode: { exists: false } - - - id: S8 - name: Connect toggle refuses once no stage is mounted - # S7 just proved there is no stage here, so this is the refusal path on a live bridge rather - # than a unit-test fixture: the action must report an error instead of answering "ok" and doing - # nothing. The wording it uses is asserted in HomeStageCloseSemanticsTests, not pinned here. - bridge.action: - name: home_connect_toggle - expect: - result.detail.error: { exists: true } - - - id: S9 - name: Logs clean - log.expect: - absent: - - "DesktopAutomationBridge: failed" diff --git a/desktop/macos/e2e/flows/home.yaml b/desktop/macos/e2e/flows/home.yaml index e473116a246..42444add7dc 100644 --- a/desktop/macos/e2e/flows/home.yaml +++ b/desktop/macos/e2e/flows/home.yaml @@ -1,7 +1,7 @@ version: 2 name: home tier: manual -description: Home tab — chat-first panel under the always-on search bar, plus its header controls and Capture/Listening status (v0.12.119+ redesign, replaces dashboard.yaml) +description: Home tab — chat-first panel under the always-on search bar, plus its header controls and Capture/Listening status (v0.12.119+ redesign; the only Home flow since dashboard.yaml was retired with DashboardPage) app: com.omi.computer-macos covers: - desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -10,6 +10,9 @@ covers: - desktop/macos/Desktop/Sources/MainWindow/DesktopWindowLayoutPolicy.swift - desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift - desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellModel.swift + # The four home_* bridge actions this surface observes. (The fifth, + # home_connect_toggle, refuses here: nothing renders a Connect tray any more.) + - desktop/macos/Desktop/Sources/Automation/DesktopAutomationHomeStageActions.swift - desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryHeroBar.swift - desktop/macos/Desktop/Sources/MainWindow/QueryShell/QuerySearchBar.swift - desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryResultsPanel.swift diff --git a/desktop/macos/e2e/flows/navigation.yaml b/desktop/macos/e2e/flows/navigation.yaml index c15c5d68f8f..06ccb0a9b70 100644 --- a/desktop/macos/e2e/flows/navigation.yaml +++ b/desktop/macos/e2e/flows/navigation.yaml @@ -7,8 +7,6 @@ covers: - desktop/macos/Desktop/Sources/MainWindow/ShellClickThrough.swift - desktop/macos/Desktop/Sources/OmiApp.swift - desktop/macos/Desktop/Sources/Sound/OmiUISound.swift - - desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift - - desktop/macos/Desktop/Sources/MainWindow/LegacySidebarSurface.swift - desktop/macos/Desktop/Sources/MainWindow/SidebarNavItem.swift - desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift - desktop/macos/Desktop/Sources/MainWindow/PageGlassLane.swift @@ -135,19 +133,17 @@ steps: - Home - id: S9 - name: Verify the legacy Home and Settings sidebars + name: Verify the Settings section list keeps its own ground do: >- - In Settings > Advanced, enable Use old Home design and return to Home. Verify the left - sidebar is a visibly frosted lane and shows Conversations as a separate destination from - Memories, then click Conversations. Open Settings from the gear, verify its left section list - keeps the same grounded lane instead of showing the desktop directly underneath it, and click - General followed by Account & Plan. Each click must change the destination inside Omi rather - than reaching the app behind its transparent top-level window. + Open Settings from the gear and verify its left section list is a grounded lane rather than + showing the desktop directly underneath it, then click General followed by Account & Plan. + Each click must change the destination inside Omi rather than reaching the app behind its + transparent top-level window. (The "Use old Home design" preference and the frosted legacy + navigation sidebar this step used to enable were deleted with the second shell, #12598.) expect: text_visible: - Settings - Account & Plan - - Conversations - id: S10 name: Verify the window has no chrome and is still closable, minimisable and movable diff --git a/desktop/macos/scripts/check-single-chat-shell.py b/desktop/macos/scripts/check-single-chat-shell.py new file mode 100755 index 00000000000..8fa0b8d5eb6 --- /dev/null +++ b/desktop/macos/scripts/check-single-chat-shell.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Keep the desktop main window on exactly one chat shell, with no inert blocks. + +The app used to mount one of two shells behind a server sample and a local +preference, and six of the journal's content-block kinds rendered as controls on +one of them and as nothing at all on the other. Both halves of that are gone +(#12598), and both are the kind of thing that grows back one symbol at a time — +a "just for now" preference, an `Optional` context, one `== nil` fork — without +any single diff looking like a second shell. + +This is a **static tripwire**, not behavioral coverage. `OneChatShellRichBlockTests` +proves what the blocks actually do; this only proves the vocabulary that made a +second shell expressible has not come back. + +Every banned symbol below is checked against production sources only +(`Desktop/Sources`). Tests may name a symbol in a string to assert its absence. +Comments and string literals are blanked before matching, so prose that names a +deleted type — including this file's own remedies quoted in a Swift comment — is +not a violation. + +Exit codes: 0 clean, 1 violations found, 2 usage/IO error. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +DESKTOP_DIR = SCRIPT_DIR.parent +DEFAULT_SOURCES_DIR = DESKTOP_DIR / "Desktop" / "Sources" + +# (pattern, why it is banned and what to do instead, scan strings too) +# +# `scan_strings` is on for the two `@AppStorage` keys, whose only spelling in +# production *is* a string literal — masking strings would make them unfindable. +# Everything else is a Swift identifier, so a string that mentions it is prose or +# a test fixture, not a use. +BANNED: list[tuple[str, str, bool]] = [ + ( + r"\bChatFirstShellCapabilitySample\b", + "the capability sample no longer selects a shell; use ChatFirstCapabilitySample, " + "which only gates kernel features", + False, + ), + ( + r"\bChatFirstShellVariant\b", + "there is one shell, so there is no variant to resolve; the automation snapshot pins " + "DesktopAutomationSnapshot.singleShellVariant", + False, + ), + ( + r"\buseLegacyHomeDesign\b", + "the legacy Home shell and its preference are deleted; every account gets ChatFirstShell", + True, + ), + ( + r"\buseOldestHomeDesign\b", + "the widgets-and-chat Home is deleted; every account gets ChatFirstShell", + True, + ), + ( + r"\busesLegacyPresentation\b", + "QueryShellHome has one presentation; the DashboardPage fork is deleted", + False, + ), + ( + r"\brichBlockRenderingEnabled\b", + "every Chat surface renders every content block; the flag that made six of them " + "EmptyView on some surfaces is deleted", + False, + ), + ( + r"chatFirstRichBlockContext\s*==\s*nil", + "the content-block context is non-optional on every host; a nil fork means a surface " + "that silently drops cards", + False, + ), + ( + r"chatFirstRichBlockContext\s*!=\s*nil", + "the content-block context is non-optional on every host; a nil fork means a surface " + "that silently drops cards", + False, + ), + ( + r"chatFirstRichBlockContext:\s*ChatFirstRichBlockContext\?", + "declare it as a non-optional `ChatFirstRichBlockContext`; auxiliary surfaces build one " + "with `ChatFirstRichBlockContext.auxiliary(chatProvider:)`", + False, + ), + ( + r"\bDashboardPage\s*\(", + "DashboardPage and its inline chat are deleted; the one chat destination is QueryShellHome", + False, + ), +] + +COMPILED = [(re.compile(pattern), remedy, scan_strings) for pattern, remedy, scan_strings in BANNED] + + +def mask_comments_and_strings(text: str, *, keep_strings: bool = False) -> str: + """Blank Swift comments and string literals, preserving offsets and newlines. + + A banned name written in prose or in a test fixture string is not a + reintroduction of the thing. Blanking rather than deleting keeps every line + number identical to the original file. + """ + out = list(text) + length = len(text) + index = 0 + + def blank(start: int, end: int) -> None: + for position in range(start, min(end, length)): + if out[position] != "\n": + out[position] = " " + + while index < length: + char = text[index] + + if char == "/" and text.startswith("//", index): + end = text.find("\n", index) + end = length if end == -1 else end + blank(index, end) + index = end + continue + + if char == "/" and text.startswith("/*", index): + depth = 1 + cursor = index + 2 + while cursor < length and depth: + if text.startswith("/*", cursor): + depth += 1 + cursor += 2 + elif text.startswith("*/", cursor): + depth -= 1 + cursor += 2 + else: + cursor += 1 + blank(index, cursor) + index = cursor + continue + + if char in '#"' and not keep_strings: + hashes = 0 + cursor = index + while cursor < length and text[cursor] == "#": + hashes += 1 + cursor += 1 + if cursor >= length or text[cursor] != '"': + index = cursor + 1 if hashes else index + 1 + continue + + pound = "#" * hashes + multiline = text.startswith('"""', cursor) + terminator = ('"""' + pound) if multiline else ('"' + pound) + cursor += 3 if multiline else 1 + + while cursor < length: + if hashes == 0 and text[cursor] == "\\": + cursor += 2 + continue + if hashes and text.startswith("\\" + pound, cursor): + cursor += 1 + hashes + 1 + continue + if text.startswith(terminator, cursor): + cursor += len(terminator) + break + if not multiline and text[cursor] == "\n": + break + cursor += 1 + + blank(index, cursor) + index = cursor + continue + + index += 1 + + return "".join(out) + + +def check_source(source: str, *, path_label: str) -> list[str]: + masked = mask_comments_and_strings(source) + comments_only = mask_comments_and_strings(source, keep_strings=True) + lines = source.splitlines() + errors: list[str] = [] + for pattern, remedy, scan_strings in COMPILED: + haystack = comments_only if scan_strings else masked + for match in pattern.finditer(haystack): + lineno = haystack.count("\n", 0, match.start()) + 1 + text = lines[lineno - 1].strip() if lineno <= len(lines) else "" + errors.append(f"{path_label}:{lineno}: {text}\n {remedy}") + return errors + + +def find_violations(sources_dir: Path) -> list[str]: + errors: list[str] = [] + for path in sorted(sources_dir.rglob("*.swift")): + try: + source = path.read_text(encoding="utf-8") + except OSError as exc: # pragma: no cover - unreadable file is a real failure + print(f"check-single-chat-shell: cannot read {path}: {exc}", file=sys.stderr) + raise SystemExit(2) from exc + try: + label = str(path.relative_to(Path.cwd())) + except ValueError: + label = str(path) + errors.extend(check_source(source, path_label=label)) + return errors + + +def run_self_test() -> None: + """Prove both directions on in-memory fixtures, without touching the tree.""" + clean = ( + "import SwiftUI\n\n" + "struct ChatBubble: View {\n" + " let chatFirstRichBlockContext: ChatFirstRichBlockContext\n" + "}\n" + '// A comment may name useLegacyHomeDesign and DashboardPage( without failing.\n' + 'let hint = "richBlockRenderingEnabled"\n' + ) + clean_errors = check_source(clean, path_label="fixture-clean.swift") + if clean_errors: + raise SystemExit(f"self-test false positive on clean fixture: {clean_errors}") + + for fixture, needle in ( + ('@AppStorage("useLegacyHomeDesign") private var flag = false\n', "useLegacyHomeDesign"), + ("var context: ChatFirstRichBlockContext? = nil\nlet x = chatFirstRichBlockContext == nil\n", + "chatFirstRichBlockContext"), + ("let g = group(blocks, richBlockRenderingEnabled: true)\n", "richBlockRenderingEnabled"), + ("var sample = ChatFirstShellCapabilitySample()\n", "ChatFirstShellCapabilitySample"), + ("body = DashboardPage(viewModel: viewModel)\n", "DashboardPage"), + ): + errors = check_source(fixture, path_label="fixture-fail.swift") + if not any(needle in error for error in errors): + raise SystemExit(f"self-test missed {needle!r} fail mode; errors={errors!r}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--sources-dir", + type=Path, + default=DEFAULT_SOURCES_DIR, + help="root of the Swift sources to scan (default: desktop/macos/Desktop/Sources)", + ) + parser.add_argument( + "--self-test", + action="store_true", + help="Run the in-memory fixtures for this checker, then exit.", + ) + args = parser.parse_args(argv) + + if args.self_test: + run_self_test() + print("OK: single-chat-shell checker self-test passed.") + return 0 + + sources_dir: Path = args.sources_dir + if not sources_dir.is_dir(): + print(f"check-single-chat-shell: sources dir not found: {sources_dir}", file=sys.stderr) + return 2 + + errors = find_violations(sources_dir) + if not errors: + print("ok: one chat shell, and every content block renders on every surface") + return 0 + + print("FAIL: a second chat shell (or an inert content block) is growing back") + for error in errors: + print(f"- {error}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/desktop/macos/scripts/check_desktop_test_quality.py b/desktop/macos/scripts/check_desktop_test_quality.py index 821f6644ab9..57a0c53e3bd 100755 --- a/desktop/macos/scripts/check_desktop_test_quality.py +++ b/desktop/macos/scripts/check_desktop_test_quality.py @@ -45,7 +45,7 @@ # Pinned debt ceilings. These may only decrease. Escaped sites are not counted. # Run with --print after improving tests, then lower both relevant values. SOURCE_INSPECTION_FILE_BASELINE = 54 -SOURCE_INSPECTION_SITE_BASELINE = 147 +SOURCE_INSPECTION_SITE_BASELINE = 146 WALL_CLOCK_WAIT_BASELINE = 16 MIN_REASON_LENGTH = 12 diff --git a/desktop/macos/scripts/omi-ctl b/desktop/macos/scripts/omi-ctl index ec7600251db..85c4601130a 100755 --- a/desktop/macos/scripts/omi-ctl +++ b/desktop/macos/scripts/omi-ctl @@ -144,11 +144,12 @@ raise SystemExit(0 if ready else 1) done echo "omi-ctl: timed out waiting for a live signed-in owner-ready main state" >&2; exit 1 ;; screens) - # Every token here must resolve in DesktopHomeView.resolvedAutomationTarget or, for the - # chat-first-only ones, ChatFirstRoute.primaryAutomationDestination. `focus` and `insight` were - # listed after their pages were deleted, so `omi-ctl navigate insight` posted a target the app - # answered "ok" to and then did nothing with — the failure mode the bridge exists to avoid. - echo "dashboard|home conversations chat memories tasks goals(chat-first) rewind apps|integrations settings permissions" ;; + # Every token here must resolve in ChatFirstRoute.automationVisibilityDestination. `focus` and + # `insight` were listed after their pages were deleted, so `omi-ctl navigate insight` posted a + # target the app answered "ok" to and then did nothing with — the failure mode the bridge exists + # to avoid. `goals` lost its "(chat-first)" note when the second shell did: there is one shell, + # and `help` opens Settings > About, where getting help from a person lives. + echo "dashboard|home conversations chat memories tasks goals rewind apps|integrations settings permissions help" ;; *) cat < Date: Wed, 2 Sep 2026 03:26:20 -0400 Subject: [PATCH 05/29] fix(desktop): the notch and the main window share one navigation owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ChatFirstRichBlockContext.auxiliary` binds `ChatFirstShellNavigation.shared` so a card tapped in the notch or the task panel routes the shell. The root was still creating its own instance, so those taps would have moved a navigation object nothing rendered — the card would appear to do nothing. Co-Authored-By: Claude Fable 5.1 --- .../macos/Desktop/Sources/MainWindow/DesktopHomeView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index fb9d7f67be4..200f191af36 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -37,7 +37,10 @@ struct DesktopHomeView: View { @StateObject private var viewModelContainer = ViewModelContainer() /// The Chat-first shell owns typed navigation at the root, never through legacy /// sidebar indices. It persists only route/collapse state, not enrollment. - @StateObject private var chatFirstNavigation = ChatFirstShellNavigation() + /// The one navigation owner, shared with the auxiliary Chat surfaces (task + /// panel, floating/notch) so a content block tapped there routes *this* shell + /// rather than a second instance nothing renders. + @ObservedObject private var chatFirstNavigation = ChatFirstShellNavigation.shared @ObservedObject private var authState = AuthState.shared @ObservedObject private var apiKeyService = APIKeyService.shared @ObservedObject private var updatePolicyManager = DesktopUpdatePolicyManager.shared From f2603c5110d3f17e19a65dc76c5894515a24b942 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 03:35:31 -0400 Subject: [PATCH 06/29] refactor(desktop): delete the intelligence store nothing renders any more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DashboardIntelligenceStore` fetched recommendations, projected them, and kept a feedback outbox for a section that only `DashboardPage` mounted. With the page gone its 750 lines had no renderer and no caller — the grep is exact: the class was referenced only by its own file and its own tests. Its 1,117-line test file went with it, because a test for a store nothing mounts is coverage of nothing. What stays is the part other surfaces still use: `TaskNavigationRequestStore`, the exact-record handoff `QueryShellHome` and the chat-first task card give the Tasks page instead of a tab index. The file is named for it now. Three source-reading tests still pointed at `DashboardPage.swift` and failed on the missing file rather than on anything real; they move onto `QueryAnswerThread` / `QueryShellHome`, which is where Home's error card and its colour tokens actually live. Co-Authored-By: Claude Fable 5.1 --- .../Desktop/Sources/ConcurrencySendable.swift | 2 - .../DashboardIntelligenceStore.swift | 904 ------------- .../TaskNavigationRequestStore.swift | 66 + .../Desktop/Tests/ChatErrorStateTests.swift | 33 +- .../DashboardIntelligenceStoreTests.swift | 1117 ----------------- .../Desktop/Tests/GlassLegibilityTests.swift | 7 +- desktop/macos/e2e/flows/tasks.yaml | 2 + .../scripts/check_desktop_test_quality.py | 4 +- 8 files changed, 88 insertions(+), 2047 deletions(-) delete mode 100644 desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardIntelligenceStore.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/Dashboard/TaskNavigationRequestStore.swift delete mode 100644 desktop/macos/Desktop/Tests/DashboardIntelligenceStoreTests.swift diff --git a/desktop/macos/Desktop/Sources/ConcurrencySendable.swift b/desktop/macos/Desktop/Sources/ConcurrencySendable.swift index a46c6a1d117..429e95e8c84 100644 --- a/desktop/macos/Desktop/Sources/ConcurrencySendable.swift +++ b/desktop/macos/Desktop/Sources/ConcurrencySendable.swift @@ -39,8 +39,6 @@ extension OmiAPI.WorkstreamDetailProjection: @unchecked Sendable {} extension AssistantSettingsResponse: @unchecked Sendable {} extension OmiAPI.RecommendationSubjectKind: @unchecked Sendable {} extension OmiAPI.GoalStatus: @unchecked Sendable {} -extension DashboardRecommendation: @unchecked Sendable {} -extension DashboardRecommendationDestination: @unchecked Sendable {} extension OmiAPI.FeedbackSubjectKind: @unchecked Sendable {} extension OmiAPI.ArtifactDescriptorCreate: @unchecked Sendable {} extension OmiAPI.ContinuationCheckpointUpsert: @unchecked Sendable {} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardIntelligenceStore.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardIntelligenceStore.swift deleted file mode 100644 index 7c1ca2470b6..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/DashboardIntelligenceStore.swift +++ /dev/null @@ -1,904 +0,0 @@ -import Foundation - -protocol DashboardIntelligenceClient: AnyObject, Sendable { - func getCandidateWorkflowControl() async throws -> OmiAPI.TaskWorkflowControl - func getWhatMattersNow(deviceID: String?) async throws -> OmiAPI.WhatMattersNowProjection - func getCanonicalGoals(includeEnded: Bool) async throws -> [OmiAPI.GoalResponse] - func getCanonicalGoalDetail(goalID: String) async throws -> OmiAPI.GoalDetailProjection - func getCanonicalCandidate(candidateID: String) async throws -> OmiAPI.CandidateRecord - func getActionItem(id: String) async throws -> TaskActionItem - func createCanonicalGoal( - title: String, desiredOutcome: String, whyItMatters: String?, successCriteria: [String], - accountGeneration: Int, idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse - func recordTaskFeedback( - _ request: OmiAPI.FeedbackCreate, idempotencyKey: String, accountGeneration: Int - ) async throws -> OmiAPI.FeedbackRecord - func createTaskOutcome( - _ request: OmiAPI.OutcomeCreate, idempotencyKey: String, accountGeneration: Int - ) async throws -> OmiAPI.OutcomeRecord - func focusCanonicalGoal( - goalID: String, replacementGoalID: String?, focusRank: Int?, accountGeneration: Int, - idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse - func unfocusCanonicalGoal( - goalID: String, accountGeneration: Int, idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse - func transitionCanonicalGoal( - goalID: String, status: OmiAPI.GoalStatus, relationshipDisposition: String, - accountGeneration: Int, idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse -} - -extension APIClient: @preconcurrency DashboardIntelligenceClient {} - -@MainActor -final class TaskNavigationRequestStore { - static let shared = TaskNavigationRequestStore() - enum Target: Equatable { - case task(String) - case candidate(String) - } - - private(set) var pendingTarget: Target? - private(set) var pendingTask: TaskActionItem? - private(set) var pendingCandidate: OmiAPI.CandidateRecord? - private var runtimeOwnerObserver: NSObjectProtocol? - - init() { - runtimeOwnerObserver = NotificationCenter.default.addObserver( - forName: .runtimeOwnerDidChange, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in self?.clear() } - } - } - - func request(task: TaskActionItem) { - pendingTarget = .task(task.id) - pendingTask = task - pendingCandidate = nil - } - - func request(candidate: OmiAPI.CandidateRecord) { - pendingTarget = .candidate(candidate.candidateId) - pendingTask = nil - pendingCandidate = candidate - } - - func peek() -> Target? { - pendingTarget - } - - func consumeIfAvailable(taskIDs: Set, candidateIDs: Set) -> Target? { - guard let target = pendingTarget else { return nil } - let isAvailable: Bool - switch target { - case .task(let id): isAvailable = taskIDs.contains(id) - case .candidate(let id): isAvailable = candidateIDs.contains(id) - } - guard isAvailable else { return nil } - clear() - return target - } - - private func clear() { - pendingTarget = nil - pendingTask = nil - pendingCandidate = nil - } -} - -enum DashboardRecommendationDestination: Equatable { - case suggested(candidateID: String) - case task(taskID: String, workstreamID: String?) - case thread(workstreamID: String, taskID: String?) - case unavailable -} - -struct DashboardRecommendation: Identifiable, Equatable { - let id: String - let interventionID: String - let outputVersion: String - let subjectKind: OmiAPI.RecommendationSubjectKind - let subjectID: String - let feedbackSubjectKind: OmiAPI.FeedbackSubjectKind - let feedbackSubjectID: String - let headline: String - let whyNow: String - let contextLabel: String? - let recommendedAction: String - let evidencePreview: String - let evidenceCount: Int - let dedupeKey: String - let expiresAt: String - let destination: DashboardRecommendationDestination -} - -struct PendingDashboardFeedback: Codable { - let request: OmiAPI.FeedbackCreate - let idempotencyKey: String - let accountGeneration: Int -} - -protocol DashboardFeedbackOutboxPersisting: AnyObject { - func currentOwnerID() -> String - func load(ownerID: String) -> [PendingDashboardFeedback] - func save(_ entries: [PendingDashboardFeedback], ownerID: String) -} - -final class DashboardFeedbackOutboxDefaults: DashboardFeedbackOutboxPersisting { - private let defaults: UserDefaults - private let fixedOwnerID: String? - - init(defaults: UserDefaults = .standard, ownerID: String? = nil) { - self.defaults = defaults - fixedOwnerID = ownerID - } - - func currentOwnerID() -> String { - fixedOwnerID ?? defaults.string(forKey: .authUserId) ?? "signed-out" - } - - private func key(ownerID: String) -> String { "whatMattersNowFeedbackOutbox.v1.\(ownerID)" } - - func load(ownerID: String) -> [PendingDashboardFeedback] { - guard let data = defaults.data(forKey: key(ownerID: ownerID)) else { return [] } - return (try? JSONDecoder().decode([PendingDashboardFeedback].self, from: data)) ?? [] - } - - func save(_ entries: [PendingDashboardFeedback], ownerID: String) { - defaults.set(try? JSONEncoder().encode(entries), forKey: key(ownerID: ownerID)) - } -} - -@MainActor -final class DashboardIntelligenceStore: ObservableObject { - private struct OwnerScope: Equatable { - let ownerID: String - let revision: UInt - } - - @Published private(set) var recommendations: [DashboardRecommendation] = [] - @Published private(set) var goals: [OmiAPI.GoalResponse] = [] - @Published private(set) var selectedGoalDetail: OmiAPI.GoalDetailProjection? - @Published private(set) var isLoading = false - @Published private(set) var accountGeneration: Int? - @Published private(set) var focusReplacementGoalID: String? - @Published var error: String? - - private let client: any DashboardIntelligenceClient - private let outboxStore: any DashboardFeedbackOutboxPersisting - private let now: () -> Date - private let deviceID: () -> String? - private let reportAttribution: (TaskIntelligenceAttributionEvent) -> Void - private var activeOwnerID: String - private var ownerRevision: UInt = 0 - private var activeLoadToken: UUID? - private var loadingOwnerID: String? - /// The in-flight same-owner load, so a concurrent `load()` (e.g. from - /// `openRecommendation`) can await the real fetch instead of returning a no-op - /// and then acting on a still-empty `recommendations`. - private var activeLoadTask: Task? - private var activeLoadTaskID: UUID? - private var pendingFeedback: [PendingDashboardFeedback] - private var presentedInterventionIDs = Set() - private var didRegisterAutomationActions = false - private var recommendationActionHandler: ((DashboardRecommendation) async -> Bool)? - - init( - client: any DashboardIntelligenceClient = APIClient.shared, - outboxStore: any DashboardFeedbackOutboxPersisting = DashboardFeedbackOutboxDefaults(), - now: @escaping () -> Date = Date.init, - deviceIDProvider: (() -> String?)? = nil, - reportAttribution: ((TaskIntelligenceAttributionEvent) -> Void)? = nil - ) { - self.client = client - self.outboxStore = outboxStore - self.now = now - self.deviceID = deviceIDProvider ?? { ClientDeviceService.shared.clientDeviceId } - self.reportAttribution = - reportAttribution ?? { AnalyticsManager.shared.taskIntelligenceAttribution($0) } - let ownerID = outboxStore.currentOwnerID() - activeOwnerID = ownerID - self.pendingFeedback = outboxStore.load(ownerID: ownerID) - } - - var focusedGoals: [OmiAPI.GoalResponse] { - goals.filter { $0.status == .focused } - .sorted { ($0.focusRank ?? Int.max, $0.updatedAt) < ($1.focusRank ?? Int.max, $1.updatedAt) } - } - - var currentGoals: [OmiAPI.GoalResponse] { - goals.filter { $0.status != .achieved && $0.status != .abandoned } - } - - var endedGoals: [OmiAPI.GoalResponse] { - goals.filter { $0.status == .achieved || $0.status == .abandoned } - } - - func load() async { - let ownerScope = captureOwnerScope() - if loadingOwnerID == ownerScope.ownerID { - // A same-owner load is already running. Await it rather than returning a - // no-op, so callers that depend on the fetched data see the populated - // result instead of a stale/empty one. - if let activeLoadTask { await activeLoadTask.value } - return - } - // Claim the dedup slot SYNCHRONOUSLY here — before spawning the task and - // before the first await. performLoad() runs inside the Task (asynchronously), - // so if we relied on it to set loadingOwnerID, a re-entrant same-owner load() - // could run on the MainActor first, still see nil, and start a second - // concurrent load (overwriting activeLoadTask) — defeating the dedup. - loadingOwnerID = ownerScope.ownerID - let taskID = UUID() - let task = Task { [weak self] in - guard let self else { return } - await self.performLoad(ownerScope: ownerScope) - } - activeLoadTask = task - activeLoadTaskID = taskID - await task.value - if activeLoadTaskID == taskID { - activeLoadTask = nil - activeLoadTaskID = nil - } - } - - private func performLoad(ownerScope: OwnerScope) async { - let loadToken = UUID() - activeLoadToken = loadToken - loadingOwnerID = ownerScope.ownerID - isLoading = true - defer { - if activeLoadToken == loadToken { - activeLoadToken = nil - loadingOwnerID = nil - isLoading = false - } - } - error = nil - - let control: OmiAPI.TaskWorkflowControl - do { - control = try await client.getCandidateWorkflowControl() - } catch { - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - accountGeneration = nil - recommendations = [] - goals = [] - self.error = UserFacingErrorPresentation.message(for: error, while: .dashboard) - logError("Dashboard: Failed to load workflow control", error: error) - return - } - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - - guard control.workflowMode == .read else { - accountGeneration = nil - recommendations = [] - goals = [] - return - } - accountGeneration = control.accountGeneration - pendingFeedback = outboxStore.load(ownerID: ownerScope.ownerID) - pendingFeedback.removeAll { $0.accountGeneration != control.accountGeneration } - outboxStore.save(pendingFeedback, ownerID: ownerScope.ownerID) - if AccountCutoverOfflineUploadAdmission.allowsUpload() { - await retryPendingFeedback(ownerScope: ownerScope, loadToken: loadToken) - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - } - do { - let projection = try await client.getWhatMattersNow(deviceID: deviceID()) - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - recommendations = projectForCurrentOwner(projection) - emitPresentedInterventions(recommendations) - } catch APIError.httpError(let statusCode, _) where statusCode == 404 { - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - // Users without the intelligence capability retain calm - // dashboard behavior while canonical Goals remain available. - recommendations = [] - } catch { - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - recommendations = [] - self.error = UserFacingErrorPresentation.message(for: error, while: .dashboard) - logError("Dashboard: What Matters Now projection unavailable", error: error) - } - do { - let loadedGoals = try await client.getCanonicalGoals(includeEnded: true) - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - goals = loadedGoals - } catch { - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - goals = [] - self.error = UserFacingErrorPresentation.message(for: error, while: .dashboard) - logError("Dashboard: Failed to load canonical goals", error: error) - return - } - if error == nil, !pendingFeedback.isEmpty { - error = "Saved feedback will retry automatically." - } - } - - /// Apply a context-triggered canonical projection without coupling dashboard - /// eligibility to notification settings or interruption policy. - func applyContextProjection(_ projection: OmiAPI.WhatMattersNowProjection) { - guard persistenceOwnerIsCurrent else { - refreshOwnerScopedState() - return - } - recommendations = projectForCurrentOwner(projection) - emitPresentedInterventions(recommendations) - error = nil - } - - @discardableResult - func openRecommendation(id: String) async -> Bool { - let ownerScope = captureOwnerScope() - if !recommendations.contains(where: { $0.id == id }) { - await load() - guard requireCurrentOwner(ownerScope) else { return false } - } - guard let recommendation = recommendations.first(where: { $0.id == id }), - let recommendationActionHandler - else { - guard requireCurrentOwner(ownerScope) else { return false } - error = "This review target is no longer available." - return false - } - let opened = await recommendationActionHandler(recommendation) - guard requireCurrentOwner(ownerScope) else { return false } - if opened { - await ContextSubjectBindingService.shared.bindRecentContext( - to: TaskContextSubject( - kind: recommendation.subjectKind, - id: recommendation.subjectID, - workstreamID: Self.destinationWorkstreamID(recommendation.destination) - )) - await recordPrimaryAction(recommendation) - guard requireCurrentOwner(ownerScope) else { return false } - } - return opened - } - - func loadGoalDetail(goalID: String) async { - let ownerScope = captureOwnerScope() - do { - let detail = try await client.getCanonicalGoalDetail(goalID: goalID) - guard requireCurrentOwner(ownerScope) else { return } - selectedGoalDetail = detail - error = nil - } catch { - guard requireCurrentOwner(ownerScope) else { return } - selectedGoalDetail = nil - self.error = "Goal details could not be loaded." - } - } - - func candidateForNavigation(candidateID: String) async -> OmiAPI.CandidateRecord? { - let ownerScope = captureOwnerScope() - do { - let candidate = try await client.getCanonicalCandidate(candidateID: candidateID) - guard requireCurrentOwner(ownerScope) else { return nil } - guard candidate.candidateId == candidateID, - SuggestedTasksStore.canPresentForNavigation(candidate) - else { - error = "This Suggested item is no longer available." - return nil - } - return candidate - } catch { - guard requireCurrentOwner(ownerScope) else { return nil } - self.error = "This Suggested item could not be opened." - return nil - } - } - - func taskForNavigation(taskID: String) async -> TaskActionItem? { - let ownerScope = captureOwnerScope() - do { - let task = try await client.getActionItem(id: taskID) - guard requireCurrentOwner(ownerScope) else { return nil } - // The detail response is the freshest word on retirement, and it projects - // it through canonical lifecycle status — a recommendation minted before - // the task was cancelled/superseded/deleted must not open it as live. - guard task.id == taskID, !task.isRetired else { - error = "This task is no longer available." - return nil - } - return task - } catch { - guard requireCurrentOwner(ownerScope) else { return nil } - self.error = "This task could not be opened." - return nil - } - } - - func clearGoalDetail() { - guard persistenceOwnerIsCurrent else { - refreshOwnerScopedState() - return - } - selectedGoalDetail = nil - } - - func createGoal( - title: String, - desiredOutcome: String, - whyItMatters: String?, - successCriteria: [String], - idempotencyKey: String - ) async -> Bool { - let ownerScope = captureOwnerScope() - guard let generation = accountGeneration else { return false } - do { - _ = try await client.createCanonicalGoal( - title: title, - desiredOutcome: desiredOutcome, - whyItMatters: whyItMatters, - successCriteria: successCriteria, - accountGeneration: generation, - idempotencyKey: idempotencyKey - ) - guard requireCurrentOwner(ownerScope) else { return false } - await load() - guard requireCurrentOwner(ownerScope) else { return false } - return true - } catch { - guard requireCurrentOwner(ownerScope) else { return false } - self.error = "Goal could not be created." - return false - } - } - - func recordPrimaryAction(_ recommendation: DashboardRecommendation) async { - let ownerScope = captureOwnerScope() - guard recommendations.contains(recommendation) else { return } - _ = await recordFeedback( - recommendation, - action: .do_now, - reason: nil, - laterUntil: nil, - idempotencyKey: "wmn:\(recommendation.interventionID):do-now", - ownerScope: ownerScope - ) - guard requireCurrentOwner(ownerScope) else { return } - recommendations.removeAll { $0.id == recommendation.id } - } - - func later(_ recommendation: DashboardRecommendation) async { - let ownerScope = captureOwnerScope() - guard recommendations.contains(recommendation) else { return } - let until = now().addingTimeInterval(24 * 60 * 60) - _ = await recordFeedback( - recommendation, - action: .later, - reason: nil, - laterUntil: Self.iso8601(until), - idempotencyKey: - "wmn:\(recommendation.interventionID):later:\(UUID().uuidString.lowercased())", - ownerScope: ownerScope - ) - guard requireCurrentOwner(ownerScope) else { return } - recommendations.removeAll { $0.id == recommendation.id } - } - - func dismiss( - _ recommendation: DashboardRecommendation, - reason: OmiAPI.TaskIntelligenceFeedbackReason? - ) async { - let ownerScope = captureOwnerScope() - guard recommendations.contains(recommendation) else { return } - _ = await recordFeedback( - recommendation, - action: .dismiss, - reason: reason, - laterUntil: nil, - idempotencyKey: "wmn:\(recommendation.interventionID):dismiss:\(reason?.rawValue ?? "none")", - ownerScope: ownerScope - ) - guard requireCurrentOwner(ownerScope) else { return } - recommendations.removeAll { $0.id == recommendation.id } - } - - func focus(goalID: String, replacing replacementGoalID: String?) async -> Bool { - let ownerScope = captureOwnerScope() - guard let generation = accountGeneration else { return false } - do { - _ = try await client.focusCanonicalGoal( - goalID: goalID, - replacementGoalID: replacementGoalID, - focusRank: nil, - accountGeneration: generation, - idempotencyKey: "goal-focus:\(goalID):\(UUID().uuidString.lowercased())" - ) - guard requireCurrentOwner(ownerScope) else { return false } - focusReplacementGoalID = nil - await load() - guard requireCurrentOwner(ownerScope) else { return false } - return true - } catch APIError.httpError(let statusCode, _) - where statusCode == 409 && replacementGoalID == nil - { - guard requireCurrentOwner(ownerScope) else { return false } - focusReplacementGoalID = goalID - self.error = "Choose a focused goal to replace." - return false - } catch { - guard requireCurrentOwner(ownerScope) else { return false } - focusReplacementGoalID = nil - self.error = "Goal focus could not be updated." - return false - } - } - - func unfocus(goalID: String) async { - let ownerScope = captureOwnerScope() - guard let generation = accountGeneration else { return } - do { - _ = try await client.unfocusCanonicalGoal( - goalID: goalID, - accountGeneration: generation, - idempotencyKey: "goal-unfocus:\(goalID):\(UUID().uuidString.lowercased())" - ) - guard requireCurrentOwner(ownerScope) else { return } - await load() - guard requireCurrentOwner(ownerScope) else { return } - } catch { - guard requireCurrentOwner(ownerScope) else { return } - self.error = "Goal focus could not be updated." - } - } - - func transition(goalID: String, status: OmiAPI.GoalStatus) async { - let ownerScope = captureOwnerScope() - guard let generation = accountGeneration else { return } - do { - _ = try await client.transitionCanonicalGoal( - goalID: goalID, - status: status, - relationshipDisposition: "retain", - accountGeneration: generation, - idempotencyKey: - "goal-lifecycle:\(goalID):\(status.rawValue):\(UUID().uuidString.lowercased())" - ) - guard requireCurrentOwner(ownerScope) else { return } - await load() - guard requireCurrentOwner(ownerScope) else { return } - } catch { - guard requireCurrentOwner(ownerScope) else { return } - self.error = "Goal lifecycle could not be updated." - } - } - - @discardableResult - private func recordFeedback( - _ recommendation: DashboardRecommendation, - action: OmiAPI.TaskIntelligenceFeedbackAction, - reason: OmiAPI.TaskIntelligenceFeedbackReason?, - laterUntil: String?, - idempotencyKey: String, - ownerScope: OwnerScope - ) async -> OmiAPI.FeedbackRecord? { - guard requireCurrentOwner(ownerScope), let generation = accountGeneration else { return nil } - let request = OmiAPI.FeedbackCreate( - action: action, - contextSnapshotHash: nil, - interventionId: recommendation.interventionID, - laterUntil: laterUntil, - reason: reason, - subjectId: recommendation.feedbackSubjectID, - subjectKind: recommendation.feedbackSubjectKind - ) - let entry = PendingDashboardFeedback( - request: request, - idempotencyKey: idempotencyKey, - accountGeneration: generation - ) - var ownerFeedback = outboxStore.load(ownerID: ownerScope.ownerID) - ownerFeedback.removeAll { $0.idempotencyKey == idempotencyKey } - ownerFeedback.append(entry) - outboxStore.save(ownerFeedback, ownerID: ownerScope.ownerID) - guard requireCurrentOwner(ownerScope) else { return nil } - pendingFeedback = ownerFeedback - do { - let feedback = try await client.recordTaskFeedback( - request, idempotencyKey: idempotencyKey, accountGeneration: generation) - guard requireCurrentOwner(ownerScope) else { return nil } - ownerFeedback = outboxStore.load(ownerID: ownerScope.ownerID) - ownerFeedback.removeAll { $0.idempotencyKey == idempotencyKey } - outboxStore.save(ownerFeedback, ownerID: ownerScope.ownerID) - pendingFeedback = ownerFeedback - error = nil - reportAttribution( - .feedbackRecorded( - interventionID: recommendation.interventionID, - surface: .whatMattersNow, - action: action.rawValue, - reason: reason?.rawValue, - subjectKind: recommendation.feedbackSubjectKind.rawValue, - subjectID: recommendation.feedbackSubjectID, - candidateID: recommendation.subjectKind == .candidate ? recommendation.subjectID : nil, - attributionChainID: feedback.attributionChainId - )) - return feedback - } catch { - guard requireCurrentOwner(ownerScope) else { return nil } - self.error = "Saved. Feedback will retry automatically." - return nil - } - } - - private func emitPresentedInterventions(_ recommendations: [DashboardRecommendation]) { - for recommendation in recommendations { - guard presentedInterventionIDs.insert(recommendation.interventionID).inserted else { - continue - } - reportAttribution( - .interventionPresented( - interventionID: recommendation.interventionID, - surface: .whatMattersNow, - subjectKind: recommendation.subjectKind.rawValue, - subjectID: recommendation.subjectID, - candidateID: recommendation.subjectKind == .candidate ? recommendation.subjectID : nil - )) - } - } - - private func retryPendingFeedback(ownerScope: OwnerScope, loadToken: UUID) async { - var succeeded = Set() - for entry in outboxStore.load(ownerID: ownerScope.ownerID) { - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - guard AccountCutoverOfflineUploadAdmission.allowsUpload() else { return } - do { - _ = try await client.recordTaskFeedback( - entry.request, idempotencyKey: entry.idempotencyKey, - accountGeneration: entry.accountGeneration) - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - succeeded.insert(entry.idempotencyKey) - } catch { - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - continue - } - } - guard loadScopeIsCurrent(ownerScope, token: loadToken) else { return } - let remaining = outboxStore.load(ownerID: ownerScope.ownerID).filter { - !succeeded.contains($0.idempotencyKey) - } - outboxStore.save(remaining, ownerID: ownerScope.ownerID) - pendingFeedback = remaining - } - - private var persistenceOwnerIsCurrent: Bool { - activeOwnerID == outboxStore.currentOwnerID() - } - - private func captureOwnerScope() -> OwnerScope { - refreshOwnerScopedState() - return OwnerScope(ownerID: activeOwnerID, revision: ownerRevision) - } - - @discardableResult - private func requireCurrentOwner(_ ownerScope: OwnerScope) -> Bool { - guard ownerScopeIsCurrent(ownerScope) else { - refreshOwnerScopedState() - return false - } - return true - } - - private func ownerScopeIsCurrent(_ ownerScope: OwnerScope) -> Bool { - ownerScope.ownerID == activeOwnerID - && ownerScope.revision == ownerRevision - && outboxStore.currentOwnerID() == ownerScope.ownerID - } - - private func loadScopeIsCurrent(_ ownerScope: OwnerScope, token: UUID) -> Bool { - guard activeLoadToken == token, ownerScopeIsCurrent(ownerScope) else { - refreshOwnerScopedState() - return false - } - return true - } - - @discardableResult - private func refreshOwnerScopedState() -> Bool { - let ownerID = outboxStore.currentOwnerID() - guard ownerID != activeOwnerID else { return false } - - activeOwnerID = ownerID - ownerRevision &+= 1 - activeLoadToken = nil - loadingOwnerID = nil - pendingFeedback = outboxStore.load(ownerID: ownerID) - recommendations = [] - goals = [] - selectedGoalDetail = nil - isLoading = false - accountGeneration = nil - focusReplacementGoalID = nil - presentedInterventionIDs = [] - error = nil - return true - } - - private func projectForCurrentOwner( - _ projection: OmiAPI.WhatMattersNowProjection - ) -> [DashboardRecommendation] { - Self.project(projection, now: now(), pendingFeedback: pendingFeedback) - } - - static func project( - _ projection: OmiAPI.WhatMattersNowProjection, - now: Date - ) -> [DashboardRecommendation] { - project(projection, now: now, pendingFeedback: []) - } - - private static func project( - _ projection: OmiAPI.WhatMattersNowProjection, - now: Date, - pendingFeedback: [PendingDashboardFeedback] - ) -> [DashboardRecommendation] { - guard let projectionExpiry = parseDate(projection.expiresAt), projectionExpiry > now else { - return [] - } - var seenDedupeKeys = Set() - let recommendations = projection.recommendations.compactMap { - item -> DashboardRecommendation? in - let suppressedByPendingFeedback = pendingFeedback.contains { entry in - guard entry.request.action == .later || entry.request.action == .dismiss else { - return false - } - let matchesIntervention = entry.request.interventionId == item.interventionId - let matchesSubject = - entry.request.subjectKind == item.feedbackSubjectKind - && entry.request.subjectId == item.feedbackSubjectId - return matchesIntervention || matchesSubject - } - guard !suppressedByPendingFeedback else { return nil } - guard let expiry = parseDate(item.expiresAt), expiry > now else { return nil } - guard seenDedupeKeys.insert(item.dedupeKey).inserted else { return nil } - let destination: DashboardRecommendationDestination - switch item.subjectKind { - case .candidate: - destination = .suggested(candidateID: item.subjectId) - case .task: - destination = .task( - taskID: item.destinationTaskId ?? item.subjectId, - workstreamID: item.destinationWorkstreamId - ) - case .workstream: - destination = .thread( - workstreamID: item.destinationWorkstreamId ?? item.subjectId, - taskID: item.destinationTaskId - ) - case .artifact, .decision, .agent_open_loop: - guard let workstreamID = item.destinationWorkstreamId else { return nil } - destination = .thread(workstreamID: workstreamID, taskID: item.destinationTaskId) - case ._unknown: - return nil - } - return DashboardRecommendation( - id: "\(item.outputVersion):\(item.dedupeKey)", - interventionID: item.interventionId, - outputVersion: item.outputVersion, - subjectKind: item.subjectKind, - subjectID: item.subjectId, - feedbackSubjectKind: item.feedbackSubjectKind, - feedbackSubjectID: item.feedbackSubjectId, - headline: item.headline, - whyNow: item.whyNow, - contextLabel: item.goalOrWorkstreamLabel, - recommendedAction: item.recommendedAction, - evidencePreview: item.evidencePreview, - evidenceCount: item.evidenceRefs.count, - dedupeKey: item.dedupeKey, - expiresAt: item.expiresAt, - destination: destination - ) - } - return Array(recommendations.prefix(3)) - } - - private static func parseDate(_ value: String) -> Date? { - let precise = ISO8601DateFormatter() - precise.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return precise.date(from: value) ?? ISO8601DateFormatter().date(from: value) - } - - private static func iso8601(_ value: Date) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.string(from: value) - } - - func registerAutomationActions() { - guard DesktopAutomationLaunchOptions.isEnabled, !didRegisterAutomationActions else { return } - didRegisterAutomationActions = true - DesktopAutomationActionRegistry.shared.register( - name: "refresh_what_matters_now", - summary: "Refresh canonical recommendations and goals", - params: [] - ) { [weak self] _ in - guard let self else { return ["error": "dashboard intelligence store deallocated"] } - await self.load() - return [ - "recommendations": String(self.recommendations.count), - "focused_goals": String(self.focusedGoals.count), - "output_ids": self.recommendations.map(\.id).joined(separator: ","), - "subjects": self.recommendations.map(Self.automationSummary).joined(separator: ","), - "error": self.error ?? "", - ] - } - DesktopAutomationActionRegistry.shared.register( - name: "open_what_matters_now", - summary: "Open one canonical recommendation by stable output id", - params: ["recommendation_id"] - ) { [weak self] params in - guard let self else { return ["error": "dashboard intelligence store deallocated"] } - guard let recommendationID = params["recommendation_id"], !recommendationID.isEmpty else { - return ["error": "recommendation_id is required"] - } - if !self.recommendations.contains(where: { $0.id == recommendationID }) { await self.load() } - let recommendation = self.recommendations.first(where: { $0.id == recommendationID }) - let opened = await self.openRecommendation(id: recommendationID) - return [ - "success": opened ? "true" : "false", - "subject_kind": recommendation?.subjectKind.rawValue ?? "", - "subject_id": recommendation?.subjectID ?? "", - "destination": recommendation.map { - Self.automationDestination($0.destination) - } ?? "", - "error": self.error ?? "", - ] - } - DesktopAutomationActionRegistry.shared.register( - name: "focus_goal", - summary: "Focus a canonical goal with optional explicit replacement", - params: ["goal_id", "replacement_goal_id"] - ) { [weak self] params in - guard let self else { return ["error": "dashboard intelligence store deallocated"] } - guard let goalID = params["goal_id"], !goalID.isEmpty else { - return ["error": "goal_id is required"] - } - let success = await self.focus(goalID: goalID, replacing: params["replacement_goal_id"]) - return ["success": success ? "true" : "false", "error": self.error ?? ""] - } - } - - func setRecommendationActionHandler(_ handler: ((DashboardRecommendation) async -> Bool)?) { - recommendationActionHandler = handler - } - - private static func automationSummary(_ recommendation: DashboardRecommendation) -> String { - [ - recommendation.id, - recommendation.subjectKind.rawValue, - recommendation.subjectID, - automationDestination(recommendation.destination), - ].joined(separator: "|") - } - - private static func automationDestination(_ destination: DashboardRecommendationDestination) - -> String - { - switch destination { - case .suggested(let candidateID): return "candidate:\(candidateID)" - case .task(let taskID, let workstreamID): return "task:\(taskID):\(workstreamID ?? "")" - case .thread(let workstreamID, let taskID): return "thread:\(workstreamID):\(taskID ?? "")" - case .unavailable: return "unavailable" - } - } - - private static func destinationWorkstreamID(_ destination: DashboardRecommendationDestination) - -> String? - { - switch destination { - case .task(_, let workstreamID): return workstreamID - case .thread(let workstreamID, _): return workstreamID - case .suggested, .unavailable: return nil - } - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/TaskNavigationRequestStore.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/TaskNavigationRequestStore.swift new file mode 100644 index 00000000000..ecb441c2e9f --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/TaskNavigationRequestStore.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Where the app remembers "open this exact task next". +/// +/// This file used to hold `DashboardIntelligenceStore` and the whole client +/// protocol it fetched through. Nothing rendered that store once `DashboardPage` +/// was deleted (#12598) — its recommendations had no surface — so it went with +/// the page. This handoff stayed: `QueryShellHome` and the chat-first task card +/// both hand the Tasks page an exact record rather than a tab index. +@MainActor +final class TaskNavigationRequestStore { + static let shared = TaskNavigationRequestStore() + enum Target: Equatable { + case task(String) + case candidate(String) + } + + private(set) var pendingTarget: Target? + private(set) var pendingTask: TaskActionItem? + private(set) var pendingCandidate: OmiAPI.CandidateRecord? + private var runtimeOwnerObserver: NSObjectProtocol? + + init() { + runtimeOwnerObserver = NotificationCenter.default.addObserver( + forName: .runtimeOwnerDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.clear() } + } + } + + func request(task: TaskActionItem) { + pendingTarget = .task(task.id) + pendingTask = task + pendingCandidate = nil + } + + func request(candidate: OmiAPI.CandidateRecord) { + pendingTarget = .candidate(candidate.candidateId) + pendingTask = nil + pendingCandidate = candidate + } + + func peek() -> Target? { + pendingTarget + } + + func consumeIfAvailable(taskIDs: Set, candidateIDs: Set) -> Target? { + guard let target = pendingTarget else { return nil } + let isAvailable: Bool + switch target { + case .task(let id): isAvailable = taskIDs.contains(id) + case .candidate(let id): isAvailable = candidateIDs.contains(id) + } + guard isAvailable else { return nil } + clear() + return target + } + + private func clear() { + pendingTarget = nil + pendingTask = nil + pendingCandidate = nil + } +} diff --git a/desktop/macos/Desktop/Tests/ChatErrorStateTests.swift b/desktop/macos/Desktop/Tests/ChatErrorStateTests.swift index f76d7b5a657..b4872f7afac 100644 --- a/desktop/macos/Desktop/Tests/ChatErrorStateTests.swift +++ b/desktop/macos/Desktop/Tests/ChatErrorStateTests.swift @@ -336,32 +336,27 @@ final class ChatErrorStateTests: XCTestCase { XCTAssertTrue(snippet.contains("await sendMessage(prompt)")) } - func testDashboardShowsChatErrorCard() throws { - let source = try sourceFile("MainWindow/Pages/DashboardPage.swift") - XCTAssertTrue(source.contains("dashboardChatErrorCard")) + func testHomeShowsChatErrorCard() throws { + let source = try sourceFile("MainWindow/QueryShell/QueryAnswerThread.swift") XCTAssertTrue(source.contains("ChatErrorCard(")) } - /// Static tripwire for the Home chat layout. The shared ChatErrorCard belongs to - /// homePanelStage, below the composer; placing it inside homeChatPanel as well - /// visibly duplicates the sign-in recovery CTA for the same ChatProvider state. - func testDashboardHomeChatHasOneSharedErrorCardRenderSite() throws { - let source = try sourceFile("MainWindow/Pages/DashboardPage.swift") - let panelStart = try XCTUnwrap(source.range(of: "private func homePanelStage")) - let chatStart = try XCTUnwrap(source.range(of: "private func homeChatPanel")) - let connectStart = try XCTUnwrap(source.range(of: "private func homeConnectPanel")) - - let panelSource = String(source[panelStart.lowerBound.. OmiAPI.WhatMattersNowProjection { - OmiAPI.WhatMattersNowProjection( - evaluationId: "evaluation-1", - expiresAt: expiresAt, - generatedAt: "2027-01-15T08:00:00Z", - materialVersion: "material-1", - outputVersion: outputVersion, - recommendations: items, - schemaVersion: 1 - ) - } - - private func pendingFeedback( - action: OmiAPI.TaskIntelligenceFeedbackAction, - key: String, - subjectID: String - ) -> PendingDashboardFeedback { - PendingDashboardFeedback( - request: OmiAPI.FeedbackCreate( - action: action, - contextSnapshotHash: nil, - interventionId: "intervention-\(subjectID)", - laterUntil: action == .later ? "2030-01-01T00:00:00Z" : nil, - reason: action == .dismiss ? .not_useful : nil, - subjectId: subjectID, - subjectKind: .task - ), - idempotencyKey: key, - accountGeneration: 7 - ) - } - - private func recommendation( - id: String, - kind: OmiAPI.RecommendationSubjectKind = .task, - outputVersion: String = "output-v1", - destinationWorkstreamID: String? = nil, - expiresAt: String = "2027-02-15T08:00:00Z", - dedupeKey: String? = nil - ) -> OmiAPI.Recommendation { - OmiAPI.Recommendation( - alternativeAction: nil, - dedupeKey: dedupeKey ?? "dedupe-\(id)", - destinationTaskId: kind == .task ? id : nil, - destinationWorkstreamId: destinationWorkstreamID, - evidencePreview: "Linked evidence", - evidenceRefs: [], - expiresAt: expiresAt, - feedbackSubjectId: id, - feedbackSubjectKind: kind == .candidate ? .candidate : .task, - goalOrWorkstreamLabel: "Launch", - headline: "Handle \(id)", - interventionId: "intervention-\(id)", - outputVersion: outputVersion, - recommendedAction: "Open", - subjectId: id, - subjectKind: kind, - whyNow: "It changed materially." - ) - } - - private func goal(id: String, status: OmiAPI.GoalStatus, rank: Int?) -> OmiAPI.GoalResponse { - OmiAPI.GoalResponse( - advice: nil, - createdAt: "2027-01-01T08:00:00Z", - currentValue: 1, - desiredOutcome: "Reach the outcome", - endedAt: status == .achieved ? "2027-01-10T08:00:00Z" : nil, - focusRank: rank, - goalId: id, - goalType: "numeric", - horizonAt: nil, - id: id, - isActive: status != .achieved && status != .abandoned, - latestProgressSequence: nil, - maxValue: 10, - metric: nil, - minValue: 0, - source: .user, - status: status, - successCriteria: ["Done"], - targetValue: 10, - title: "Goal \(id)", - unit: nil, - updatedAt: "2027-01-10T08:00:00Z", - whyItMatters: "Important" - ) - } - - private func candidate(id: String) -> OmiAPI.CandidateRecord { - OmiAPI.CandidateRecord( - accountGeneration: 7, - candidateId: id, - captureConfidence: 0.9, - createdAt: "2027-01-15T08:00:00Z", - evidenceRefs: [], - goalId: nil, - idempotencyKey: "capture-\(id)", - ownershipConfidence: 0.9, - proposedAction: .create, - resolutionReason: nil, - resolvedAt: nil, - resultTaskId: nil, - resultWorkstreamId: nil, - sourceSurface: "conversation", - status: .pending, - subjectKind: .task, - taskChange: .create( - OmiAPI.TaskCreatePayload( - description_: "Review exact candidate", - dueAt: nil, - dueConfidence: nil, - owner: .user, - priority: .medium, - recurrenceParentId: nil, - recurrenceRule: nil - )), - taskId: nil, - workstreamId: nil, - workstreamProposal: nil - ) - } -} - -private final class MemoryDashboardOutbox: DashboardFeedbackOutboxPersisting { - var ownerID = "test-owner" - private var entriesByOwner: [String: [PendingDashboardFeedback]] = [:] - - var entries: [PendingDashboardFeedback] { - get { load(ownerID: ownerID) } - set { save(newValue, ownerID: ownerID) } - } - - func currentOwnerID() -> String { ownerID } - func load(ownerID: String) -> [PendingDashboardFeedback] { entriesByOwner[ownerID] ?? [] } - func save(_ entries: [PendingDashboardFeedback], ownerID: String) { - entriesByOwner[ownerID] = entries - } -} - -@MainActor -final class DashboardFeedbackOutboxOwnerIsolationTests: XCTestCase { - override func setUp() async throws { - AccountCutoverControlManager.shared.resetForTesting() - AccountCutoverControlManager.shared.apply(.legacyDefault) - } - - override func tearDown() async throws { - AccountCutoverControlManager.shared.resetForTesting() - } - - func testDefaultOwnerTracksAuthenticationChanges() { - let suite = "DashboardFeedbackOutboxOwnerIsolationTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } - let outbox = DashboardFeedbackOutboxDefaults(defaults: defaults) - let entry = PendingDashboardFeedback( - request: OmiAPI.FeedbackCreate( - action: .dismiss, - contextSnapshotHash: nil, - interventionId: nil, - laterUntil: nil, - reason: .not_useful, - subjectId: "task-1", - subjectKind: .task - ), - idempotencyKey: "feedback-1", - accountGeneration: 7 - ) - defaults.set("owner-a", forKey: "auth_userId") - outbox.save([entry], ownerID: outbox.currentOwnerID()) - defaults.set("owner-b", forKey: "auth_userId") - XCTAssertTrue(outbox.load(ownerID: outbox.currentOwnerID()).isEmpty) - defaults.set("owner-a", forKey: "auth_userId") - XCTAssertEqual( - outbox.load(ownerID: outbox.currentOwnerID()).first?.idempotencyKey, "feedback-1") - } - - func testAccountSwitchDuringFeedbackDoesNotOverwriteNewOwnerQueue() async { - let suite = "DashboardFeedbackOutboxOwnerIsolationTests.inflight.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } - defaults.set("owner-a", forKey: "auth_userId") - let outbox = DashboardFeedbackOutboxDefaults(defaults: defaults) - let client = FakeDashboardIntelligenceClient() - client.projection = Self.projection(id: "recommendation-1") - client.feedbackSuspensionsRemaining = 1 - let store = DashboardIntelligenceStore(client: client, outboxStore: outbox) - await store.load() - let recommendation = store.recommendations[0] - let requestTask = Task { await store.later(recommendation) } - while client.feedbackRelease == nil { await Task.yield() } - defaults.set("owner-b", forKey: "auth_userId") - let ownerBEntry = PendingDashboardFeedback( - request: OmiAPI.FeedbackCreate( - action: .dismiss, - contextSnapshotHash: nil, - interventionId: nil, - laterUntil: nil, - reason: .not_useful, - subjectId: "task-b", - subjectKind: .task - ), - idempotencyKey: "owner-b-feedback", - accountGeneration: 7 - ) - outbox.save([ownerBEntry], ownerID: "owner-b") - client.feedbackRelease?.resume() - await requestTask.value - - XCTAssertEqual(outbox.load(ownerID: "owner-a").count, 1) - XCTAssertTrue( - outbox.load(ownerID: "owner-a")[0].idempotencyKey - .hasPrefix("wmn:intervention-recommendation-1:later:") - ) - XCTAssertEqual(outbox.load(ownerID: "owner-b").map(\.idempotencyKey), ["owner-b-feedback"]) - } - - func testRetryMergesConcurrentSameOwnerEnqueue() async { - let suite = "DashboardFeedbackOutboxOwnerIsolationTests.retry.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } - defaults.set("owner-a", forKey: "auth_userId") - let outbox = DashboardFeedbackOutboxDefaults(defaults: defaults) - let retryEntry = PendingDashboardFeedback( - request: OmiAPI.FeedbackCreate( - action: .later, - contextSnapshotHash: nil, - interventionId: "intervention-retry", - laterUntil: "2030-01-01T00:00:00Z", - reason: nil, - subjectId: "task-retry", - subjectKind: .task - ), - idempotencyKey: "retry-feedback", - accountGeneration: 7 - ) - outbox.save([retryEntry], ownerID: "owner-a") - let client = FakeDashboardIntelligenceClient() - client.feedbackSuspensionsRemaining = 1 - let store = DashboardIntelligenceStore(client: client, outboxStore: outbox) - store.applyContextProjection(Self.projection(id: "new-recommendation")) - let recommendation = store.recommendations[0] - let loadTask = Task { await store.load() } - while client.feedbackRelease == nil { await Task.yield() } - client.failFeedback = true - await store.later(recommendation) - client.failFeedback = false - client.feedbackRelease?.resume() - await loadTask.value - - let remaining = outbox.load(ownerID: "owner-a") - XCTAssertEqual(remaining.count, 1) - XCTAssertTrue( - remaining[0].idempotencyKey.hasPrefix("wmn:intervention-new-recommendation:later:")) - } - - private static func projection(id: String) -> OmiAPI.WhatMattersNowProjection { - OmiAPI.WhatMattersNowProjection( - evaluationId: "evaluation-\(id)", - expiresAt: "2030-01-01T00:00:00Z", - generatedAt: "2027-01-15T08:00:00Z", - materialVersion: "material-1", - outputVersion: "output-1", - recommendations: [ - OmiAPI.Recommendation( - alternativeAction: nil, - dedupeKey: "dedupe-\(id)", - destinationTaskId: id, - destinationWorkstreamId: nil, - evidencePreview: "Evidence", - evidenceRefs: [], - expiresAt: "2030-01-01T00:00:00Z", - feedbackSubjectId: id, - feedbackSubjectKind: .task, - goalOrWorkstreamLabel: nil, - headline: "Continue task", - interventionId: "intervention-\(id)", - outputVersion: "output-1", - recommendedAction: "Continue", - subjectId: id, - subjectKind: .task, - whyNow: "Ready" - ) - ], - schemaVersion: 1 - ) - } -} - -private final class FakeDashboardIntelligenceClient: DashboardIntelligenceClient { - nonisolated(unsafe) var workflowMode = OmiAPI.TaskWorkflowMode.read - nonisolated(unsafe) var projection: OmiAPI.WhatMattersNowProjection - nonisolated(unsafe) var goals: [OmiAPI.GoalResponse] = [] - nonisolated(unsafe) var detail: OmiAPI.GoalDetailProjection? - nonisolated(unsafe) var projectionLoads = 0 - nonisolated(unsafe) var projectionError: Error? - nonisolated(unsafe) var projectionSuspensionsRemaining = 0 - nonisolated(unsafe) var projectionRelease: CheckedContinuation? - nonisolated(unsafe) var controlError: Error? - nonisolated(unsafe) var goalsError: Error? - nonisolated(unsafe) var detailLoads = 0 - nonisolated(unsafe) var focusRequests: [(goalID: String, replacementID: String?)] = [] - nonisolated(unsafe) var focusError: Error? - nonisolated(unsafe) var failFeedback = false - nonisolated(unsafe) var feedbackKeys: [String] = [] - nonisolated(unsafe) var feedbackSuspensionsRemaining = 0 - nonisolated(unsafe) var feedbackRelease: CheckedContinuation? - nonisolated(unsafe) var outcomeRequests: [OmiAPI.OutcomeCreate] = [] - nonisolated(unsafe) var outcomeKeys: [String] = [] - nonisolated(unsafe) var failOutcome = false - nonisolated(unsafe) var lastDeviceID: String? - nonisolated(unsafe) var createdGoal: - (desiredOutcome: String, successCriteria: [String], generation: Int, idempotencyKey: String)? - nonisolated(unsafe) var exactCandidate: OmiAPI.CandidateRecord? - nonisolated(unsafe) var exactTask: TaskActionItem? - - init() { - projection = OmiAPI.WhatMattersNowProjection( - evaluationId: "evaluation-empty", - expiresAt: "2027-02-15T08:00:00Z", - generatedAt: "2027-01-15T08:00:00Z", - materialVersion: "material-empty", - outputVersion: "output-empty", - recommendations: [], - schemaVersion: 1 - ) - } - - func getCandidateWorkflowControl() async throws -> OmiAPI.TaskWorkflowControl { - if let controlError { throw controlError } - return OmiAPI.TaskWorkflowControl(accountGeneration: 7, workflowMode: workflowMode) - } - - func getWhatMattersNow(deviceID: String?) async throws -> OmiAPI.WhatMattersNowProjection { - projectionLoads += 1 - lastDeviceID = deviceID - let result = projection - let resultError = projectionError - if projectionSuspensionsRemaining > 0 { - projectionSuspensionsRemaining -= 1 - await withCheckedContinuation { projectionRelease = $0 } - projectionRelease = nil - } - if let resultError { throw resultError } - return result - } - - func getCanonicalGoals(includeEnded: Bool) async throws -> [OmiAPI.GoalResponse] { - if let goalsError { throw goalsError } - return goals - } - - func getCanonicalGoalDetail(goalID: String) async throws -> OmiAPI.GoalDetailProjection { - detailLoads += 1 - guard let detail else { throw FakeError.missing } - return detail - } - - func getCanonicalCandidate(candidateID: String) async throws -> OmiAPI.CandidateRecord { - guard let exactCandidate else { throw FakeError.missing } - return exactCandidate - } - - func getActionItem(id: String) async throws -> TaskActionItem { - guard let exactTask else { throw FakeError.missing } - return exactTask - } - - func createCanonicalGoal( - title: String, desiredOutcome: String, whyItMatters: String?, successCriteria: [String], - accountGeneration: Int, idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse { - createdGoal = (desiredOutcome, successCriteria, accountGeneration, idempotencyKey) - return goals.first! - } - - func recordTaskFeedback( - _ request: OmiAPI.FeedbackCreate, idempotencyKey: String, accountGeneration: Int - ) async throws -> OmiAPI.FeedbackRecord { - feedbackKeys.append(idempotencyKey) - if feedbackSuspensionsRemaining > 0 { - feedbackSuspensionsRemaining -= 1 - await withCheckedContinuation { feedbackRelease = $0 } - feedbackRelease = nil - } - if failFeedback { throw FakeError.missing } - return OmiAPI.FeedbackRecord( - action: request.action, - attributionChainId: "attribution", - contextSnapshotHash: nil, - createdAt: "2027-01-15T08:00:00Z", - dedupeKey: "dedupe", - feedbackId: "feedback", - interventionId: request.interventionId, - laterUntil: request.laterUntil, - proposedCompletion: false, - proposedCompletionCandidateId: nil, - reason: request.reason, - subjectId: request.subjectId, - subjectKind: request.subjectKind - ) - } - - func createTaskOutcome( - _ request: OmiAPI.OutcomeCreate, idempotencyKey: String, accountGeneration: Int - ) async throws -> OmiAPI.OutcomeRecord { - outcomeRequests.append(request) - outcomeKeys.append(idempotencyKey) - if failOutcome { throw FakeError.missing } - return OmiAPI.OutcomeRecord( - attributionChainId: request.attributionChainId, - occurredAt: "2027-01-15T08:00:00Z", - outcomeCode: request.outcomeCode, - outcomeId: "outcome-\(idempotencyKey)", - subjectId: request.subjectId, - subjectKind: request.subjectKind - ) - } - - func focusCanonicalGoal( - goalID: String, replacementGoalID: String?, focusRank: Int?, accountGeneration: Int, - idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse { - focusRequests.append((goalID, replacementGoalID)) - if let focusError { throw focusError } - return goals.first(where: { $0.goalId == goalID })! - } - - func unfocusCanonicalGoal( - goalID: String, accountGeneration: Int, idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse { - goals.first(where: { $0.goalId == goalID })! - } - - func transitionCanonicalGoal( - goalID: String, status: OmiAPI.GoalStatus, relationshipDisposition: String, - accountGeneration: Int, idempotencyKey: String - ) async throws -> OmiAPI.GoalResponse { - goals.first(where: { $0.goalId == goalID })! - } - - enum FakeError: Error { case missing } -} diff --git a/desktop/macos/Desktop/Tests/GlassLegibilityTests.swift b/desktop/macos/Desktop/Tests/GlassLegibilityTests.swift index ee1f0640583..bc4a3b000fc 100644 --- a/desktop/macos/Desktop/Tests/GlassLegibilityTests.swift +++ b/desktop/macos/Desktop/Tests/GlassLegibilityTests.swift @@ -220,16 +220,17 @@ final class GlassLegibilityTests: XCTestCase { /// test could resolve ever changed. A component-scoped tripwire on the literal is the only thing /// that would have failed on that commit. /// - /// Scoped to Home's own file and to `Color(red:` specifically: a page hosted on the panel has no + /// Scoped to Home's own file — `QueryShellHome`, since `DashboardPage` was deleted — and to + /// `Color(red:` specifically: a page hosted on the panel has no /// business mixing its own opaque colour at all, and every legitimate surface on it is a token. func testStaticCheck_HomeMixesNoColourLiteralOfItsOwn() { let home = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() // Tests .deletingLastPathComponent() // Desktop - .appendingPathComponent("Sources/MainWindow/Pages/DashboardPage.swift") + .appendingPathComponent("Sources/MainWindow/QueryShell/QueryShellHome.swift") // omi-test-quality: source-inspection -- static contract: which token a call site names is a source fact; a rendered view cannot report it. guard let source = try? String(contentsOf: home, encoding: .utf8) else { - return XCTFail("Could not read DashboardPage.swift at \(home.path)") + return XCTFail("Could not read QueryShellHome.swift at \(home.path)") } XCTAssertFalse( source.contains("Color(red:"), diff --git a/desktop/macos/e2e/flows/tasks.yaml b/desktop/macos/e2e/flows/tasks.yaml index cfcd1645be1..f75048ac13b 100644 --- a/desktop/macos/e2e/flows/tasks.yaml +++ b/desktop/macos/e2e/flows/tasks.yaml @@ -5,6 +5,8 @@ description: Tasks tab — search, Today/No Deadline sections, keyboard toolbar app: com.omi.computer-macos covers: - desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift + # The exact-record handoff TasksPage consumes on arrival. + - desktop/macos/Desktop/Sources/MainWindow/Dashboard/TaskNavigationRequestStore.swift - desktop/macos/Desktop/Sources/Rewind/Core/ActionItemStorage.swift - desktop/macos/Desktop/Sources/MainWindow/Tasks/SuggestedTasksSection.swift - desktop/macos/Desktop/Sources/MainWindow/Tasks/TasksEscapeHandling.swift diff --git a/desktop/macos/scripts/check_desktop_test_quality.py b/desktop/macos/scripts/check_desktop_test_quality.py index 57a0c53e3bd..ef3d82bb55e 100755 --- a/desktop/macos/scripts/check_desktop_test_quality.py +++ b/desktop/macos/scripts/check_desktop_test_quality.py @@ -44,8 +44,8 @@ # Pinned debt ceilings. These may only decrease. Escaped sites are not counted. # Run with --print after improving tests, then lower both relevant values. -SOURCE_INSPECTION_FILE_BASELINE = 54 -SOURCE_INSPECTION_SITE_BASELINE = 146 +SOURCE_INSPECTION_FILE_BASELINE = 53 +SOURCE_INSPECTION_SITE_BASELINE = 144 WALL_CLOCK_WAIT_BASELINE = 16 MIN_REASON_LENGTH = 12 From 55e75e58259acc16ba2a732b5e4ec030c7a47ae5 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 03:39:09 -0400 Subject: [PATCH 07/29] fix(desktop): an explicit settings section still wins over the help default `navigate help` pre-selects About because that is where getting help from a person lives. A caller that also names a section meant that section. Co-Authored-By: Claude Fable 5.1 --- desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 200f191af36..deb85bea2be 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -703,7 +703,7 @@ struct DesktopHomeView: View { // waiting for it. Settings → About is where getting help from a person // actually lives (the Community / Join Discord card), so the name now lands // on a destination that exists. - if ChatFirstRoute.isHelpAutomationTarget(target) { + if ChatFirstRoute.isHelpAutomationTarget(target), settingsSectionRaw == nil { selectedSettingsSection = .about } if let route = ChatFirstRoute.automationVisibilityDestination(named: target) { From 7f4b440ea7419c0d7eb2a038b7fac82068521e2e Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 03:25:40 -0400 Subject: [PATCH 08/29] fix(desktop): let a settled chat answer be selected, copied and seen as cut off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things a reader could not do with a reply on screen. Select it. `OmiMarkdown` disabled native text selection outright, so a date or a name in an answer could only be retyped. The reason was real — one AppKit selection overlay per `Text`, on a row that rewrites its body every streaming flush, is a non-converging layout loop (400 segments, 2 s hangs) — but it is a reason about *streaming* rows. Selection is now opt-in through `\.chatTextSelectable`, and `ChatTextSelectionPolicy` grants it to settled rows only, on every surface that shows chat prose: the transcript, the notch, the expanded floating bar, and onboarding. The three `.textSelection(.enabled)` calls outside `OmiMarkdown` in the floating surfaces were dead — the inner `.disabled` won — and are replaced rather than left as decoration. Copy it without hunting. The copy button lived only in the hover-revealed strip, and a user turn had no copy affordance at all. Every row now has a "Copy Message" context menu over the same pasteboard write, and the copy button takes ⌘C while its row's strip holds keyboard focus — not window-wide, which would take the shortcut from selected prose and the composer. See that it stopped mid-sentence. A voice barge-in persists the partial answer with a terminal failed status, and the only failure affordance was a stamp for a row with no text — so "…arrive on Saturday," rendered exactly like a finished reply. `ChatTurnFailurePresentation` decides between that stamp and a quiet trailing "Interrupted" mark, and the empty-row case is unchanged. Also: the hover strip is now `accessibilityHidden` when it is invisible (opacity and hit-testing hid it from the eye and the mouse but not VoiceOver), each row carries a You/Omi label, and a row whose whole content is a rich block reserves no metadata band — a memory card stamps its own time and has nothing to copy or rate. Co-Authored-By: Claude Fable 5.1 --- .../FloatingControlBar/AIResponseView.swift | 10 +- .../FloatingControlBarView.swift | 9 +- .../MainWindow/Components/ChatBubble.swift | 99 ++++++++++++++++--- .../Components/ChatBubbleSupport.swift | 73 +++++++++++++- .../MainWindow/Components/OmiMarkdown.swift | 23 +++-- .../Onboarding/OnboardingChatView.swift | 12 +++ .../20260902-chat-row-ergonomics.json | 3 + 7 files changed, 200 insertions(+), 29 deletions(-) create mode 100644 desktop/macos/changelog/unreleased/20260902-chat-row-ergonomics.json diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift index 294e3a2aa37..288513bb8c3 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift @@ -196,7 +196,10 @@ struct AIResponseView: View { switch group { case .text(_, let text): OmiMarkdown(text: text, sender: .ai, citations: message.inlineCitationReferences) - .textSelection(.enabled) + .environment( + \.chatTextSelectable, + ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) + ) .environment(\.colorScheme, .dark) .frame(maxWidth: .infinity, alignment: .leading) case .commentary(_, let text): @@ -259,7 +262,10 @@ struct AIResponseView: View { } } else if !message.text.isEmpty { OmiMarkdown(text: message.text, sender: .ai, citations: message.inlineCitationReferences) - .textSelection(.enabled) + .environment( + \.chatTextSelectable, + ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) + ) .environment(\.colorScheme, .dark) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift index f7c294952d6..4c564efd87e 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift @@ -2156,6 +2156,10 @@ private struct AgentMainChatView: View { case .text(_, let text): if !text.isEmpty { OmiMarkdown(text: text, sender: .ai, citations: message.inlineCitationReferences) + .environment( + \.chatTextSelectable, + ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) + ) .environment(\.colorScheme, .dark) .frame(maxWidth: .infinity, alignment: .leading) } @@ -2216,7 +2220,10 @@ private struct AgentMainChatView: View { let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { OmiMarkdown(text: trimmed, sender: .ai, citations: message.inlineCitationReferences) - .textSelection(.enabled) + .environment( + \.chatTextSelectable, + ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) + ) .environment(\.fontScale, 0.88) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift index bf6fbe99cf4..fdbc788381c 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift @@ -6,6 +6,26 @@ enum ChatBubbleMetadataControlMetrics { static let leadingInset = OmiSpacing.xxs static let topInset = leadingInset static let targetSize: CGFloat = 24 + + /// What an assistant row reserves under its last line for the hover strip. + /// The transcript's row spacing subtracts it, so the gap under a reply is the + /// band itself rather than the band *plus* a full inter-exchange gap. + static let bandHeight: CGFloat = topInset + targetSize +} + +/// `.keyboardShortcut` is unconditional on a `Button`, so the guard has to be +/// the modifier's presence rather than an argument to it. +struct ChatCopyKeyboardShortcut: ViewModifier { + let isActive: Bool + + @ViewBuilder + func body(content: Content) -> some View { + if isActive { + content.keyboardShortcut("c", modifiers: .command) + } else { + content + } + } } enum ChatBubbleMetadataHoverRegion { @@ -218,11 +238,18 @@ struct ChatBubble: View { .frame(maxWidth: .infinity, alignment: message.sender == .user ? .trailing : .leading) } } + // The reserved mark height is for **an empty streaming reply**, which has no + // content of its own and would otherwise clip the mark. A settled row is + // always taller than the mark, so reserving it there only centred short + // content — a one-line answer or a memory card — inside a 32 pt box and + // floated it in symmetric dead space. .frame( maxWidth: .infinity, - minHeight: ChatOmiMarkPlacement.rowHeight( - showsMark: message.sender == .ai && app == nil && showsOmiMark), - alignment: message.sender == .user ? .trailing : .leading + minHeight: message.isStreaming + ? ChatOmiMarkPlacement.rowHeight( + showsMark: message.sender == .ai && app == nil && showsOmiMark) + : 0, + alignment: message.sender == .user ? .topTrailing : .topLeading ) .overlay(alignment: .topLeading) { if message.sender == .ai, app == nil, showsOmiMark { @@ -243,6 +270,39 @@ struct ChatBubble: View { } .contentShape(Rectangle()) .onHover { updateMetadataHover(.row, hovering: $0) } + // A settled row may be selected with the cursor; a streaming one may not. + .environment( + \.chatTextSelectable, + ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) + ) + // Copy without hunting for the hover strip — and the only copy affordance a + // user turn has ever had. + .contextMenu { messageContextMenu } + .accessibilityElement(children: .contain) + .accessibilityLabel(message.sender == .user ? "You" : "Omi") + } + + /// The text the row's copy actions put on the pasteboard. `copyableText` + /// excludes pre-tool commentary, but it is empty for a user turn, whose whole + /// body is the message. + private var copyPayload: String { + message.copyableText.isEmpty ? message.text : message.copyableText + } + + private func copyMessageToPasteboard() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(copyPayload, forType: .string) + showCopied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + showCopied = false + } + } + + @ViewBuilder + private var messageContextMenu: some View { + if !copyPayload.isEmpty { + Button("Copy Message") { copyMessageToPasteboard() } + } } @ViewBuilder @@ -343,13 +403,23 @@ struct ChatBubble: View { // `ChatTurnFailureNotice`). The blanket "Couldn't save this reply" caption // both duplicated that reason in different words and named the wrong // cause — the turn failed, no save was attempted. Keep a stamp only for a - // failed row that has nothing of its own to say. - if message.sender == .ai && !message.isStreaming && message.journalStatus == .failed - && message.text.isEmpty && message.contentBlocks.isEmpty - { + // failed row that has nothing of its own to say — and, for a row that was + // cut off mid-sentence, a quiet mark so the reader can see it was cut. + switch ChatTurnFailurePresentation.of(message) { + case .none: + EmptyView() + case .emptyTurnStamp: Text("This turn didn't finish") .scaledFont(size: OmiType.micro, weight: .medium) .foregroundColor(PageGlass.warning) + case .truncatedAnswer: + HStack(spacing: OmiSpacing.xxs) { + Text("\u{2026}") + .scaledFont(size: OmiType.caption, weight: .semibold) + Text("Interrupted") + .scaledFont(size: OmiType.micro, weight: .medium) + } + .foregroundColor(Ink.secondary) } switch ChatBubbleMetadataBand.of(message) { @@ -536,6 +606,9 @@ struct ChatBubble: View { .onHover { updateMetadataHover(.controls, hovering: $0) } .opacity(isVisible ? 1 : 0) .allowsHitTesting(isVisible) + // Opacity and hit-testing hide the strip from the eye and the mouse; without + // this VoiceOver still walked through invisible thumbs and a copy button. + .accessibilityHidden(!isVisible) .omiAnimation(.easeInOut(duration: 0.15), value: isVisible) } @@ -618,14 +691,7 @@ struct ChatBubble: View { @ViewBuilder private var copyButton: some View { - Button(action: { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(message.copyableText, forType: .string) - showCopied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - showCopied = false - } - }) { + Button(action: copyMessageToPasteboard) { Image(systemName: showCopied ? "checkmark" : "doc.on.doc") .scaledFont(size: OmiType.caption) .foregroundColor(showCopied ? Ink.listeningGreen : Ink.secondary) @@ -637,6 +703,9 @@ struct ChatBubble: View { } .buttonStyle(.plain) .focused($isMetadataControlFocused) + // Only while this row's control strip holds keyboard focus. A window-wide + // ⌘C would take the shortcut away from selected prose and the composer. + .modifier(ChatCopyKeyboardShortcut(isActive: isMetadataControlFocused)) .help("Copy message") } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift index 7e6d9c37ed0..d72754b72e3 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift @@ -341,7 +341,14 @@ enum ChatBubbleMetadataBand: Equatable { static func of(_ message: ChatMessage) -> Self { guard message.sender == .ai, !message.isStreaming else { return .hidden } - guard !message.copyableText.isEmpty else { return .timestampOnly } + guard !message.copyableText.isEmpty else { + // **A row whose whole content is a rich block gets no band.** A memory + // card carries its own header and time on its face; reserving a strip + // for a second timestamp underneath it left the card floating in dead + // space with nothing to copy or rate. A row with nothing at all still + // keeps its timestamp — that stamp is all it has. + return message.contentBlocks.isEmpty ? .timestampOnly : .hidden + } return .actions } } @@ -492,3 +499,67 @@ struct ChatSuggestedTaskRow: View { } } } + +/// **When chat prose may host AppKit's selection overlay.** +/// +/// `.textSelection(.enabled)` costs one AppKit `SelectionOverlay` per `Text`. +/// A *streaming* row rewrites its body on every flush, so those overlays turn +/// into a font/intrinsic-size/layout loop — the measured 400-segment, 2-second +/// hang that made `OmiMarkdown` disable selection outright. A *settled* row is +/// rewritten only by journal replay, so it can carry selection safely, and the +/// reader can finally drag a date or a name out of an answer. +enum ChatTextSelectionPolicy { + static func isSelectable(isStreaming: Bool) -> Bool { !isStreaming } +} + +private struct ChatTextSelectableKey: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + /// Opt-in switch read by `OmiMarkdown`. The default keeps every host that has + /// not reasoned about the cost above selection-free. + var chatTextSelectable: Bool { + get { self[ChatTextSelectableKey.self] } + set { self[ChatTextSelectableKey.self] = newValue } + } +} + +/// `.textSelection` takes two different concrete types, so the choice cannot be +/// a ternary. One modifier keeps the branch in a single place. +struct OmiChatTextSelectability: ViewModifier { + let isEnabled: Bool + + @ViewBuilder + func body(content: Content) -> some View { + if isEnabled { + content.textSelection(.enabled) + } else { + content.textSelection(.disabled) + } + } +} + +/// **How a turn that stopped mid-sentence tells the reader it was cut off.** +/// +/// A voice barge-in persists whatever the assistant had said so far with a +/// terminal `.failed` status. Before this, a truncated answer rendered exactly +/// like a complete one — "…arrive on Saturday," with nothing to say it was +/// interrupted — because the only failure affordance was a stamp for a row +/// with no text at all. +enum ChatTurnFailurePresentation: Equatable { + /// Not a failed assistant row. + case none + /// The turn failed with nothing to show: the row is the notice. + case emptyTurnStamp + /// The turn failed after saying something: show it, then mark the cut. + case truncatedAnswer + + static func of(_ message: ChatMessage) -> Self { + guard message.sender == .ai, !message.isStreaming, message.journalStatus == .failed else { + return .none + } + guard message.text.isEmpty, message.contentBlocks.isEmpty else { return .truncatedAnswer } + return .emptyTurnStamp + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift index ce534ed62d2..7f526c39b64 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift @@ -13,14 +13,14 @@ import OmiTheme /// - Thematic breaks render as a quiet, branded section divider rather than /// leaking their Markdown source (`---`) into a response. /// -/// Live chat Markdown deliberately disables native SwiftUI text selection. Even -/// settled messages still participate in transcript loading, scrolling, window -/// resizing, and parent-state updates. AppKit-backed selection overlays can turn -/// those updates into a non-converging font/intrinsic-size/layout loop. +/// Native SwiftUI text selection is **opt-in per host**, through +/// `\.chatTextSelectable`. A row that is still streaming rewrites its body on +/// every flush, and AppKit-backed selection overlays turn those updates into a +/// non-converging font/intrinsic-size/layout loop — so the default is off and +/// `ChatTextSelectionPolicy` is what lets a settled row through. /// /// Chat bubbles retain whole-message copy actions, while code blocks and tables -/// keep their focused copy controls. A future selectable reading surface must be -/// isolated from the live transcript instead of adding an escape hatch here. +/// keep their focused copy controls. struct OmiMarkdown: View { enum Style: Equatable { case assistant @@ -33,6 +33,7 @@ struct OmiMarkdown: View { let citations: [ChatCitationReference] let onOpenCitation: ((ChatCitationReference) -> Void)? @Environment(\.fontScale) private var fontScale + @Environment(\.chatTextSelectable) private var chatTextSelectable init( text: String, @@ -75,7 +76,7 @@ struct OmiMarkdown: View { onOpenCitation: onOpenCitation) } } - .textSelection(.disabled) + .modifier(OmiChatTextSelectability(isEnabled: chatTextSelectable)) } static func containsGFMTable(_ content: String) -> Bool { @@ -1444,6 +1445,7 @@ private struct OmiMarkdownTableView: View { let fontScale: CGFloat let citations: [ChatCitationReference] let onOpenCitation: ((ChatCitationReference) -> Void)? + @Environment(\.chatTextSelectable) private var chatTextSelectable private var allRows: [[String]] { [table.header] + table.rows @@ -1483,9 +1485,10 @@ private struct OmiMarkdownTableView: View { .stroke(borderColor, lineWidth: 1) ) .fixedSize(horizontal: false, vertical: true) - // Tables do not create one AppKit SelectionOverlay per cell inside the - // live transcript. Copy remains available only on fenced code blocks. - .textSelection(.disabled) + // A live (streaming) transcript does not create one AppKit SelectionOverlay + // per cell; a settled row opts in through `\.chatTextSelectable` so a value + // can be dragged out of a table the same way it can out of a sentence. + .modifier(OmiChatTextSelectability(isEnabled: chatTextSelectable)) .accessibilityElement(children: .contain) .accessibilityIdentifier("omi-markdown-table") } diff --git a/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift b/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift index c483c10763f..1ed131398c2 100644 --- a/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift +++ b/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift @@ -1905,6 +1905,10 @@ struct OnboardingChatBubble: View { // Fallback for messages loaded from backend (no contentBlocks, only flat text) if !message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { OmiMarkdown(text: message.text, style: .assistant) + .environment( + \.chatTextSelectable, + ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) + ) .padding(.horizontal, OmiSpacing.md) .padding(.vertical, OmiSpacing.sm) .glassCard() @@ -1916,6 +1920,10 @@ struct OnboardingChatBubble: View { if !allText.isEmpty { OmiMarkdown(text: allText, style: .assistant) + .environment( + \.chatTextSelectable, + ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) + ) .padding(.horizontal, OmiSpacing.md) .padding(.vertical, OmiSpacing.sm) .glassCard() @@ -1943,6 +1951,10 @@ struct OnboardingChatBubble: View { } else { if !message.text.isEmpty { OmiMarkdown(text: message.text, style: .onboardingUser) + .environment( + \.chatTextSelectable, + ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) + ) .padding(.horizontal, OmiSpacing.md) .padding(.vertical, OmiSpacing.sm) .glassCard(emphasized: true) diff --git a/desktop/macos/changelog/unreleased/20260902-chat-row-ergonomics.json b/desktop/macos/changelog/unreleased/20260902-chat-row-ergonomics.json new file mode 100644 index 00000000000..112cedc90ec --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260902-chat-row-ergonomics.json @@ -0,0 +1,3 @@ +{ + "change": "Chat answers are now selectable, copyable from a right-click menu, tighter in the transcript, and a reply cut off by an interruption says so instead of looking finished" +} From 1d03f9327a20b3302aaf4d040ca6e82a58d75e0e Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 03:25:55 -0400 Subject: [PATCH 09/29] fix(desktop): stop charging the transcript twice for the metadata band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consecutive one-line answers sat roughly 100 device pixels apart, and a memory card floated in symmetric dead space. Measured on the real views: 44 pt between two settled replies, and a 48 pt card row inflated to 69 pt. Two causes, both double-charges. The hover strip is 28 pt of real reserved height under every settled reply. The stack then added a full 16 pt inter-exchange gap on top of it, so the separation the band already provides was paid for twice. `ChatTranscriptLayout.spacing` now asks whether the row above reserves a band and takes a hairline when it does; every other rung of the ladder is unchanged, and a reply still binds to its question more tightly than to the next exchange. `ChatOmiMarkPlacement.rowHeight` reserved 32 pt on every assistant row for a mark that only needs it when an empty streaming reply has no height of its own. On a settled row the reservation did nothing but centre short content in a box taller than itself — which is what put equal dead space above and below the memory card. It now applies while streaming, top-aligned. Measured after: 32 pt between two replies, 48 pt for the card row, and a five-row transcript 296 pt tall instead of 341. Also collapses adjacent repeats. Dedup only ran on messages over 200 characters, so three push-to-talk tries at the same ~90-character question stuttered down the transcript untouched — each press mints a distinct `voice:` turn, so those are three legitimate journal rows and journal identity is not the place to fix it. `adjacentDuplicateIDs` collapses a short answer repeated in the row immediately below it within ten minutes, and folds a failed barge-in fragment into the answer it is a strict prefix of. It stays behind the existing expandable "Duplicate message" chip, so nothing is hidden outright, and non-adjacent, distant, or cross-sender repeats are left alone. Co-Authored-By: Claude Fable 5.1 --- .../Components/ChatMessagesView.swift | 68 +++++- .../Tests/ChatRowErgonomicsTests.swift | 199 ++++++++++++++++++ .../Tests/DesktopChatDriftGuardTests.swift | 36 ++-- 3 files changed, 287 insertions(+), 16 deletions(-) create mode 100644 desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift index 9899ba83687..05a26833b14 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift @@ -25,7 +25,7 @@ enum ChatMessageDeduplicator { var seen: [String: String] = [:] // sender+full-text fingerprint → first message ID var dupes = Set() for msg in messages { - guard msg.text.count > 200 else { continue } // only dedup long messages + guard msg.text.count > 200 else { continue } // replay dedup: long messages only let fingerprint = "\(msg.sender)\u{1}\(msg.text)" if seen[fingerprint] != nil { dupes.insert(msg.id) @@ -33,8 +33,60 @@ enum ChatMessageDeduplicator { seen[fingerprint] = msg.id } } + return dupes.union(adjacentDuplicateIDs(in: messages)) + } + + /// Below this an answer is too small to read as a stutter worth a chip. + static let shortDuplicateMinimumLength = 40 + /// Two rows further apart than this are two occasions, not one repeated one. + static let adjacentDuplicateWindow: TimeInterval = 600 + + /// A short answer repeated back-to-back is the other way the transcript + /// stutters, and the 200-character floor above never caught it: each press of + /// push-to-talk mints a distinct `voice:` turn, so three tries at the + /// same question are three legitimate journal rows saying the same ~90 + /// characters. Journal identity is not the place to fix that — this is a + /// display collapse, and it stays behind the expandable "Duplicate message" + /// chip so nothing is ever hidden outright. + /// + /// Adjacency and time proximity are what keep it honest: the same sentence + /// said again tomorrow, or with another exchange in between, is a real answer + /// to a real question and must not collapse. + static func adjacentDuplicateIDs(in messages: [ChatMessage]) -> Set { + var dupes = Set() + for index in messages.indices.dropFirst() { + let previous = messages[index - 1] + let current = messages[index] + guard previous.sender == current.sender else { continue } + guard + abs(current.createdAt.timeIntervalSince(previous.createdAt)) <= adjacentDuplicateWindow + else { continue } + + let earlier = normalizedBody(previous) + let later = normalizedBody(current) + + if earlier == later { + // The floor is on the answer itself: a repeated "Done." is not a stutter. + guard earlier.count >= shortDuplicateMinimumLength else { continue } + dupes.insert(current.id) + } else if previous.journalStatus == .failed, !earlier.isEmpty, later.hasPrefix(earlier), + later.count >= shortDuplicateMinimumLength + { + // A barge-in fragment and the answer it was cut out of. The *fragment* + // is short by definition, so the floor applies to the whole answer. + dupes.insert(previous.id) + } else if current.journalStatus == .failed, !later.isEmpty, earlier.hasPrefix(later), + earlier.count >= shortDuplicateMinimumLength + { + dupes.insert(current.id) + } + } return dupes } + + private static func normalizedBody(_ message: ChatMessage) -> String { + message.text.trimmingCharacters(in: .whitespacesAndNewlines) + } } /// **When duplicate detection has to run again.** @@ -142,6 +194,13 @@ enum ChatTranscriptLayout { /// `topAdjustment`, so the stack has one spacing and the exceptions are named. static let regularRowSpacing: CGFloat = OmiSpacing.lg static let consecutiveUserRowSpacing: CGFloat = OmiSpacing.sm + /// **The gap after a row that reserves its own metadata band.** That band is + /// 28 pt of real, empty layout under the last line, so adding a full + /// inter-exchange gap on top of it charged the reader twice for the same + /// separation — roughly 100 device pixels of nothing between two one-line + /// answers. The band *is* the gap; this is only the hairline that keeps the + /// controls off the next row. + static let afterMetadataBandRowSpacing: CGFloat = OmiSpacing.xxs /// A reply and the question that caused it are one exchange, not two events. /// `md` rather than `sm`: the user bubble's own bottom padding already hugs /// the text, so `sm` left the next assistant line sitting on the bubble. @@ -149,10 +208,11 @@ enum ChatTranscriptLayout { /// The gap *before* `current`, given the row above it. /// - /// An assistant row above always takes the full gap: it closes an exchange, and - /// it is also the row whose hover-revealed metadata band draws into the space - /// below it, so that space has to exist. + /// A row that reserves a metadata band has already paid for the separation in + /// its own height, so it takes the hairline. Everything else follows the + /// exchange ladder. static func spacing(from previous: ChatMessage, to current: ChatMessage) -> CGFloat { + if ChatBubbleMetadataBand.of(previous) != .hidden { return afterMetadataBandRowSpacing } guard previous.sender == .user else { return regularRowSpacing } return current.sender == .user ? consecutiveUserRowSpacing : replySpacing } diff --git a/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift b/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift new file mode 100644 index 00000000000..cd0c7bbbf8c --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift @@ -0,0 +1,199 @@ +import AppKit +import SwiftUI +import XCTest + +@testable import Omi_Computer + +/// Selection is the difference between a transcript you can read and one you can +/// use: before this, a date or a name in an answer could only be retyped. +final class ChatTextSelectionPolicyTests: XCTestCase { + func testASettledRowIsSelectable() { + XCTAssertTrue(ChatTextSelectionPolicy.isSelectable(isStreaming: false)) + } + + /// The measured hang this gate exists for: a streaming row rewrites its body + /// every flush, and one AppKit selection overlay per `Text` turns that into a + /// non-converging layout loop. + func testAStreamingRowIsNotSelectable() { + XCTAssertFalse(ChatTextSelectionPolicy.isSelectable(isStreaming: true)) + } +} + +/// A turn cut off by a barge-in used to render exactly like a finished one. +final class ChatTurnFailurePresentationTests: XCTestCase { + private func failed(_ text: String, blocks: [ChatContentBlock] = []) -> ChatMessage { + ChatMessage( + id: "t", text: text, sender: .ai, isStreaming: false, contentBlocks: blocks, + journalStatus: .failed) + } + + func testAFailedTurnWithPartialTextIsMarkedTruncated() { + XCTAssertEqual( + ChatTurnFailurePresentation.of(failed("They arrive on Saturday,")), .truncatedAnswer) + } + + func testAFailedTurnWithNothingToShowKeepsTheStamp() { + XCTAssertEqual(ChatTurnFailurePresentation.of(failed("")), .emptyTurnStamp) + } + + func testAFailedTurnThatOnlyProducedBlocksIsStillMarkedTruncated() { + XCTAssertEqual( + ChatTurnFailurePresentation.of(failed("", blocks: [.text(id: "b", text: "partial")])), + .truncatedAnswer) + } + + func testACompletedTurnIsNotAFailure() { + let done = ChatMessage( + id: "t", text: "All set.", sender: .ai, isStreaming: false, journalStatus: .completed) + XCTAssertEqual(ChatTurnFailurePresentation.of(done), .none) + } + + /// A turn still streaming has not failed yet, whatever the last journal row said. + func testAStreamingRowIsNeverPresentedAsFailed() { + let live = ChatMessage( + id: "t", text: "They arrive on Sat", sender: .ai, isStreaming: true, journalStatus: .failed) + XCTAssertEqual(ChatTurnFailurePresentation.of(live), .none) + } +} + +/// Each push-to-talk press mints a distinct journal turn, so the same short +/// answer three times is three legitimate rows. Collapsing them is a *display* +/// decision, and it has to be narrow enough not to eat a real repeated answer. +final class ChatShortDuplicateCollapseTests: XCTestCase { + private let shortAnswer = + "They arrive on Saturday, and the booking is already confirmed." + + private func msg( + _ id: String, _ text: String, sender: ChatSender = .ai, offset: TimeInterval = 0, + status: KernelJournalTurnStatus? = nil + ) -> ChatMessage { + ChatMessage( + id: id, text: text, createdAt: Date(timeIntervalSince1970: 1_700_000_000 + offset), + sender: sender, journalStatus: status) + } + + func testConsecutiveIdenticalShortAnswersCollapse() { + let messages = [ + msg("1", shortAnswer), + msg("2", shortAnswer, offset: 30), + msg("3", shortAnswer, offset: 60), + ] + XCTAssertEqual(ChatMessageDeduplicator.duplicateIDs(in: messages), ["2", "3"]) + } + + /// The same sentence answering a different question later is not a stutter. + func testIdenticalShortAnswersSeparatedByAnotherTurnDoNotCollapse() { + let messages = [ + msg("1", shortAnswer), + msg("q", "and the flight?", sender: .user, offset: 10), + msg("2", shortAnswer, offset: 20), + ] + XCTAssertTrue(ChatMessageDeduplicator.duplicateIDs(in: messages).isEmpty) + } + + func testIdenticalShortAnswersFarApartInTimeDoNotCollapse() { + let messages = [msg("1", shortAnswer), msg("2", shortAnswer, offset: 3_600)] + XCTAssertTrue(ChatMessageDeduplicator.duplicateIDs(in: messages).isEmpty) + } + + func testTheSameWordsFromDifferentSendersDoNotCollapse() { + let messages = [msg("1", shortAnswer, sender: .user), msg("2", shortAnswer, offset: 5)] + XCTAssertTrue(ChatMessageDeduplicator.duplicateIDs(in: messages).isEmpty) + } + + /// A barge-in fragment followed by the answer it was cut out of. + func testAFailedFragmentCollapsesIntoTheCompleteAnswerBelowIt() { + let messages = [ + msg("fragment", "They arrive on Saturday, and the", offset: 0, status: .failed), + msg("full", shortAnswer, offset: 20), + ] + XCTAssertEqual(ChatMessageDeduplicator.duplicateIDs(in: messages), ["fragment"]) + } + + /// The observed screenshot: the answer twice, then a third try cut off. + func testATruncatedRetryAfterACompleteAnswerCollapses() { + let messages = [ + msg("1", shortAnswer), + msg("2", shortAnswer, offset: 20), + msg("3", "They arrive on Saturday,", offset: 40, status: .failed), + ] + XCTAssertEqual(ChatMessageDeduplicator.duplicateIDs(in: messages), ["2", "3"]) + } + + /// A one-word "Done." repeated is not a stutter worth a chip. + func testVeryShortRepeatsStayBelowTheFloor() { + let messages = [msg("1", "Done."), msg("2", "Done.", offset: 5)] + XCTAssertTrue(ChatMessageDeduplicator.duplicateIDs(in: messages).isEmpty) + } +} + +/// The rhythm complaint, measured on the real views rather than argued about. +@MainActor +final class ChatTranscriptRowRhythmTests: XCTestCase { + private static let width: CGFloat = 520 + + private func message( + _ id: String, _ text: String, sender: ChatSender = .ai, + blocks: [ChatContentBlock] = [] + ) -> ChatMessage { + ChatMessage( + id: id, text: text, createdAt: Date(timeIntervalSince1970: 1_700_000_000), sender: sender, + isStreaming: false, isSynced: true, contentBlocks: blocks) + } + + private func rowHeight(_ message: ChatMessage) -> CGFloat { + NSHostingView( + rootView: ChatBubble(message: message, app: nil, showsOmiMark: true, onRate: { _ in }) + .frame(width: Self.width) + ).fittingSize.height + } + + /// **The complaint, in numbers.** Two consecutive one-line answers sat roughly + /// 100 device pixels apart: a 28 pt reserved hover band *plus* a full 16 pt + /// inter-exchange gap, on top of a row that reserved 32 pt for a mark it did + /// not need. The band is separation; the gap must not be charged twice. + func testTwoConsecutiveShortAnswersAreNotSeparatedByHalfALineOfNothing() { + let first = message("a0", "They arrive on Saturday.") + let second = message("a1", "The booking is confirmed.") + let gap = ChatTranscriptLayout.spacing(from: first, to: second) + let deadSpaceUnderTheRow = ChatBubbleMetadataControlMetrics.bandHeight + gap + + XCTAssertLessThanOrEqual( + deadSpaceUnderTheRow, 32, + "a settled answer must not float in more than 64 device pixels of nothing") + XCTAssertEqual(gap, ChatTranscriptLayout.afterMetadataBandRowSpacing) + } + + /// A card-only row has nothing to copy or rate and stamps its own time, so it + /// reserves no band — which is what left the memory card floating. + func testACardOnlyRowReservesNoMetadataBand() { + let card = message( + "card", "", blocks: [.discoveryCard(id: "b", title: "Memory", summary: "summary", fullText: "full")]) + XCTAssertEqual(ChatBubbleMetadataBand.of(card), .hidden) + XCTAssertEqual( + ChatTranscriptLayout.spacing(from: card, to: message("a", "next")), + ChatTranscriptLayout.regularRowSpacing, + "with no band of its own the card takes the ordinary exchange gap") + } + + /// A row with nothing at all still keeps its timestamp — that stamp is all it + /// has to say it happened. + func testAnEmptyCompletedRowStillKeepsItsTimestamp() { + XCTAssertEqual(ChatBubbleMetadataBand.of(message("empty", "")), .timestampOnly) + } + + /// The 32 pt reservation exists for an empty streaming reply, whose own + /// content has no height. On a settled row it only centred short content in a + /// box taller than itself. + func testASettledShortRowDoesNotReserveTheStreamingMarkHeight() { + let settled = message("a0", "Yes.") + let streaming = ChatMessage( + id: "a1", text: "", sender: .ai, isStreaming: true, isSynced: false) + + XCTAssertGreaterThanOrEqual(rowHeight(streaming), ChatOmiMarkPlacement.reservedRowHeight - 1) + XCTAssertLessThan( + rowHeight(settled), + ChatOmiMarkPlacement.reservedRowHeight + ChatBubbleMetadataControlMetrics.bandHeight, + "a one-line answer plus its band must not be padded out to the mark's box plus its band") + } +} diff --git a/desktop/macos/Desktop/Tests/DesktopChatDriftGuardTests.swift b/desktop/macos/Desktop/Tests/DesktopChatDriftGuardTests.swift index d2793660e29..948962bf434 100644 --- a/desktop/macos/Desktop/Tests/DesktopChatDriftGuardTests.swift +++ b/desktop/macos/Desktop/Tests/DesktopChatDriftGuardTests.swift @@ -73,28 +73,40 @@ final class DesktopChatDriftGuardTests: XCTestCase { } XCTAssertEqual(ChatTranscriptLayout.topAdjustment(at: 0, in: messages), 0) - // assistant → user starts a new exchange and takes the full gap. - XCTAssertEqual(gap(1), ChatTranscriptLayout.regularRowSpacing) + // assistant → user: the assistant row already reserves its own 28 pt band, + // which is the separation. Adding the exchange gap on top charged twice. + XCTAssertEqual(gap(1), ChatTranscriptLayout.afterMetadataBandRowSpacing) XCTAssertEqual(gap(2), ChatTranscriptLayout.consecutiveUserRowSpacing) // user → assistant is one exchange, so it is the tight gap. XCTAssertEqual(gap(3), ChatTranscriptLayout.replySpacing) - XCTAssertEqual(gap(4), ChatTranscriptLayout.regularRowSpacing) + XCTAssertEqual(gap(4), ChatTranscriptLayout.afterMetadataBandRowSpacing) XCTAssertLessThan( ChatTranscriptLayout.replySpacing, ChatTranscriptLayout.regularRowSpacing, "a reply must bind to its question more tightly than to the next exchange") + XCTAssertLessThan( + ChatTranscriptLayout.replySpacing, + ChatBubbleMetadataControlMetrics.bandHeight + + ChatTranscriptLayout.afterMetadataBandRowSpacing, + "the exchange boundary is still the widest gap once the band is counted") } - /// The gap after an assistant row is also the room its hover-revealed metadata - /// band draws into — that band is zero-height at rest, so the space has to come - /// from somewhere, and it comes from here. - func testTheGapAfterAnAssistantRowStaysWideEnoughForItsHoverBand() { - let messages = [ - ChatMessage(id: "a0", text: "Answer", sender: .ai), - ChatMessage(id: "a1", text: "Also this", sender: .ai), - ] + /// The band is real, reserved height under the row, so the *stack* must not + /// also pay for it — that double charge is what left two one-line answers + /// roughly 100 device pixels apart. + func testTheGapAfterARowThatReservesItsBandIsNotChargedTwice() { + let banded = ChatMessage(id: "a0", text: "Answer", sender: .ai) + let next = ChatMessage(id: "a1", text: "Also this", sender: .ai) + XCTAssertNotEqual(ChatBubbleMetadataBand.of(banded), .hidden) + XCTAssertEqual( + ChatTranscriptLayout.spacing(from: banded, to: next), + ChatTranscriptLayout.afterMetadataBandRowSpacing) + + // A row with no band of its own still takes the ordinary exchange gap. + let streaming = ChatMessage(id: "a2", text: "Thinking", sender: .ai, isStreaming: true) + XCTAssertEqual(ChatBubbleMetadataBand.of(streaming), .hidden) XCTAssertEqual( - ChatTranscriptLayout.spacing(from: messages[0], to: messages[1]), + ChatTranscriptLayout.spacing(from: streaming, to: next), ChatTranscriptLayout.regularRowSpacing) } From 7e88013b7ab0f058195cf90973e63e2fc4700ce6 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 02:56:52 -0400 Subject: [PATCH 10/29] feat(app): decode chat content blocks into typed models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile stored `content_blocks` as raw maps and, since #12015, hid any message whose blocks were only desktop chat-first chrome (goal/task/ question) because there was no renderer for them. Both halves are now wrong: the components are coming, so the schema needs a typed projection and the hide filter has to go. Add `ChatContentBlock`, a sealed model mirroring the canonical schema in `desktop/macos/agent/src/runtime/types.ts` and the Swift codec's required-field rules, decoding both the camelCase (desktop/agent) and snake_case (chat-first spec) dialects. Malformed blocks are dropped; unknown types become `UnknownContentBlock` so the message keeps its synthesized fallback text instead of losing content. The raw list stays authoritative on the wire — `toJson` is unchanged. Delete `hideFromMobileChat` / `visibleOnMobile` and their four call sites in MessageProvider, replacing the "is this body only the fallback dump?" test with `textIsStructuredFallback`, which the renderer uses to decide whether components replace the body or sit beside it. Co-Authored-By: Claude Fable 5.1 --- .../backend/schema/chat_content_block.dart | 457 ++++++++++++++++++ app/lib/backend/schema/message.dart | 38 +- app/lib/providers/message_provider.dart | 9 +- .../unit/chat_content_block_decode_test.dart | 205 ++++++++ .../server_message_content_blocks_test.dart | 57 ++- 5 files changed, 710 insertions(+), 56 deletions(-) create mode 100644 app/lib/backend/schema/chat_content_block.dart create mode 100644 app/test/unit/chat_content_block_decode_test.dart diff --git a/app/lib/backend/schema/chat_content_block.dart b/app/lib/backend/schema/chat_content_block.dart new file mode 100644 index 00000000000..7e2c96177da --- /dev/null +++ b/app/lib/backend/schema/chat_content_block.dart @@ -0,0 +1,457 @@ +/// Typed projection of the chat `content_blocks` wire array. +/// +/// The canonical schema is owned by the macOS agent runtime +/// (`desktop/macos/agent/src/runtime/types.ts` `ConversationContentBlock`) and +/// mirrored by the Swift codec (`ChatContentBlockCodec`). This decoder follows +/// the same required-field rules so a block that macOS drops is dropped here +/// too, and vice versa. +/// +/// Two wire dialects reach mobile: camelCase (desktop/agent, stored verbatim by +/// the backend) and snake_case (validated chat-first specs). Every field is read +/// in both dialects. A malformed block decodes to `null` and is dropped; an +/// unrecognised `type` decodes to [UnknownContentBlock] so the message keeps its +/// text fallback instead of losing content. +library; + +sealed class ChatContentBlock { + const ChatContentBlock({required this.id}); + + final String id; + + /// Canonical wire type name (camelCase), used for widget keys. + String get type; + + /// Decodes a raw wire array, dropping malformed entries. + static List decodeList(List> raw) { + final blocks = []; + for (final entry in raw) { + final block = tryDecode(entry); + if (block != null) blocks.add(block); + } + return List.unmodifiable(blocks); + } + + static ChatContentBlock? tryDecode(Map raw) { + final type = _string(raw, 'type'); + final id = _string(raw, 'id'); + if (type == null || id == null) return null; + + switch (type) { + case 'text': + return TextContentBlock(id: id, text: _string(raw, 'text') ?? ''); + case 'toolCall': + case 'tool_call': + final name = _string(raw, 'name'); + if (name == null) return null; + return ToolCallContentBlock( + id: id, + name: name, + status: _string(raw, 'status') ?? 'completed', + toolUseId: _string(raw, 'toolUseId', 'tool_use_id'), + inputSummary: _string(raw, 'inputSummary', 'input_summary'), + inputDetails: _string(raw, 'inputDetails', 'input_details'), + output: _string(raw, 'output'), + ); + case 'thinking': + return ThinkingContentBlock(id: id, text: _string(raw, 'text') ?? ''); + case 'discoveryCard': + case 'discovery_card': + return DiscoveryCardContentBlock( + id: id, + title: _string(raw, 'title') ?? '', + summary: _string(raw, 'summary') ?? '', + fullText: _string(raw, 'fullText', 'full_text') ?? '', + ); + case 'questionCard': + case 'question_card': + return _decodeQuestionCard(raw, id); + case 'taskCard': + case 'task_card': + final taskId = _string(raw, 'taskId', 'task_id'); + if (taskId == null) return null; + return TaskCardContentBlock(id: id, taskId: taskId); + case 'goalLink': + case 'goal_link': + final goalId = _string(raw, 'goalId', 'goal_id'); + final summary = _string(raw, 'summary'); + if (goalId == null || summary == null) return null; + return GoalLinkContentBlock(id: id, goalId: goalId, summary: summary); + case 'captureLink': + case 'capture_link': + final conversationId = _string(raw, 'conversationId', 'conversation_id'); + final summary = _string(raw, 'summary'); + if (conversationId == null || summary == null) return null; + return CaptureLinkContentBlock( + id: id, + conversationId: conversationId, + summary: summary, + momentTimestampMs: _int(raw, 'momentTimestampMs', 'moment_timestamp_ms'), + ); + case 'conversationLink': + case 'conversation_link': + final conversationId = _string(raw, 'conversationId', 'conversation_id'); + final summary = _string(raw, 'summary'); + if (conversationId == null || summary == null) return null; + return ConversationLinkContentBlock( + id: id, + conversationId: conversationId, + summary: summary, + recommendedActionItems: _decodeRecommendedActionItems( + raw['recommendedActionItems'] ?? raw['recommended_action_items'], + ), + ); + case 'memoryLink': + case 'memory_link': + final memoryId = _string(raw, 'memoryId', 'memory_id'); + final summary = _string(raw, 'summary'); + if (memoryId == null || summary == null) return null; + return MemoryLinkContentBlock(id: id, memoryId: memoryId, summary: summary); + case 'citation': + final ordinal = _int(raw, 'ordinal'); + final kind = _string(raw, 'kind'); + final sourceId = _string(raw, 'sourceId', 'source_id'); + if (ordinal == null || kind == null || sourceId == null) return null; + return CitationContentBlock( + id: id, + ordinal: ordinal, + kind: kind, + sourceId: sourceId, + title: _string(raw, 'title'), + preview: _string(raw, 'preview'), + ); + case 'agentSpawn': + case 'agent_spawn': + final sessionId = _string(raw, 'sessionId', 'session_id'); + final runId = _string(raw, 'runId', 'run_id'); + if (sessionId == null || runId == null) return null; + return AgentSpawnContentBlock( + id: id, + sessionId: sessionId, + runId: runId, + pillId: _string(raw, 'pillId', 'pill_id'), + title: _string(raw, 'title') ?? '', + objective: _string(raw, 'objective') ?? '', + ); + case 'agentCompletion': + case 'agent_completion': + return AgentCompletionContentBlock( + id: id, + sessionId: _string(raw, 'sessionId', 'session_id'), + runId: _string(raw, 'runId', 'run_id'), + pillId: _string(raw, 'pillId', 'pill_id'), + title: _string(raw, 'title') ?? '', + output: _string(raw, 'output') ?? '', + status: _string(raw, 'status') ?? 'completed', + ); + default: + return UnknownContentBlock(id: id, type: type, raw: Map.unmodifiable(raw)); + } + } + + static ChatContentBlock? _decodeQuestionCard(Map raw, String id) { + final questionId = _string(raw, 'questionId', 'question_id'); + final text = _string(raw, 'text'); + final subject = raw['subject']; + if (questionId == null || text == null || subject is! Map) return null; + final subjectMap = Map.from(subject); + final subjectKind = _string(subjectMap, 'kind'); + final subjectId = _string(subjectMap, 'id'); + if (subjectKind == null || subjectId == null) return null; + + final rawOptions = raw['options']; + if (rawOptions is! List) return null; + final options = []; + for (final entry in rawOptions) { + if (entry is! Map) continue; + final option = Map.from(entry); + final optionId = _string(option, 'optionId', 'option_id'); + final label = _string(option, 'label'); + if (optionId == null || label == null) continue; + options.add( + QuestionCardOption( + optionId: optionId, + label: label, + preparedAnswer: _string(option, 'preparedAnswer', 'prepared_answer') ?? label, + isDeferral: option['defer'] == true, + ), + ); + } + if (options.isEmpty) return null; + + return QuestionCardContentBlock( + id: id, + questionId: questionId, + text: text, + subjectKind: subjectKind, + subjectId: subjectId, + options: List.unmodifiable(options), + selectedOptionId: _string(raw, 'selectedOptionId', 'selected_option_id'), + ); + } + + static List _decodeRecommendedActionItems(Object? value) { + if (value is! List) return const []; + final items = []; + for (final entry in value) { + if (entry is! Map) continue; + final item = Map.from(entry); + final description = _string(item, 'description'); + if (description == null) continue; + items.add( + ConversationLinkActionItem( + description: description, + taskId: _string(item, 'taskId', 'task_id'), + ), + ); + } + return List.unmodifiable(items); + } + + static String? _string(Map raw, String camel, [String? snake]) { + final value = raw[camel] ?? (snake == null ? null : raw[snake]); + if (value is! String) return null; + return value.trim().isEmpty ? null : value; + } + + static int? _int(Map raw, String camel, [String? snake]) { + final value = raw[camel] ?? (snake == null ? null : raw[snake]); + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } +} + +class TextContentBlock extends ChatContentBlock { + const TextContentBlock({required super.id, required this.text}); + + final String text; + + @override + String get type => 'text'; +} + +class ToolCallContentBlock extends ChatContentBlock { + const ToolCallContentBlock({ + required super.id, + required this.name, + required this.status, + this.toolUseId, + this.inputSummary, + this.inputDetails, + this.output, + }); + + final String name; + final String status; + final String? toolUseId; + final String? inputSummary; + final String? inputDetails; + final String? output; + + @override + String get type => 'toolCall'; +} + +class ThinkingContentBlock extends ChatContentBlock { + const ThinkingContentBlock({required super.id, required this.text}); + + final String text; + + @override + String get type => 'thinking'; +} + +class DiscoveryCardContentBlock extends ChatContentBlock { + const DiscoveryCardContentBlock({ + required super.id, + required this.title, + required this.summary, + required this.fullText, + }); + + final String title; + final String summary; + final String fullText; + + @override + String get type => 'discoveryCard'; +} + +class QuestionCardOption { + const QuestionCardOption({ + required this.optionId, + required this.label, + required this.preparedAnswer, + this.isDeferral = false, + }); + + final String optionId; + final String label; + final String preparedAnswer; + final bool isDeferral; +} + +class QuestionCardContentBlock extends ChatContentBlock { + const QuestionCardContentBlock({ + required super.id, + required this.questionId, + required this.text, + required this.subjectKind, + required this.subjectId, + required this.options, + this.selectedOptionId, + }); + + final String questionId; + final String text; + final String subjectKind; + final String subjectId; + final List options; + final String? selectedOptionId; + + @override + String get type => 'questionCard'; +} + +class TaskCardContentBlock extends ChatContentBlock { + const TaskCardContentBlock({required super.id, required this.taskId}); + + final String taskId; + + @override + String get type => 'taskCard'; +} + +class GoalLinkContentBlock extends ChatContentBlock { + const GoalLinkContentBlock({required super.id, required this.goalId, required this.summary}); + + final String goalId; + final String summary; + + @override + String get type => 'goalLink'; +} + +class CaptureLinkContentBlock extends ChatContentBlock { + const CaptureLinkContentBlock({ + required super.id, + required this.conversationId, + required this.summary, + this.momentTimestampMs, + }); + + final String conversationId; + final String summary; + final int? momentTimestampMs; + + @override + String get type => 'captureLink'; +} + +class ConversationLinkActionItem { + const ConversationLinkActionItem({required this.description, this.taskId}); + + final String description; + final String? taskId; +} + +class ConversationLinkContentBlock extends ChatContentBlock { + const ConversationLinkContentBlock({ + required super.id, + required this.conversationId, + required this.summary, + this.recommendedActionItems = const [], + }); + + final String conversationId; + final String summary; + final List recommendedActionItems; + + @override + String get type => 'conversationLink'; +} + +class MemoryLinkContentBlock extends ChatContentBlock { + const MemoryLinkContentBlock({required super.id, required this.memoryId, required this.summary}); + + final String memoryId; + final String summary; + + @override + String get type => 'memoryLink'; +} + +class CitationContentBlock extends ChatContentBlock { + const CitationContentBlock({ + required super.id, + required this.ordinal, + required this.kind, + required this.sourceId, + this.title, + this.preview, + }); + + final int ordinal; + final String kind; + final String sourceId; + final String? title; + final String? preview; + + @override + String get type => 'citation'; +} + +class AgentSpawnContentBlock extends ChatContentBlock { + const AgentSpawnContentBlock({ + required super.id, + required this.sessionId, + required this.runId, + this.pillId, + this.title = '', + this.objective = '', + }); + + final String sessionId; + final String runId; + final String? pillId; + final String title; + final String objective; + + @override + String get type => 'agentSpawn'; +} + +class AgentCompletionContentBlock extends ChatContentBlock { + const AgentCompletionContentBlock({ + required super.id, + this.sessionId, + this.runId, + this.pillId, + this.title = '', + this.output = '', + this.status = 'completed', + }); + + final String? sessionId; + final String? runId; + final String? pillId; + final String title; + final String output; + final String status; + + @override + String get type => 'agentCompletion'; +} + +/// A block type this client does not know. Kept so the message keeps rendering +/// its synthesized fallback text instead of silently losing content. +class UnknownContentBlock extends ChatContentBlock { + const UnknownContentBlock({required super.id, required String type, required this.raw}) : _type = type; + + final String _type; + final Map raw; + + @override + String get type => _type; +} diff --git a/app/lib/backend/schema/message.dart b/app/lib/backend/schema/message.dart index 80778026e50..f614427e974 100644 --- a/app/lib/backend/schema/message.dart +++ b/app/lib/backend/schema/message.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:collection/collection.dart'; +import 'package:omi/backend/schema/chat_content_block.dart'; import 'package:omi/backend/schema/gen/messages_wire.g.dart' as wire; import 'package:omi/models/chat_evidence_reference.dart'; import 'package:uuid/uuid.dart'; @@ -435,38 +436,25 @@ class ServerMessage { return value.whereType().map((item) => Map.from(item)).toList(growable: false); } - static const _desktopChatChromeTypes = { - 'goalLink', - 'goal_link', - 'taskCard', - 'task_card', - 'questionCard', - 'question_card', - }; - - /// Desktop Chat-first cards (goal/task/question) are interactive shell chrome. - /// Mobile has no renderer for them, so fallback dumps like - /// `Goal - … Task Task Task` should not appear in the phone timeline. - bool get hideFromMobileChat { - if (sender != MessageSender.ai) return false; - if (type != MessageType.text) return false; - if (files.isNotEmpty || memories.isNotEmpty) return false; + List? _typedContentBlocks; + + /// Typed projection of [contentBlocks], decoded once per message. + /// + /// The raw list stays authoritative on the wire (see [toJson]); this is the + /// renderable view used by the chat content-block widgets. + List get typedContentBlocks => _typedContentBlocks ??= ChatContentBlock.decodeList(contentBlocks); + + /// True when [text] carries nothing beyond the fallback text synthesized from + /// [contentBlocks]. The interactive blocks then replace the body instead of + /// repeating it. + bool get textIsStructuredFallback { if (contentBlocks.isEmpty) return false; - if (!_blocksAreDesktopChatChromeOnly(contentBlocks)) return false; final fallback = _structuredFallbackText(contentBlocks); if (fallback.isEmpty) return false; final body = text.trim(); return body.isEmpty || _normalizeWhitespace(body) == _normalizeWhitespace(fallback); } - static List visibleOnMobile(Iterable messages) { - return messages.where((message) => !message.hideFromMobileChat).toList(); - } - - static bool _blocksAreDesktopChatChromeOnly(List> blocks) { - return blocks.every((block) => _desktopChatChromeTypes.contains(block['type'])); - } - static String _normalizeWhitespace(String value) { return value.split(RegExp(r'\s+')).where((part) => part.isNotEmpty).join(' '); } diff --git a/app/lib/providers/message_provider.dart b/app/lib/providers/message_provider.dart index 0a6688f78ca..6514b48d6f4 100644 --- a/app/lib/providers/message_provider.dart +++ b/app/lib/providers/message_provider.dart @@ -362,7 +362,7 @@ class MessageProvider extends ChangeNotifier { } messages = await getMessagesFromServer(dropdownSelected: dropdownSelected); if (messages.isEmpty) { - messages = ServerMessage.visibleOnMobile(SharedPreferencesUtil().cachedMessages); + messages = List.from(SharedPreferencesUtil().cachedMessages); } else { SharedPreferencesUtil().cachedMessages = messages; setHasCachedMessages(true); @@ -375,7 +375,7 @@ class MessageProvider extends ChangeNotifier { void setMessagesFromCache() { if (SharedPreferencesUtil().cachedMessages.isNotEmpty) { setHasCachedMessages(true); - messages = ServerMessage.visibleOnMobile(SharedPreferencesUtil().cachedMessages); + messages = List.from(SharedPreferencesUtil().cachedMessages); messages.sort((a, b) => a.createdAt.compareTo(b.createdAt)); } notifyListeners(); @@ -393,7 +393,7 @@ class MessageProvider extends ChangeNotifier { firstTimeLoadingText = l10n?.msgLearningMemories ?? 'Learning from your memories...'; notifyListeners(); } - messages = ServerMessage.visibleOnMobile(mes); + messages = List.from(mes); messages.sort((a, b) => a.createdAt.compareTo(b.createdAt)); setLoadingMessages(false); notifyListeners(); @@ -411,7 +411,7 @@ class MessageProvider extends ChangeNotifier { Future clearChat() async { setClearingChat(true); var mes = await clearChatServer(appId: appProvider?.selectedChatAppId); - messages = ServerMessage.visibleOnMobile(mes); + messages = List.from(mes); messages.sort((a, b) => a.createdAt.compareTo(b.createdAt)); setClearingChat(false); notifyListeners(); @@ -450,7 +450,6 @@ class MessageProvider extends ChangeNotifier { } void addMessage(ServerMessage message) { - if (message.hideFromMobileChat) return; if (messages.firstWhereOrNull((m) => m.id == message.id) != null) { return; } diff --git a/app/test/unit/chat_content_block_decode_test.dart b/app/test/unit/chat_content_block_decode_test.dart new file mode 100644 index 00000000000..287add9f033 --- /dev/null +++ b/app/test/unit/chat_content_block_decode_test.dart @@ -0,0 +1,205 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:omi/backend/schema/chat_content_block.dart'; + +void main() { + ChatContentBlock? decode(Map raw) => ChatContentBlock.tryDecode(raw); + + group('camelCase wire (desktop/agent dialect)', () { + test('decodes every interactable block type', () { + final blocks = ChatContentBlock.decodeList([ + {'type': 'taskCard', 'id': 'b1', 'taskId': 'task-1'}, + {'type': 'goalLink', 'id': 'b2', 'goalId': 'goal-1', 'summary': 'Ship the release'}, + { + 'type': 'captureLink', + 'id': 'b3', + 'conversationId': 'conversation-1', + 'summary': 'Standup', + 'momentTimestampMs': 1234, + }, + { + 'type': 'conversationLink', + 'id': 'b4', + 'conversationId': 'conversation-2', + 'summary': 'Weekly planning', + 'recommendedActionItems': [ + {'description': 'Draft the plan', 'taskId': 'task-9'}, + {'description': ' '}, + ], + }, + {'type': 'memoryLink', 'id': 'b5', 'memoryId': 'memory-1', 'summary': 'Prefers dark mode'}, + { + 'type': 'questionCard', + 'id': 'b6', + 'questionId': 'question-1', + 'text': 'What next?', + 'subject': {'kind': 'goal', 'id': 'goal-1'}, + 'options': [ + {'optionId': 'ship', 'label': 'Ship it', 'preparedAnswer': 'Ship it today'}, + {'optionId': 'later', 'label': 'Later', 'preparedAnswer': 'Ask me later', 'defer': true}, + ], + 'selectedOptionId': 'ship', + }, + ]); + + expect(blocks, hasLength(6)); + expect((blocks[0] as TaskCardContentBlock).taskId, 'task-1'); + expect((blocks[1] as GoalLinkContentBlock).goalId, 'goal-1'); + + final capture = blocks[2] as CaptureLinkContentBlock; + expect(capture.conversationId, 'conversation-1'); + expect(capture.momentTimestampMs, 1234); + + final conversation = blocks[3] as ConversationLinkContentBlock; + expect(conversation.recommendedActionItems, hasLength(1)); + expect(conversation.recommendedActionItems.single.taskId, 'task-9'); + + expect((blocks[4] as MemoryLinkContentBlock).memoryId, 'memory-1'); + + final question = blocks[5] as QuestionCardContentBlock; + expect(question.subjectKind, 'goal'); + expect(question.selectedOptionId, 'ship'); + expect(question.options, hasLength(2)); + expect(question.options.last.isDeferral, isTrue); + expect(question.options.last.preparedAnswer, 'Ask me later'); + }); + + test('decodes the non-interactable types without dropping them', () { + expect(decode({'type': 'text', 'id': 'b1', 'text': 'hi'}), isA()); + expect(decode({'type': 'thinking', 'id': 'b2', 'text': 'hmm'}), isA()); + expect( + decode({'type': 'toolCall', 'id': 'b3', 'name': 'search', 'status': 'running'}), + isA(), + ); + expect( + decode({'type': 'discoveryCard', 'id': 'b4', 'title': 'T', 'summary': 'S', 'fullText': 'F'}), + isA(), + ); + expect( + decode({'type': 'citation', 'id': 'b5', 'ordinal': 1, 'kind': 'conversation', 'sourceId': 'c1'}), + isA(), + ); + expect( + decode({'type': 'agentSpawn', 'id': 'b6', 'sessionId': 's1', 'runId': 'r1'}), + isA(), + ); + expect(decode({'type': 'agentCompletion', 'id': 'b7'}), isA()); + }); + }); + + group('snake_case wire (validated chat-first specs)', () { + test('reads every renamed field', () { + final blocks = ChatContentBlock.decodeList([ + {'type': 'task_card', 'id': 'b1', 'task_id': 'task-1'}, + {'type': 'goal_link', 'id': 'b2', 'goal_id': 'goal-1', 'summary': 'Ship'}, + { + 'type': 'capture_link', + 'id': 'b3', + 'conversation_id': 'conversation-1', + 'summary': 'Standup', + 'moment_timestamp_ms': 99, + }, + { + 'type': 'conversation_link', + 'id': 'b4', + 'conversation_id': 'conversation-2', + 'summary': 'Planning', + 'recommended_action_items': [ + {'description': 'Draft', 'task_id': 'task-9'}, + ], + }, + {'type': 'memory_link', 'id': 'b5', 'memory_id': 'memory-1', 'summary': 'Dark mode'}, + { + 'type': 'question_card', + 'id': 'b6', + 'question_id': 'question-1', + 'text': 'What next?', + 'subject': {'kind': 'task', 'id': 'task-1'}, + 'options': [ + {'option_id': 'ship', 'label': 'Ship it', 'prepared_answer': 'Ship it today'}, + ], + 'selected_option_id': 'ship', + }, + ]); + + expect(blocks, hasLength(6)); + expect((blocks[0] as TaskCardContentBlock).taskId, 'task-1'); + expect((blocks[1] as GoalLinkContentBlock).goalId, 'goal-1'); + expect((blocks[2] as CaptureLinkContentBlock).momentTimestampMs, 99); + expect((blocks[3] as ConversationLinkContentBlock).recommendedActionItems.single.taskId, 'task-9'); + expect((blocks[4] as MemoryLinkContentBlock).memoryId, 'memory-1'); + + final question = blocks[5] as QuestionCardContentBlock; + expect(question.subjectId, 'task-1'); + expect(question.selectedOptionId, 'ship'); + expect(question.options.single.preparedAnswer, 'Ship it today'); + }); + }); + + group('required fields', () { + test('drops blocks that the macOS codec would also drop', () { + expect(decode({'type': 'taskCard', 'id': 'b1'}), isNull); + expect(decode({'type': 'taskCard', 'taskId': 'task-1'}), isNull, reason: 'missing id'); + expect(decode({'id': 'b1', 'taskId': 'task-1'}), isNull, reason: 'missing type'); + expect(decode({'type': 'goalLink', 'id': 'b1', 'goalId': 'goal-1'}), isNull, reason: 'missing summary'); + expect(decode({'type': 'goalLink', 'id': 'b1', 'summary': 'Ship'}), isNull, reason: 'missing goalId'); + expect(decode({'type': 'memoryLink', 'id': 'b1', 'summary': 'Ship'}), isNull); + expect(decode({'type': 'conversationLink', 'id': 'b1', 'summary': 'Ship'}), isNull); + expect(decode({'type': 'toolCall', 'id': 'b1', 'status': 'running'}), isNull, reason: 'missing name'); + expect( + decode({ + 'type': 'questionCard', + 'id': 'b1', + 'questionId': 'q1', + 'text': 'What next?', + 'subject': {'kind': 'goal', 'id': 'goal-1'}, + 'options': >[], + }), + isNull, + reason: 'no usable options', + ); + expect( + decode({ + 'type': 'questionCard', + 'id': 'b1', + 'questionId': 'q1', + 'text': 'What next?', + 'options': [ + {'optionId': 'a', 'label': 'A', 'preparedAnswer': 'A'}, + ], + }), + isNull, + reason: 'missing subject', + ); + }); + + test('drops malformed entries but keeps the rest of the list', () { + final blocks = ChatContentBlock.decodeList([ + {'type': 'taskCard', 'id': 'b1'}, + {'type': 'taskCard', 'id': 'b2', 'taskId': 'task-2'}, + ]); + expect(blocks, hasLength(1)); + expect((blocks.single as TaskCardContentBlock).taskId, 'task-2'); + }); + + test('falls back to the option label when no prepared answer is sent', () { + final question = decode({ + 'type': 'questionCard', + 'id': 'b1', + 'questionId': 'q1', + 'text': 'What next?', + 'subject': {'kind': 'cold_start', 'id': 'seq-1'}, + 'options': [ + {'optionId': 'a', 'label': 'Ship it'}, + ], + })! as QuestionCardContentBlock; + expect(question.options.single.preparedAnswer, 'Ship it'); + }); + }); + + test('an unknown type becomes an unknown block instead of being dropped', () { + final block = decode({'type': 'somethingNew', 'id': 'b1', 'title': 'Future'}); + expect(block, isA()); + expect(block!.type, 'somethingNew'); + expect((block as UnknownContentBlock).raw['title'], 'Future'); + }); +} diff --git a/app/test/unit/server_message_content_blocks_test.dart b/app/test/unit/server_message_content_blocks_test.dart index 15434ed0524..cc7771e3228 100644 --- a/app/test/unit/server_message_content_blocks_test.dart +++ b/app/test/unit/server_message_content_blocks_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:omi/backend/schema/chat_content_block.dart'; import 'package:omi/backend/schema/message.dart'; void main() { @@ -71,12 +72,12 @@ void main() { ); expect(message.text, 'Meeting notes ready - Canonical title'); - expect(message.hideFromMobileChat, isFalse); + expect(message.textIsStructuredFallback, isFalse); }, ); test( - 'hides desktop goal and task chrome fallbacks from the mobile timeline', + 'keeps desktop goal and task chrome on the mobile timeline', () { final message = ServerMessage.fromJson( messageJson( @@ -84,45 +85,52 @@ void main() { contentBlocks: [ { 'type': 'goalLink', + 'id': 'block-goal', 'goalId': 'goal-1', 'summary': 'Make Omi Great Again', }, - {'type': 'taskCard', 'taskId': 'task-1'}, - {'type': 'taskCard', 'taskId': 'task-2'}, - {'type': 'taskCard', 'taskId': 'task-3'}, + {'type': 'taskCard', 'id': 'block-task-1', 'taskId': 'task-1'}, + {'type': 'taskCard', 'id': 'block-task-2', 'taskId': 'task-2'}, + {'type': 'taskCard', 'id': 'block-task-3', 'taskId': 'task-3'}, ], ), ); expect(message.text, 'Goal - Make Omi Great Again\nTask\nTask\nTask'); - expect(message.hideFromMobileChat, isTrue); - expect(ServerMessage.visibleOnMobile([message]), isEmpty); + // The body is nothing but the synthesized fallback, so the interactive + // components replace it instead of repeating it. + expect(message.textIsStructuredFallback, isTrue); + expect(message.typedContentBlocks, hasLength(4)); + expect(message.typedContentBlocks.first, isA()); + expect(message.typedContentBlocks.last, isA()); }, ); - test('hides stored one-line goal/task fallback dumps', () { + test('keeps stored one-line goal/task fallback dumps renderable', () { final message = ServerMessage.fromJson( messageJson( text: 'Goal - Make Omi Great Again Task Task Task', contentBlocks: [ - {'type': 'goalLink', 'summary': 'Make Omi Great Again'}, - {'type': 'taskCard', 'taskId': 'task-1'}, - {'type': 'taskCard', 'taskId': 'task-2'}, - {'type': 'taskCard', 'taskId': 'task-3'}, + {'type': 'goalLink', 'id': 'block-goal', 'goalId': 'goal-1', 'summary': 'Make Omi Great Again'}, + {'type': 'taskCard', 'id': 'block-task-1', 'taskId': 'task-1'}, + {'type': 'taskCard', 'id': 'block-task-2', 'taskId': 'task-2'}, + {'type': 'taskCard', 'id': 'block-task-3', 'taskId': 'task-3'}, ], ), ); - expect(message.hideFromMobileChat, isTrue); + expect(message.textIsStructuredFallback, isTrue); + expect(message.typedContentBlocks, hasLength(4)); }); - test('hides question cards that have no other content', () { + test('keeps question cards that have no other content', () { final message = ServerMessage.fromJson( messageJson( text: 'What should we focus on?', contentBlocks: [ { 'type': 'questionCard', + 'id': 'block-question', 'questionId': 'question-1', 'text': 'What should we focus on?', 'subject': {'kind': 'goal', 'id': 'goal-1'}, @@ -134,7 +142,8 @@ void main() { ), ); - expect(message.hideFromMobileChat, isTrue); + expect(message.textIsStructuredFallback, isTrue); + expect(message.typedContentBlocks.single, isA()); }); test('keeps meeting-note cards and mixed useful blocks', () { @@ -144,6 +153,7 @@ void main() { contentBlocks: [ { 'type': 'conversationLink', + 'id': 'block-conversation', 'conversationId': 'conversation-1', 'summary': 'Founders explore AI memory', }, @@ -154,8 +164,8 @@ void main() { messageJson( text: 'I started tracking this.', contentBlocks: [ - {'type': 'text', 'text': 'I started tracking this.'}, - {'type': 'goalLink', 'summary': 'Make Omi Great Again'}, + {'type': 'text', 'id': 'block-text', 'text': 'I started tracking this.'}, + {'type': 'goalLink', 'id': 'block-goal', 'goalId': 'goal-1', 'summary': 'Make Omi Great Again'}, ], ), ); @@ -163,19 +173,14 @@ void main() { messageJson( text: 'Here is a real reply about the weather.', contentBlocks: [ - {'type': 'goalLink', 'summary': 'Make Omi Great Again'}, + {'type': 'goalLink', 'id': 'block-goal', 'goalId': 'goal-1', 'summary': 'Make Omi Great Again'}, ], ), ); - expect(meeting.hideFromMobileChat, isFalse); - expect(mixed.hideFromMobileChat, isFalse); - expect(prose.hideFromMobileChat, isFalse); - expect(ServerMessage.visibleOnMobile([meeting, mixed, prose]), [ - meeting, - mixed, - prose, - ]); + expect(meeting.typedContentBlocks.single, isA()); + expect(mixed.typedContentBlocks, hasLength(2)); + expect(prose.textIsStructuredFallback, isFalse); }); test('decodes optional evidence envelope without changing the answer text', () { From 74776c8c5a01be1360e9516c5afb34714467d176 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 02:56:58 -0400 Subject: [PATCH 11/29] feat(app): add l10n keys for chat content-block components Ten new keys for the block eyebrows, destination actions, unavailable state, and the conversation link's recommended-steps header, translated into all 48 non-template locales. Co-Authored-By: Claude Fable 5.1 --- app/lib/l10n/app_ar.arb | 12 +++++- app/lib/l10n/app_be.arb | 12 +++++- app/lib/l10n/app_bg.arb | 12 +++++- app/lib/l10n/app_bn.arb | 12 +++++- app/lib/l10n/app_bs.arb | 12 +++++- app/lib/l10n/app_ca.arb | 12 +++++- app/lib/l10n/app_cs.arb | 12 +++++- app/lib/l10n/app_da.arb | 12 +++++- app/lib/l10n/app_de.arb | 12 +++++- app/lib/l10n/app_el.arb | 12 +++++- app/lib/l10n/app_en.arb | 40 +++++++++++++++++ app/lib/l10n/app_es.arb | 12 +++++- app/lib/l10n/app_et.arb | 12 +++++- app/lib/l10n/app_fa.arb | 12 +++++- app/lib/l10n/app_fi.arb | 12 +++++- app/lib/l10n/app_fr.arb | 12 +++++- app/lib/l10n/app_he.arb | 12 +++++- app/lib/l10n/app_hi.arb | 12 +++++- app/lib/l10n/app_hr.arb | 12 +++++- app/lib/l10n/app_hu.arb | 12 +++++- app/lib/l10n/app_id.arb | 12 +++++- app/lib/l10n/app_it.arb | 12 +++++- app/lib/l10n/app_ja.arb | 12 +++++- app/lib/l10n/app_kn.arb | 12 +++++- app/lib/l10n/app_ko.arb | 12 +++++- app/lib/l10n/app_localizations.dart | 60 ++++++++++++++++++++++++++ app/lib/l10n/app_localizations_ar.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_be.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_bg.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_bn.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_bs.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_ca.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_cs.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_da.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_de.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_el.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_en.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_es.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_et.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_fa.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_fi.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_fr.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_he.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_hi.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_hr.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_hu.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_id.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_it.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_ja.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_kn.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_ko.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_lt.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_lv.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_mk.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_mr.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_ms.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_nl.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_no.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_pl.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_pt.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_ro.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_ru.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_sk.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_sl.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_sr.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_sv.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_ta.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_te.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_th.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_tl.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_tr.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_uk.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_ur.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_vi.dart | 30 +++++++++++++ app/lib/l10n/app_localizations_zh.dart | 30 +++++++++++++ app/lib/l10n/app_lt.arb | 12 +++++- app/lib/l10n/app_lv.arb | 12 +++++- app/lib/l10n/app_mk.arb | 12 +++++- app/lib/l10n/app_mr.arb | 12 +++++- app/lib/l10n/app_ms.arb | 12 +++++- app/lib/l10n/app_nl.arb | 12 +++++- app/lib/l10n/app_no.arb | 12 +++++- app/lib/l10n/app_pl.arb | 12 +++++- app/lib/l10n/app_pt.arb | 12 +++++- app/lib/l10n/app_ro.arb | 12 +++++- app/lib/l10n/app_ru.arb | 12 +++++- app/lib/l10n/app_sk.arb | 12 +++++- app/lib/l10n/app_sl.arb | 12 +++++- app/lib/l10n/app_sr.arb | 12 +++++- app/lib/l10n/app_sv.arb | 12 +++++- app/lib/l10n/app_ta.arb | 12 +++++- app/lib/l10n/app_te.arb | 12 +++++- app/lib/l10n/app_th.arb | 12 +++++- app/lib/l10n/app_tl.arb | 12 +++++- app/lib/l10n/app_tr.arb | 12 +++++- app/lib/l10n/app_uk.arb | 12 +++++- app/lib/l10n/app_ur.arb | 12 +++++- app/lib/l10n/app_vi.arb | 12 +++++- app/lib/l10n/app_zh.arb | 12 +++++- 99 files changed, 2098 insertions(+), 48 deletions(-) diff --git a/app/lib/l10n/app_ar.arb b/app/lib/l10n/app_ar.arb index 336432ac18a..7dfdbc22102 100644 --- a/app/lib/l10n/app_ar.arb +++ b/app/lib/l10n/app_ar.arb @@ -3210,5 +3210,15 @@ "pendantRecordingSyncBlocked": "لا يزال Pendant يسجّل، لذا لا يمكن نقل الصوت المخزّن عليه. اضغط على زر Pendant لإيقاف التسجيل، ثم أعد المزامنة.", "pendantFullSyncBlocked": "ذاكرة Pendant ممتلئة وما زال في وضع التسجيل، لذا لا يمكن نقل الصوت المخزّن. اضغط على زر Pendant لإيقاف التسجيل، ثم أعد المزامنة.", "conversationsNotCapturedCount": "لم يتم التسجيل ({count})", - "transcriptionNoAudio": "النسخ لا يستلم الصوت" + "transcriptionNoAudio": "النسخ لا يستلم الصوت", + "chatBlockTask": "مهمة", + "chatBlockGoal": "هدف", + "chatBlockConversation": "محادثة", + "chatBlockMemory": "ذكرى", + "chatBlockQuestion": "سؤال", + "chatBlockOpenInGoals": "فتح في الأهداف", + "chatBlockOpenConversation": "فتح المحادثة", + "chatBlockOpenInMemories": "فتح في الذكريات", + "chatBlockUnavailable": "لم يعد متاحًا", + "chatBlockRecommendedNextSteps": "الخطوات التالية الموصى بها" } diff --git a/app/lib/l10n/app_be.arb b/app/lib/l10n/app_be.arb index 5309837797e..32ecc4cde06 100644 --- a/app/lib/l10n/app_be.arb +++ b/app/lib/l10n/app_be.arb @@ -10800,5 +10800,15 @@ "pendantRecordingSyncBlocked": "Pendant усё яшчэ запісвае, таму захаваны гук нельга перадаць. Націсніце кнопку Pendant, каб спыніць запіс, а потым сінхранізуйце зноў.", "pendantFullSyncBlocked": "Памяць Pendant запоўнена, і ён усё яшчэ ў рэжыме запісу, таму захаванае аўдыя нельга перадаць. Націсніце кнопку Pendant, каб спыніць запіс, а затым сінхранізуйце зноў.", "conversationsNotCapturedCount": "Не запісана ({count})", - "transcriptionNoAudio": "Транскрыпцыя не атрымлівае аўдыё" + "transcriptionNoAudio": "Транскрыпцыя не атрымлівае аўдыё", + "chatBlockTask": "Задача", + "chatBlockGoal": "Мэта", + "chatBlockConversation": "Размова", + "chatBlockMemory": "Успамін", + "chatBlockQuestion": "Пытанне", + "chatBlockOpenInGoals": "Адкрыць у мэтах", + "chatBlockOpenConversation": "Адкрыць размову", + "chatBlockOpenInMemories": "Адкрыць ва ўспамінах", + "chatBlockUnavailable": "Больш недаступна", + "chatBlockRecommendedNextSteps": "Рэкамендаваныя наступныя крокі" } diff --git a/app/lib/l10n/app_bg.arb b/app/lib/l10n/app_bg.arb index 3bf553f6a39..45d05496ed8 100644 --- a/app/lib/l10n/app_bg.arb +++ b/app/lib/l10n/app_bg.arb @@ -3212,5 +3212,15 @@ "pendantRecordingSyncBlocked": "Pendant все още записва, затова съхраненото аудио не може да бъде прехвърлено. Натиснете бутона на Pendant, за да спрете записа, и синхронизирайте отново.", "pendantFullSyncBlocked": "Паметта на Pendant е пълна и той все още е в режим на запис, затова съхраненото аудио не може да бъде прехвърлено. Натиснете бутона на Pendant, за да спрете записа, и след това синхронизирайте отново.", "conversationsNotCapturedCount": "Не е записано ({count})", - "transcriptionNoAudio": "Транскрипцията не получава аудио" + "transcriptionNoAudio": "Транскрипцията не получава аудио", + "chatBlockTask": "Задача", + "chatBlockGoal": "Цел", + "chatBlockConversation": "Разговор", + "chatBlockMemory": "Спомен", + "chatBlockQuestion": "Въпрос", + "chatBlockOpenInGoals": "Отваряне в „Цели“", + "chatBlockOpenConversation": "Отваряне на разговора", + "chatBlockOpenInMemories": "Отваряне в „Спомени“", + "chatBlockUnavailable": "Вече не е налично", + "chatBlockRecommendedNextSteps": "Препоръчани следващи стъпки" } diff --git a/app/lib/l10n/app_bn.arb b/app/lib/l10n/app_bn.arb index 3c68b612bcd..b6cf72d1cef 100644 --- a/app/lib/l10n/app_bn.arb +++ b/app/lib/l10n/app_bn.arb @@ -10800,5 +10800,15 @@ "pendantRecordingSyncBlocked": "Pendant এখনও রেকর্ড করছে, তাই এর সংরক্ষিত অডিও স্থানান্তর করা যাচ্ছে না। রেকর্ডিং বন্ধ করতে Pendant-এর বোতাম টিপুন, তারপর আবার সিঙ্ক করুন।", "pendantFullSyncBlocked": "Pendant-এর স্টোরেজ পূর্ণ এবং এটি এখনও রেকর্ডিং মোডে আছে, তাই সংরক্ষিত অডিও স্থানান্তর করা যাচ্ছে না। রেকর্ডিং বন্ধ করতে Pendant-এর বোতাম টিপুন, তারপর আবার সিঙ্ক করুন।", "conversationsNotCapturedCount": "রেকর্ড করা হয়নি ({count})", - "transcriptionNoAudio": "ট্রান্সক্রিপশন অডিও গ্রহণ করছে না" + "transcriptionNoAudio": "ট্রান্সক্রিপশন অডিও গ্রহণ করছে না", + "chatBlockTask": "কাজ", + "chatBlockGoal": "লক্ষ্য", + "chatBlockConversation": "কথোপকথন", + "chatBlockMemory": "স্মৃতি", + "chatBlockQuestion": "প্রশ্ন", + "chatBlockOpenInGoals": "লক্ষ্যে খুলুন", + "chatBlockOpenConversation": "কথোপকথন খুলুন", + "chatBlockOpenInMemories": "স্মৃতিতে খুলুন", + "chatBlockUnavailable": "আর উপলব্ধ নেই", + "chatBlockRecommendedNextSteps": "প্রস্তাবিত পরবর্তী পদক্ষেপ" } diff --git a/app/lib/l10n/app_bs.arb b/app/lib/l10n/app_bs.arb index a24ac562146..ca0b2349676 100644 --- a/app/lib/l10n/app_bs.arb +++ b/app/lib/l10n/app_bs.arb @@ -10800,5 +10800,15 @@ "pendantRecordingSyncBlocked": "Pendant još uvijek snima, pa se pohranjeni zvuk ne može prenijeti. Pritisnite dugme na Pendantu da zaustavite snimanje, zatim ponovo sinhronizujte.", "pendantFullSyncBlocked": "Memorija Pendanta je puna i još uvijek je u režimu snimanja, pa se pohranjeni zvuk ne može prenijeti. Pritisnite dugme na Pendantu da zaustavite snimanje, a zatim ponovo sinhronizujte.", "conversationsNotCapturedCount": "Nije snimljeno ({count})", - "transcriptionNoAudio": "Transkripcija ne prima audio" + "transcriptionNoAudio": "Transkripcija ne prima audio", + "chatBlockTask": "Zadatak", + "chatBlockGoal": "Cilj", + "chatBlockConversation": "Razgovor", + "chatBlockMemory": "Sjećanje", + "chatBlockQuestion": "Pitanje", + "chatBlockOpenInGoals": "Otvori u Ciljevima", + "chatBlockOpenConversation": "Otvori razgovor", + "chatBlockOpenInMemories": "Otvori u Sjećanjima", + "chatBlockUnavailable": "Više nije dostupno", + "chatBlockRecommendedNextSteps": "Preporučeni sljedeći koraci" } diff --git a/app/lib/l10n/app_ca.arb b/app/lib/l10n/app_ca.arb index 6b358ca9bf0..6cebd64dee0 100644 --- a/app/lib/l10n/app_ca.arb +++ b/app/lib/l10n/app_ca.arb @@ -3212,5 +3212,15 @@ "pendantRecordingSyncBlocked": "El Pendant encara està gravant, així que el seu àudio emmagatzemat no es pot transferir. Prem el botó del Pendant per aturar la gravació i torna a sincronitzar.", "pendantFullSyncBlocked": "L'emmagatzematge del Pendant és ple i encara està en mode de gravació, així que l'àudio desat no es pot transferir. Prem el botó del Pendant per aturar la gravació i torna a sincronitzar.", "conversationsNotCapturedCount": "No enregistrat ({count})", - "transcriptionNoAudio": "La transcripció no rep àudio" + "transcriptionNoAudio": "La transcripció no rep àudio", + "chatBlockTask": "Tasca", + "chatBlockGoal": "Objectiu", + "chatBlockConversation": "Conversa", + "chatBlockMemory": "Record", + "chatBlockQuestion": "Pregunta", + "chatBlockOpenInGoals": "Obre a Objectius", + "chatBlockOpenConversation": "Obre la conversa", + "chatBlockOpenInMemories": "Obre a Records", + "chatBlockUnavailable": "Ja no està disponible", + "chatBlockRecommendedNextSteps": "Propers passos recomanats" } diff --git a/app/lib/l10n/app_cs.arb b/app/lib/l10n/app_cs.arb index f1ad55fa7b7..e8e14e3ce4f 100644 --- a/app/lib/l10n/app_cs.arb +++ b/app/lib/l10n/app_cs.arb @@ -3212,5 +3212,15 @@ "pendantRecordingSyncBlocked": "Pendant stále nahrává, takže uložený zvuk nelze přenést. Stisknutím tlačítka na Pendantu nahrávání zastavte a poté synchronizujte znovu.", "pendantFullSyncBlocked": "Úložiště Pendantu je plné a stále je v režimu nahrávání, takže uložený zvuk nelze přenést. Stisknutím tlačítka na Pendantu zastavte nahrávání a poté znovu synchronizujte.", "conversationsNotCapturedCount": "Nezaznamenáno ({count})", - "transcriptionNoAudio": "Transkripce nepřijímá zvuk" + "transcriptionNoAudio": "Transkripce nepřijímá zvuk", + "chatBlockTask": "Úkol", + "chatBlockGoal": "Cíl", + "chatBlockConversation": "Konverzace", + "chatBlockMemory": "Vzpomínka", + "chatBlockQuestion": "Otázka", + "chatBlockOpenInGoals": "Otevřít v Cílech", + "chatBlockOpenConversation": "Otevřít konverzaci", + "chatBlockOpenInMemories": "Otevřít ve Vzpomínkách", + "chatBlockUnavailable": "Již není k dispozici", + "chatBlockRecommendedNextSteps": "Doporučené další kroky" } diff --git a/app/lib/l10n/app_da.arb b/app/lib/l10n/app_da.arb index 8ffecc0f711..5f63499dc96 100644 --- a/app/lib/l10n/app_da.arb +++ b/app/lib/l10n/app_da.arb @@ -3252,5 +3252,15 @@ "pendantRecordingSyncBlocked": "Din Pendant optager stadig, så den gemte lyd kan ikke overføres. Tryk på Pendantens knap for at stoppe optagelsen, og synkroniser igen.", "pendantFullSyncBlocked": "Din Pendants lager er fuldt, og den er stadig i optagetilstand, så den gemte lyd kan ikke overføres. Tryk på Pendantens knap for at stoppe optagelsen, og synkroniser derefter igen.", "conversationsNotCapturedCount": "Ikke optaget ({count})", - "transcriptionNoAudio": "Transskription modtager ikke lyd" + "transcriptionNoAudio": "Transskription modtager ikke lyd", + "chatBlockTask": "Opgave", + "chatBlockGoal": "Mål", + "chatBlockConversation": "Samtale", + "chatBlockMemory": "Minde", + "chatBlockQuestion": "Spørgsmål", + "chatBlockOpenInGoals": "Åbn i Mål", + "chatBlockOpenConversation": "Åbn samtale", + "chatBlockOpenInMemories": "Åbn i Minder", + "chatBlockUnavailable": "Ikke længere tilgængelig", + "chatBlockRecommendedNextSteps": "Anbefalede næste trin" } diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 8cae2fd2637..62c975807a1 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -3211,5 +3211,15 @@ "pendantRecordingSyncBlocked": "Dein Pendant nimmt noch auf, daher kann das gespeicherte Audio nicht übertragen werden. Drücke die Taste am Pendant, um die Aufnahme zu stoppen, und synchronisiere dann erneut.", "pendantFullSyncBlocked": "Der Speicher deines Pendants ist voll und es befindet sich noch im Aufnahmemodus, daher kann das gespeicherte Audio nicht übertragen werden. Drücke die Taste am Pendant, um die Aufnahme zu stoppen, und synchronisiere dann erneut.", "conversationsNotCapturedCount": "Nicht erfasst ({count})", - "transcriptionNoAudio": "Transkription empfängt kein Audio" + "transcriptionNoAudio": "Transkription empfängt kein Audio", + "chatBlockTask": "Aufgabe", + "chatBlockGoal": "Ziel", + "chatBlockConversation": "Gespräch", + "chatBlockMemory": "Erinnerung", + "chatBlockQuestion": "Frage", + "chatBlockOpenInGoals": "In Zielen öffnen", + "chatBlockOpenConversation": "Gespräch öffnen", + "chatBlockOpenInMemories": "In Erinnerungen öffnen", + "chatBlockUnavailable": "Nicht mehr verfügbar", + "chatBlockRecommendedNextSteps": "Empfohlene nächste Schritte" } diff --git a/app/lib/l10n/app_el.arb b/app/lib/l10n/app_el.arb index 4f70db23275..db97b04aca7 100644 --- a/app/lib/l10n/app_el.arb +++ b/app/lib/l10n/app_el.arb @@ -3234,5 +3234,15 @@ "pendantRecordingSyncBlocked": "Το Pendant εξακολουθεί να ηχογραφεί, οπότε ο αποθηκευμένος ήχος δεν μπορεί να μεταφερθεί. Πατήστε το κουμπί του Pendant για να σταματήσετε την ηχογράφηση και συγχρονίστε ξανά.", "pendantFullSyncBlocked": "Ο αποθηκευτικός χώρος του Pendant είναι πλήρης και βρίσκεται ακόμα σε λειτουργία εγγραφής, οπότε ο αποθηκευμένος ήχος δεν μπορεί να μεταφερθεί. Πατήστε το κουμπί του Pendant για να σταματήσετε την εγγραφή και μετά συγχρονίστε ξανά.", "conversationsNotCapturedCount": "Δεν καταγράφηκε ({count})", - "transcriptionNoAudio": "Η μεταγραφή δεν λαμβάνει ήχο" + "transcriptionNoAudio": "Η μεταγραφή δεν λαμβάνει ήχο", + "chatBlockTask": "Εργασία", + "chatBlockGoal": "Στόχος", + "chatBlockConversation": "Συνομιλία", + "chatBlockMemory": "Ανάμνηση", + "chatBlockQuestion": "Ερώτηση", + "chatBlockOpenInGoals": "Άνοιγμα στους Στόχους", + "chatBlockOpenConversation": "Άνοιγμα συνομιλίας", + "chatBlockOpenInMemories": "Άνοιγμα στις Αναμνήσεις", + "chatBlockUnavailable": "Δεν είναι πλέον διαθέσιμο", + "chatBlockRecommendedNextSteps": "Προτεινόμενα επόμενα βήματα" } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index f27e584440d..978ed2fef60 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -11744,5 +11744,45 @@ "tapPlusToStartRecording": "Tap + to start recording", "@tapPlusToStartRecording": { "description": "Empty-home hint pointing at the + record button" + }, + "chatBlockTask": "Task", + "@chatBlockTask": { + "description": "Eyebrow label on a chat task card block" + }, + "chatBlockGoal": "Goal", + "@chatBlockGoal": { + "description": "Eyebrow label on a chat goal link block" + }, + "chatBlockConversation": "Conversation", + "@chatBlockConversation": { + "description": "Eyebrow label on a chat conversation/capture link block" + }, + "chatBlockMemory": "Memory", + "@chatBlockMemory": { + "description": "Eyebrow label on a chat memory link block" + }, + "chatBlockQuestion": "Question", + "@chatBlockQuestion": { + "description": "Eyebrow label on a chat question card block" + }, + "chatBlockOpenInGoals": "Open in Goals", + "@chatBlockOpenInGoals": { + "description": "Action on a chat goal link block" + }, + "chatBlockOpenConversation": "Open conversation", + "@chatBlockOpenConversation": { + "description": "Action on a chat conversation link block" + }, + "chatBlockOpenInMemories": "Open in Memories", + "@chatBlockOpenInMemories": { + "description": "Action on a chat memory link block" + }, + "chatBlockUnavailable": "No longer available", + "@chatBlockUnavailable": { + "description": "Status shown when a chat block's entity no longer exists" + }, + "chatBlockRecommendedNextSteps": "Recommended next steps", + "@chatBlockRecommendedNextSteps": { + "description": "Header above recommended action items on a chat conversation link block" } } diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index d41736998af..48a26a22bd2 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -3235,5 +3235,15 @@ "pendantRecordingSyncBlocked": "Tu Pendant sigue grabando, por lo que su audio almacenado no se puede transferir. Pulsa el botón del Pendant para detener la grabación y vuelve a sincronizar.", "pendantFullSyncBlocked": "El almacenamiento de tu Pendant está lleno y sigue en modo de grabación, por lo que su audio almacenado no se puede transferir. Pulsa el botón del Pendant para detener la grabación y vuelve a sincronizar.", "conversationsNotCapturedCount": "No capturado ({count})", - "transcriptionNoAudio": "La transcripción no recibe audio" + "transcriptionNoAudio": "La transcripción no recibe audio", + "chatBlockTask": "Tarea", + "chatBlockGoal": "Objetivo", + "chatBlockConversation": "Conversación", + "chatBlockMemory": "Recuerdo", + "chatBlockQuestion": "Pregunta", + "chatBlockOpenInGoals": "Abrir en Objetivos", + "chatBlockOpenConversation": "Abrir conversación", + "chatBlockOpenInMemories": "Abrir en Recuerdos", + "chatBlockUnavailable": "Ya no está disponible", + "chatBlockRecommendedNextSteps": "Próximos pasos recomendados" } diff --git a/app/lib/l10n/app_et.arb b/app/lib/l10n/app_et.arb index fd5f4d14bc3..2323553ea13 100644 --- a/app/lib/l10n/app_et.arb +++ b/app/lib/l10n/app_et.arb @@ -3234,5 +3234,15 @@ "pendantRecordingSyncBlocked": "Pendant salvestab endiselt, seega salvestatud heli ei saa üle kanda. Salvestamise peatamiseks vajuta Pendanti nuppu ja sünkrooni uuesti.", "pendantFullSyncBlocked": "Pendanti mälu on täis ja see on endiselt salvestusrežiimis, seega salvestatud heli ei saa üle kanda. Salvestamise peatamiseks vajuta Pendanti nuppu ja seejärel sünkrooni uuesti.", "conversationsNotCapturedCount": "Salvestamata ({count})", - "transcriptionNoAudio": "Transkriptsioon ei saa heli" + "transcriptionNoAudio": "Transkriptsioon ei saa heli", + "chatBlockTask": "Ülesanne", + "chatBlockGoal": "Eesmärk", + "chatBlockConversation": "Vestlus", + "chatBlockMemory": "Mälestus", + "chatBlockQuestion": "Küsimus", + "chatBlockOpenInGoals": "Ava eesmärkides", + "chatBlockOpenConversation": "Ava vestlus", + "chatBlockOpenInMemories": "Ava mälestustes", + "chatBlockUnavailable": "Pole enam saadaval", + "chatBlockRecommendedNextSteps": "Soovitatud järgmised sammud" } diff --git a/app/lib/l10n/app_fa.arb b/app/lib/l10n/app_fa.arb index 3d9a06cc72c..383f16cf0dc 100644 --- a/app/lib/l10n/app_fa.arb +++ b/app/lib/l10n/app_fa.arb @@ -10800,5 +10800,15 @@ "pendantRecordingSyncBlocked": "Pendant هنوز در حال ضبط است، بنابراین صدای ذخیره‌شده قابل انتقال نیست. دکمه Pendant را فشار دهید تا ضبط متوقف شود، سپس دوباره همگام‌سازی کنید.", "pendantFullSyncBlocked": "حافظه Pendant پر است و همچنان در حالت ضبط قرار دارد، بنابراین صدای ذخیره‌شده قابل انتقال نیست. دکمه Pendant را فشار دهید تا ضبط متوقف شود، سپس دوباره همگام‌سازی کنید.", "conversationsNotCapturedCount": "ثبت نشده ({count})", - "transcriptionNoAudio": "رونویسی صدا دریافت نمی‌کند" + "transcriptionNoAudio": "رونویسی صدا دریافت نمی‌کند", + "chatBlockTask": "وظیفه", + "chatBlockGoal": "هدف", + "chatBlockConversation": "گفتگو", + "chatBlockMemory": "خاطره", + "chatBlockQuestion": "پرسش", + "chatBlockOpenInGoals": "باز کردن در اهداف", + "chatBlockOpenConversation": "باز کردن گفتگو", + "chatBlockOpenInMemories": "باز کردن در خاطرات", + "chatBlockUnavailable": "دیگر در دسترس نیست", + "chatBlockRecommendedNextSteps": "گام‌های بعدی پیشنهادی" } diff --git a/app/lib/l10n/app_fi.arb b/app/lib/l10n/app_fi.arb index b1411a04fe6..7594b66e5a8 100644 --- a/app/lib/l10n/app_fi.arb +++ b/app/lib/l10n/app_fi.arb @@ -3234,5 +3234,15 @@ "pendantRecordingSyncBlocked": "Pendant tallentaa edelleen, joten tallennettua ääntä ei voi siirtää. Pysäytä tallennus painamalla Pendantin painiketta ja synkronoi sitten uudelleen.", "pendantFullSyncBlocked": "Pendantin muisti on täynnä ja se on yhä äänitystilassa, joten tallennettua ääntä ei voi siirtää. Pysäytä äänitys painamalla Pendantin painiketta ja synkronoi sitten uudelleen.", "conversationsNotCapturedCount": "Ei tallennettu ({count})", - "transcriptionNoAudio": "Transkriptio ei vastaanota ääntä" + "transcriptionNoAudio": "Transkriptio ei vastaanota ääntä", + "chatBlockTask": "Tehtävä", + "chatBlockGoal": "Tavoite", + "chatBlockConversation": "Keskustelu", + "chatBlockMemory": "Muisto", + "chatBlockQuestion": "Kysymys", + "chatBlockOpenInGoals": "Avaa Tavoitteissa", + "chatBlockOpenConversation": "Avaa keskustelu", + "chatBlockOpenInMemories": "Avaa Muistoissa", + "chatBlockUnavailable": "Ei ole enää saatavilla", + "chatBlockRecommendedNextSteps": "Suositellut seuraavat vaiheet" } diff --git a/app/lib/l10n/app_fr.arb b/app/lib/l10n/app_fr.arb index 6340239e7e7..2da73895229 100644 --- a/app/lib/l10n/app_fr.arb +++ b/app/lib/l10n/app_fr.arb @@ -3269,5 +3269,15 @@ "pendantRecordingSyncBlocked": "Votre Pendant est encore en train d'enregistrer, son audio stocké ne peut donc pas être transféré. Appuyez sur le bouton du Pendant pour arrêter l'enregistrement, puis synchronisez à nouveau.", "pendantFullSyncBlocked": "Le stockage de votre Pendant est plein et il est encore en mode enregistrement, son audio stocké ne peut donc pas être transféré. Appuyez sur le bouton du Pendant pour arrêter l'enregistrement, puis synchronisez à nouveau.", "conversationsNotCapturedCount": "Non capturé ({count})", - "transcriptionNoAudio": "La transcription ne reçoit pas d'audio" + "transcriptionNoAudio": "La transcription ne reçoit pas d'audio", + "chatBlockTask": "Tâche", + "chatBlockGoal": "Objectif", + "chatBlockConversation": "Conversation", + "chatBlockMemory": "Souvenir", + "chatBlockQuestion": "Question", + "chatBlockOpenInGoals": "Ouvrir dans Objectifs", + "chatBlockOpenConversation": "Ouvrir la conversation", + "chatBlockOpenInMemories": "Ouvrir dans Souvenirs", + "chatBlockUnavailable": "N’est plus disponible", + "chatBlockRecommendedNextSteps": "Prochaines étapes recommandées" } diff --git a/app/lib/l10n/app_he.arb b/app/lib/l10n/app_he.arb index 14fcebd0dd8..d31cbcacbf5 100644 --- a/app/lib/l10n/app_he.arb +++ b/app/lib/l10n/app_he.arb @@ -10800,5 +10800,15 @@ "pendantRecordingSyncBlocked": "ה-Pendant עדיין מקליט, ולכן לא ניתן להעביר את השמע השמור בו. לחצו על כפתור ה-Pendant כדי לעצור את ההקלטה, ואז סנכרנו שוב.", "pendantFullSyncBlocked": "האחסון של ה-Pendant מלא והוא עדיין במצב הקלטה, ולכן לא ניתן להעביר את השמע השמור. לחצו על כפתור ה-Pendant כדי לעצור את ההקלטה, ולאחר מכן סנכרנו שוב.", "conversationsNotCapturedCount": "לא הוקלט ({count})", - "transcriptionNoAudio": "התמליל אינו מקבל שמע" + "transcriptionNoAudio": "התמליל אינו מקבל שמע", + "chatBlockTask": "משימה", + "chatBlockGoal": "יעד", + "chatBlockConversation": "שיחה", + "chatBlockMemory": "זיכרון", + "chatBlockQuestion": "שאלה", + "chatBlockOpenInGoals": "פתיחה ביעדים", + "chatBlockOpenConversation": "פתיחת השיחה", + "chatBlockOpenInMemories": "פתיחה בזיכרונות", + "chatBlockUnavailable": "אינו זמין עוד", + "chatBlockRecommendedNextSteps": "השלבים הבאים המומלצים" } diff --git a/app/lib/l10n/app_hi.arb b/app/lib/l10n/app_hi.arb index a47d72a408c..8fbd21074f3 100644 --- a/app/lib/l10n/app_hi.arb +++ b/app/lib/l10n/app_hi.arb @@ -3235,5 +3235,15 @@ "pendantRecordingSyncBlocked": "Pendant अभी भी रिकॉर्ड कर रहा है, इसलिए उसमें संग्रहीत ऑडियो स्थानांतरित नहीं किया जा सकता। रिकॉर्डिंग रोकने के लिए Pendant का बटन दबाएँ, फिर दोबारा सिंक करें।", "pendantFullSyncBlocked": "Pendant का स्टोरेज भर गया है और यह अभी भी रिकॉर्डिंग मोड में है, इसलिए संग्रहीत ऑडियो स्थानांतरित नहीं किया जा सकता। रिकॉर्डिंग रोकने के लिए Pendant का बटन दबाएँ, फिर दोबारा सिंक करें।", "conversationsNotCapturedCount": "रिकॉर्ड नहीं हुआ ({count})", - "transcriptionNoAudio": "ट्रांसक्रिप्शन ऑडियो प्राप्त नहीं कर रहा है" + "transcriptionNoAudio": "ट्रांसक्रिप्शन ऑडियो प्राप्त नहीं कर रहा है", + "chatBlockTask": "कार्य", + "chatBlockGoal": "लक्ष्य", + "chatBlockConversation": "बातचीत", + "chatBlockMemory": "स्मृति", + "chatBlockQuestion": "प्रश्न", + "chatBlockOpenInGoals": "लक्ष्यों में खोलें", + "chatBlockOpenConversation": "बातचीत खोलें", + "chatBlockOpenInMemories": "स्मृतियों में खोलें", + "chatBlockUnavailable": "अब उपलब्ध नहीं है", + "chatBlockRecommendedNextSteps": "अनुशंसित अगले कदम" } diff --git a/app/lib/l10n/app_hr.arb b/app/lib/l10n/app_hr.arb index 4ffd7be97f0..03e77bab685 100644 --- a/app/lib/l10n/app_hr.arb +++ b/app/lib/l10n/app_hr.arb @@ -10800,5 +10800,15 @@ "pendantRecordingSyncBlocked": "Pendant još uvijek snima pa se pohranjeni zvuk ne može prenijeti. Pritisnite gumb na Pendantu da zaustavite snimanje, a zatim ponovno sinkronizirajte.", "pendantFullSyncBlocked": "Pohrana Pendanta je puna i još je u načinu snimanja, pa se pohranjeni zvuk ne može prenijeti. Pritisnite gumb na Pendantu da zaustavite snimanje, a zatim ponovno sinkronizirajte.", "conversationsNotCapturedCount": "Nije snimljeno ({count})", - "transcriptionNoAudio": "Transkripcija ne prima zvuk" + "transcriptionNoAudio": "Transkripcija ne prima zvuk", + "chatBlockTask": "Zadatak", + "chatBlockGoal": "Cilj", + "chatBlockConversation": "Razgovor", + "chatBlockMemory": "Sjećanje", + "chatBlockQuestion": "Pitanje", + "chatBlockOpenInGoals": "Otvori u Ciljevima", + "chatBlockOpenConversation": "Otvori razgovor", + "chatBlockOpenInMemories": "Otvori u Sjećanjima", + "chatBlockUnavailable": "Više nije dostupno", + "chatBlockRecommendedNextSteps": "Preporučeni sljedeći koraci" } diff --git a/app/lib/l10n/app_hu.arb b/app/lib/l10n/app_hu.arb index e2f3f1f6a5e..aaec8ec1d1b 100644 --- a/app/lib/l10n/app_hu.arb +++ b/app/lib/l10n/app_hu.arb @@ -3330,5 +3330,15 @@ "pendantRecordingSyncBlocked": "A Pendant még mindig felvételt készít, ezért a tárolt hang nem vihető át. Nyomd meg a Pendant gombját a felvétel leállításához, majd szinkronizálj újra.", "pendantFullSyncBlocked": "A Pendant tárhelye megtelt, és még mindig felvételi módban van, ezért a tárolt hang nem vihető át. Nyomja meg a Pendant gombját a felvétel leállításához, majd szinkronizáljon újra.", "conversationsNotCapturedCount": "Nincs rögzítve ({count})", - "transcriptionNoAudio": "Az átírás nem kap hangot" + "transcriptionNoAudio": "Az átírás nem kap hangot", + "chatBlockTask": "Feladat", + "chatBlockGoal": "Cél", + "chatBlockConversation": "Beszélgetés", + "chatBlockMemory": "Emlék", + "chatBlockQuestion": "Kérdés", + "chatBlockOpenInGoals": "Megnyitás a Célokban", + "chatBlockOpenConversation": "Beszélgetés megnyitása", + "chatBlockOpenInMemories": "Megnyitás az Emlékekben", + "chatBlockUnavailable": "Már nem érhető el", + "chatBlockRecommendedNextSteps": "Javasolt következő lépések" } diff --git a/app/lib/l10n/app_id.arb b/app/lib/l10n/app_id.arb index 405161a1147..9c8e0091503 100644 --- a/app/lib/l10n/app_id.arb +++ b/app/lib/l10n/app_id.arb @@ -3276,5 +3276,15 @@ "pendantRecordingSyncBlocked": "Pendant masih merekam, jadi audio yang tersimpan tidak dapat ditransfer. Tekan tombol Pendant untuk menghentikan perekaman, lalu sinkronkan lagi.", "pendantFullSyncBlocked": "Penyimpanan Pendant penuh dan masih dalam mode perekaman, sehingga audio yang tersimpan tidak dapat ditransfer. Tekan tombol Pendant untuk menghentikan perekaman, lalu sinkronkan lagi.", "conversationsNotCapturedCount": "Tidak direkam ({count})", - "transcriptionNoAudio": "Transkripsi tidak menerima audio" + "transcriptionNoAudio": "Transkripsi tidak menerima audio", + "chatBlockTask": "Tugas", + "chatBlockGoal": "Tujuan", + "chatBlockConversation": "Percakapan", + "chatBlockMemory": "Memori", + "chatBlockQuestion": "Pertanyaan", + "chatBlockOpenInGoals": "Buka di Tujuan", + "chatBlockOpenConversation": "Buka percakapan", + "chatBlockOpenInMemories": "Buka di Memori", + "chatBlockUnavailable": "Tidak lagi tersedia", + "chatBlockRecommendedNextSteps": "Langkah berikutnya yang disarankan" } diff --git a/app/lib/l10n/app_it.arb b/app/lib/l10n/app_it.arb index 93edc63b02e..6fbf765e07f 100644 --- a/app/lib/l10n/app_it.arb +++ b/app/lib/l10n/app_it.arb @@ -3234,5 +3234,15 @@ "pendantRecordingSyncBlocked": "Il Pendant sta ancora registrando, quindi l'audio memorizzato non può essere trasferito. Premi il pulsante del Pendant per interrompere la registrazione, poi sincronizza di nuovo.", "pendantFullSyncBlocked": "La memoria del Pendant è piena ed è ancora in modalità registrazione, quindi l'audio memorizzato non può essere trasferito. Premi il pulsante del Pendant per interrompere la registrazione, poi sincronizza di nuovo.", "conversationsNotCapturedCount": "Non registrato ({count})", - "transcriptionNoAudio": "La trascrizione non riceve audio" + "transcriptionNoAudio": "La trascrizione non riceve audio", + "chatBlockTask": "Attività", + "chatBlockGoal": "Obiettivo", + "chatBlockConversation": "Conversazione", + "chatBlockMemory": "Ricordo", + "chatBlockQuestion": "Domanda", + "chatBlockOpenInGoals": "Apri in Obiettivi", + "chatBlockOpenConversation": "Apri conversazione", + "chatBlockOpenInMemories": "Apri in Ricordi", + "chatBlockUnavailable": "Non è più disponibile", + "chatBlockRecommendedNextSteps": "Prossimi passi consigliati" } diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index 26ad4631dfe..af75379049d 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -3210,5 +3210,15 @@ "pendantRecordingSyncBlocked": "Pendantはまだ録音中のため、保存された音声を転送できません。Pendantのボタンを押して録音を停止してから、もう一度同期してください。", "pendantFullSyncBlocked": "Pendantのストレージが満杯で、まだ録音モードのままのため、保存された音声を転送できません。Pendantのボタンを押して録音を停止してから、もう一度同期してください。", "conversationsNotCapturedCount": "未記録 ({count})", - "transcriptionNoAudio": "文字起こしが音声を受信していません" + "transcriptionNoAudio": "文字起こしが音声を受信していません", + "chatBlockTask": "タスク", + "chatBlockGoal": "目標", + "chatBlockConversation": "会話", + "chatBlockMemory": "メモリー", + "chatBlockQuestion": "質問", + "chatBlockOpenInGoals": "目標で開く", + "chatBlockOpenConversation": "会話を開く", + "chatBlockOpenInMemories": "メモリーで開く", + "chatBlockUnavailable": "現在は利用できません", + "chatBlockRecommendedNextSteps": "おすすめの次のステップ" } diff --git a/app/lib/l10n/app_kn.arb b/app/lib/l10n/app_kn.arb index bf67390d230..fdc439012bd 100644 --- a/app/lib/l10n/app_kn.arb +++ b/app/lib/l10n/app_kn.arb @@ -10800,5 +10800,15 @@ "pendantRecordingSyncBlocked": "Pendant ಇನ್ನೂ ರೆಕಾರ್ಡ್ ಮಾಡುತ್ತಿದೆ, ಆದ್ದರಿಂದ ಸಂಗ್ರಹಿಸಿದ ಆಡಿಯೊವನ್ನು ವರ್ಗಾಯಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ. ರೆಕಾರ್ಡಿಂಗ್ ನಿಲ್ಲಿಸಲು Pendant ಬಟನ್ ಒತ್ತಿ, ನಂತರ ಮತ್ತೆ ಸಿಂಕ್ ಮಾಡಿ.", "pendantFullSyncBlocked": "Pendant ನ ಸಂಗ್ರಹಣೆ ತುಂಬಿದೆ ಮತ್ತು ಅದು ಇನ್ನೂ ರೆಕಾರ್ಡಿಂಗ್ ಮೋಡ್‌ನಲ್ಲಿದೆ, ಆದ್ದರಿಂದ ಸಂಗ್ರಹಿಸಿದ ಆಡಿಯೊವನ್ನು ವರ್ಗಾಯಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ. ರೆಕಾರ್ಡಿಂಗ್ ನಿಲ್ಲಿಸಲು Pendant ನ ಬಟನ್ ಒತ್ತಿ, ನಂತರ ಮತ್ತೆ ಸಿಂಕ್ ಮಾಡಿ.", "conversationsNotCapturedCount": "ದಾಖಲಾಗಿಲ್ಲ ({count})", - "transcriptionNoAudio": "ಲಿಪ್ಯಂತರಣ ಆಡಿಯೊ ಸ್ವೀಕರಿಸುತ್ತಿಲ್ಲ" + "transcriptionNoAudio": "ಲಿಪ್ಯಂತರಣ ಆಡಿಯೊ ಸ್ವೀಕರಿಸುತ್ತಿಲ್ಲ", + "chatBlockTask": "ಕಾರ್ಯ", + "chatBlockGoal": "ಗುರಿ", + "chatBlockConversation": "ಸಂಭಾಷಣೆ", + "chatBlockMemory": "ನೆನಪು", + "chatBlockQuestion": "ಪ್ರಶ್ನೆ", + "chatBlockOpenInGoals": "ಗುರಿಗಳಲ್ಲಿ ತೆರೆಯಿರಿ", + "chatBlockOpenConversation": "ಸಂಭಾಷಣೆ ತೆರೆಯಿರಿ", + "chatBlockOpenInMemories": "ನೆನಪುಗಳಲ್ಲಿ ತೆರೆಯಿರಿ", + "chatBlockUnavailable": "ಇನ್ನು ಲಭ್ಯವಿಲ್ಲ", + "chatBlockRecommendedNextSteps": "ಶಿಫಾರಸು ಮಾಡಿದ ಮುಂದಿನ ಹಂತಗಳು" } diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index eb0965377fa..f51261da897 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -3234,5 +3234,15 @@ "pendantRecordingSyncBlocked": "Pendant가 아직 녹음 중이어서 저장된 오디오를 전송할 수 없습니다. Pendant의 버튼을 눌러 녹음을 중지한 후 다시 동기화하세요.", "pendantFullSyncBlocked": "Pendant의 저장 공간이 가득 찼고 아직 녹음 모드이므로 저장된 오디오를 전송할 수 없습니다. Pendant의 버튼을 눌러 녹음을 중지한 다음 다시 동기화하세요.", "conversationsNotCapturedCount": "기록되지 않음 ({count})", - "transcriptionNoAudio": "전사가 오디오를 받지 못하고 있습니다" + "transcriptionNoAudio": "전사가 오디오를 받지 못하고 있습니다", + "chatBlockTask": "작업", + "chatBlockGoal": "목표", + "chatBlockConversation": "대화", + "chatBlockMemory": "메모리", + "chatBlockQuestion": "질문", + "chatBlockOpenInGoals": "목표에서 열기", + "chatBlockOpenConversation": "대화 열기", + "chatBlockOpenInMemories": "메모리에서 열기", + "chatBlockUnavailable": "더 이상 사용할 수 없음", + "chatBlockRecommendedNextSteps": "권장 다음 단계" } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index a0f5c2ccade..0a9a1aaa150 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -18464,6 +18464,66 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Tap + to start recording'** String get tapPlusToStartRecording; + + /// Eyebrow label on a chat task card block + /// + /// In en, this message translates to: + /// **'Task'** + String get chatBlockTask; + + /// Eyebrow label on a chat goal link block + /// + /// In en, this message translates to: + /// **'Goal'** + String get chatBlockGoal; + + /// Eyebrow label on a chat conversation/capture link block + /// + /// In en, this message translates to: + /// **'Conversation'** + String get chatBlockConversation; + + /// Eyebrow label on a chat memory link block + /// + /// In en, this message translates to: + /// **'Memory'** + String get chatBlockMemory; + + /// Eyebrow label on a chat question card block + /// + /// In en, this message translates to: + /// **'Question'** + String get chatBlockQuestion; + + /// Action on a chat goal link block + /// + /// In en, this message translates to: + /// **'Open in Goals'** + String get chatBlockOpenInGoals; + + /// Action on a chat conversation link block + /// + /// In en, this message translates to: + /// **'Open conversation'** + String get chatBlockOpenConversation; + + /// Action on a chat memory link block + /// + /// In en, this message translates to: + /// **'Open in Memories'** + String get chatBlockOpenInMemories; + + /// Status shown when a chat block's entity no longer exists + /// + /// In en, this message translates to: + /// **'No longer available'** + String get chatBlockUnavailable; + + /// Header above recommended action items on a chat conversation link block + /// + /// In en, this message translates to: + /// **'Recommended next steps'** + String get chatBlockRecommendedNextSteps; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/app/lib/l10n/app_localizations_ar.dart b/app/lib/l10n/app_localizations_ar.dart index c8dd61ab225..01fff1fc8e9 100644 --- a/app/lib/l10n/app_localizations_ar.dart +++ b/app/lib/l10n/app_localizations_ar.dart @@ -9860,4 +9860,34 @@ class AppLocalizationsAr extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'مهمة'; + + @override + String get chatBlockGoal => 'هدف'; + + @override + String get chatBlockConversation => 'محادثة'; + + @override + String get chatBlockMemory => 'ذكرى'; + + @override + String get chatBlockQuestion => 'سؤال'; + + @override + String get chatBlockOpenInGoals => 'فتح في الأهداف'; + + @override + String get chatBlockOpenConversation => 'فتح المحادثة'; + + @override + String get chatBlockOpenInMemories => 'فتح في الذكريات'; + + @override + String get chatBlockUnavailable => 'لم يعد متاحًا'; + + @override + String get chatBlockRecommendedNextSteps => 'الخطوات التالية الموصى بها'; } diff --git a/app/lib/l10n/app_localizations_be.dart b/app/lib/l10n/app_localizations_be.dart index 236449c9588..c23a16ce251 100644 --- a/app/lib/l10n/app_localizations_be.dart +++ b/app/lib/l10n/app_localizations_be.dart @@ -9950,4 +9950,34 @@ class AppLocalizationsBe extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Задача'; + + @override + String get chatBlockGoal => 'Мэта'; + + @override + String get chatBlockConversation => 'Размова'; + + @override + String get chatBlockMemory => 'Успамін'; + + @override + String get chatBlockQuestion => 'Пытанне'; + + @override + String get chatBlockOpenInGoals => 'Адкрыць у мэтах'; + + @override + String get chatBlockOpenConversation => 'Адкрыць размову'; + + @override + String get chatBlockOpenInMemories => 'Адкрыць ва ўспамінах'; + + @override + String get chatBlockUnavailable => 'Больш недаступна'; + + @override + String get chatBlockRecommendedNextSteps => 'Рэкамендаваныя наступныя крокі'; } diff --git a/app/lib/l10n/app_localizations_bg.dart b/app/lib/l10n/app_localizations_bg.dart index d38ed27ad27..c1d7108a78b 100644 --- a/app/lib/l10n/app_localizations_bg.dart +++ b/app/lib/l10n/app_localizations_bg.dart @@ -9955,4 +9955,34 @@ class AppLocalizationsBg extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Задача'; + + @override + String get chatBlockGoal => 'Цел'; + + @override + String get chatBlockConversation => 'Разговор'; + + @override + String get chatBlockMemory => 'Спомен'; + + @override + String get chatBlockQuestion => 'Въпрос'; + + @override + String get chatBlockOpenInGoals => 'Отваряне в „Цели“'; + + @override + String get chatBlockOpenConversation => 'Отваряне на разговора'; + + @override + String get chatBlockOpenInMemories => 'Отваряне в „Спомени“'; + + @override + String get chatBlockUnavailable => 'Вече не е налично'; + + @override + String get chatBlockRecommendedNextSteps => 'Препоръчани следващи стъпки'; } diff --git a/app/lib/l10n/app_localizations_bn.dart b/app/lib/l10n/app_localizations_bn.dart index 1940ad0262b..c43c29575fd 100644 --- a/app/lib/l10n/app_localizations_bn.dart +++ b/app/lib/l10n/app_localizations_bn.dart @@ -9923,4 +9923,34 @@ class AppLocalizationsBn extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'কাজ'; + + @override + String get chatBlockGoal => 'লক্ষ্য'; + + @override + String get chatBlockConversation => 'কথোপকথন'; + + @override + String get chatBlockMemory => 'স্মৃতি'; + + @override + String get chatBlockQuestion => 'প্রশ্ন'; + + @override + String get chatBlockOpenInGoals => 'লক্ষ্যে খুলুন'; + + @override + String get chatBlockOpenConversation => 'কথোপকথন খুলুন'; + + @override + String get chatBlockOpenInMemories => 'স্মৃতিতে খুলুন'; + + @override + String get chatBlockUnavailable => 'আর উপলব্ধ নেই'; + + @override + String get chatBlockRecommendedNextSteps => 'প্রস্তাবিত পরবর্তী পদক্ষেপ'; } diff --git a/app/lib/l10n/app_localizations_bs.dart b/app/lib/l10n/app_localizations_bs.dart index f732b11bc3d..0c3429b687f 100644 --- a/app/lib/l10n/app_localizations_bs.dart +++ b/app/lib/l10n/app_localizations_bs.dart @@ -9947,4 +9947,34 @@ class AppLocalizationsBs extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Zadatak'; + + @override + String get chatBlockGoal => 'Cilj'; + + @override + String get chatBlockConversation => 'Razgovor'; + + @override + String get chatBlockMemory => 'Sjećanje'; + + @override + String get chatBlockQuestion => 'Pitanje'; + + @override + String get chatBlockOpenInGoals => 'Otvori u Ciljevima'; + + @override + String get chatBlockOpenConversation => 'Otvori razgovor'; + + @override + String get chatBlockOpenInMemories => 'Otvori u Sjećanjima'; + + @override + String get chatBlockUnavailable => 'Više nije dostupno'; + + @override + String get chatBlockRecommendedNextSteps => 'Preporučeni sljedeći koraci'; } diff --git a/app/lib/l10n/app_localizations_ca.dart b/app/lib/l10n/app_localizations_ca.dart index 0b3c3de2e46..d823b6b73b9 100644 --- a/app/lib/l10n/app_localizations_ca.dart +++ b/app/lib/l10n/app_localizations_ca.dart @@ -9975,4 +9975,34 @@ class AppLocalizationsCa extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Tasca'; + + @override + String get chatBlockGoal => 'Objectiu'; + + @override + String get chatBlockConversation => 'Conversa'; + + @override + String get chatBlockMemory => 'Record'; + + @override + String get chatBlockQuestion => 'Pregunta'; + + @override + String get chatBlockOpenInGoals => 'Obre a Objectius'; + + @override + String get chatBlockOpenConversation => 'Obre la conversa'; + + @override + String get chatBlockOpenInMemories => 'Obre a Records'; + + @override + String get chatBlockUnavailable => 'Ja no està disponible'; + + @override + String get chatBlockRecommendedNextSteps => 'Propers passos recomanats'; } diff --git a/app/lib/l10n/app_localizations_cs.dart b/app/lib/l10n/app_localizations_cs.dart index cc2da65add4..59c1e38942d 100644 --- a/app/lib/l10n/app_localizations_cs.dart +++ b/app/lib/l10n/app_localizations_cs.dart @@ -9919,4 +9919,34 @@ class AppLocalizationsCs extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Úkol'; + + @override + String get chatBlockGoal => 'Cíl'; + + @override + String get chatBlockConversation => 'Konverzace'; + + @override + String get chatBlockMemory => 'Vzpomínka'; + + @override + String get chatBlockQuestion => 'Otázka'; + + @override + String get chatBlockOpenInGoals => 'Otevřít v Cílech'; + + @override + String get chatBlockOpenConversation => 'Otevřít konverzaci'; + + @override + String get chatBlockOpenInMemories => 'Otevřít ve Vzpomínkách'; + + @override + String get chatBlockUnavailable => 'Již není k dispozici'; + + @override + String get chatBlockRecommendedNextSteps => 'Doporučené další kroky'; } diff --git a/app/lib/l10n/app_localizations_da.dart b/app/lib/l10n/app_localizations_da.dart index 07abc642a1f..f03962669d3 100644 --- a/app/lib/l10n/app_localizations_da.dart +++ b/app/lib/l10n/app_localizations_da.dart @@ -9902,4 +9902,34 @@ class AppLocalizationsDa extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Opgave'; + + @override + String get chatBlockGoal => 'Mål'; + + @override + String get chatBlockConversation => 'Samtale'; + + @override + String get chatBlockMemory => 'Minde'; + + @override + String get chatBlockQuestion => 'Spørgsmål'; + + @override + String get chatBlockOpenInGoals => 'Åbn i Mål'; + + @override + String get chatBlockOpenConversation => 'Åbn samtale'; + + @override + String get chatBlockOpenInMemories => 'Åbn i Minder'; + + @override + String get chatBlockUnavailable => 'Ikke længere tilgængelig'; + + @override + String get chatBlockRecommendedNextSteps => 'Anbefalede næste trin'; } diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index c63bd03ec90..0d2babde13f 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -10001,4 +10001,34 @@ class AppLocalizationsDe extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Aufgabe'; + + @override + String get chatBlockGoal => 'Ziel'; + + @override + String get chatBlockConversation => 'Gespräch'; + + @override + String get chatBlockMemory => 'Erinnerung'; + + @override + String get chatBlockQuestion => 'Frage'; + + @override + String get chatBlockOpenInGoals => 'In Zielen öffnen'; + + @override + String get chatBlockOpenConversation => 'Gespräch öffnen'; + + @override + String get chatBlockOpenInMemories => 'In Erinnerungen öffnen'; + + @override + String get chatBlockUnavailable => 'Nicht mehr verfügbar'; + + @override + String get chatBlockRecommendedNextSteps => 'Empfohlene nächste Schritte'; } diff --git a/app/lib/l10n/app_localizations_el.dart b/app/lib/l10n/app_localizations_el.dart index 2f8ee0160d0..9d5415b2d23 100644 --- a/app/lib/l10n/app_localizations_el.dart +++ b/app/lib/l10n/app_localizations_el.dart @@ -9988,4 +9988,34 @@ class AppLocalizationsEl extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Εργασία'; + + @override + String get chatBlockGoal => 'Στόχος'; + + @override + String get chatBlockConversation => 'Συνομιλία'; + + @override + String get chatBlockMemory => 'Ανάμνηση'; + + @override + String get chatBlockQuestion => 'Ερώτηση'; + + @override + String get chatBlockOpenInGoals => 'Άνοιγμα στους Στόχους'; + + @override + String get chatBlockOpenConversation => 'Άνοιγμα συνομιλίας'; + + @override + String get chatBlockOpenInMemories => 'Άνοιγμα στις Αναμνήσεις'; + + @override + String get chatBlockUnavailable => 'Δεν είναι πλέον διαθέσιμο'; + + @override + String get chatBlockRecommendedNextSteps => 'Προτεινόμενα επόμενα βήματα'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index ac3e7f7efdd..30709fa5033 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -9909,4 +9909,34 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Task'; + + @override + String get chatBlockGoal => 'Goal'; + + @override + String get chatBlockConversation => 'Conversation'; + + @override + String get chatBlockMemory => 'Memory'; + + @override + String get chatBlockQuestion => 'Question'; + + @override + String get chatBlockOpenInGoals => 'Open in Goals'; + + @override + String get chatBlockOpenConversation => 'Open conversation'; + + @override + String get chatBlockOpenInMemories => 'Open in Memories'; + + @override + String get chatBlockUnavailable => 'No longer available'; + + @override + String get chatBlockRecommendedNextSteps => 'Recommended next steps'; } diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 3ce51425849..757eca7991c 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -9942,4 +9942,34 @@ class AppLocalizationsEs extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Tarea'; + + @override + String get chatBlockGoal => 'Objetivo'; + + @override + String get chatBlockConversation => 'Conversación'; + + @override + String get chatBlockMemory => 'Recuerdo'; + + @override + String get chatBlockQuestion => 'Pregunta'; + + @override + String get chatBlockOpenInGoals => 'Abrir en Objetivos'; + + @override + String get chatBlockOpenConversation => 'Abrir conversación'; + + @override + String get chatBlockOpenInMemories => 'Abrir en Recuerdos'; + + @override + String get chatBlockUnavailable => 'Ya no está disponible'; + + @override + String get chatBlockRecommendedNextSteps => 'Próximos pasos recomendados'; } diff --git a/app/lib/l10n/app_localizations_et.dart b/app/lib/l10n/app_localizations_et.dart index 52f139bff36..ddcc575be43 100644 --- a/app/lib/l10n/app_localizations_et.dart +++ b/app/lib/l10n/app_localizations_et.dart @@ -9912,4 +9912,34 @@ class AppLocalizationsEt extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Ülesanne'; + + @override + String get chatBlockGoal => 'Eesmärk'; + + @override + String get chatBlockConversation => 'Vestlus'; + + @override + String get chatBlockMemory => 'Mälestus'; + + @override + String get chatBlockQuestion => 'Küsimus'; + + @override + String get chatBlockOpenInGoals => 'Ava eesmärkides'; + + @override + String get chatBlockOpenConversation => 'Ava vestlus'; + + @override + String get chatBlockOpenInMemories => 'Ava mälestustes'; + + @override + String get chatBlockUnavailable => 'Pole enam saadaval'; + + @override + String get chatBlockRecommendedNextSteps => 'Soovitatud järgmised sammud'; } diff --git a/app/lib/l10n/app_localizations_fa.dart b/app/lib/l10n/app_localizations_fa.dart index 248a340cadf..0f88488adb7 100644 --- a/app/lib/l10n/app_localizations_fa.dart +++ b/app/lib/l10n/app_localizations_fa.dart @@ -9918,4 +9918,34 @@ class AppLocalizationsFa extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'وظیفه'; + + @override + String get chatBlockGoal => 'هدف'; + + @override + String get chatBlockConversation => 'گفتگو'; + + @override + String get chatBlockMemory => 'خاطره'; + + @override + String get chatBlockQuestion => 'پرسش'; + + @override + String get chatBlockOpenInGoals => 'باز کردن در اهداف'; + + @override + String get chatBlockOpenConversation => 'باز کردن گفتگو'; + + @override + String get chatBlockOpenInMemories => 'باز کردن در خاطرات'; + + @override + String get chatBlockUnavailable => 'دیگر در دسترس نیست'; + + @override + String get chatBlockRecommendedNextSteps => 'گام‌های بعدی پیشنهادی'; } diff --git a/app/lib/l10n/app_localizations_fi.dart b/app/lib/l10n/app_localizations_fi.dart index 6b781a0e459..0e09e8381d7 100644 --- a/app/lib/l10n/app_localizations_fi.dart +++ b/app/lib/l10n/app_localizations_fi.dart @@ -9919,4 +9919,34 @@ class AppLocalizationsFi extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Tehtävä'; + + @override + String get chatBlockGoal => 'Tavoite'; + + @override + String get chatBlockConversation => 'Keskustelu'; + + @override + String get chatBlockMemory => 'Muisto'; + + @override + String get chatBlockQuestion => 'Kysymys'; + + @override + String get chatBlockOpenInGoals => 'Avaa Tavoitteissa'; + + @override + String get chatBlockOpenConversation => 'Avaa keskustelu'; + + @override + String get chatBlockOpenInMemories => 'Avaa Muistoissa'; + + @override + String get chatBlockUnavailable => 'Ei ole enää saatavilla'; + + @override + String get chatBlockRecommendedNextSteps => 'Suositellut seuraavat vaiheet'; } diff --git a/app/lib/l10n/app_localizations_fr.dart b/app/lib/l10n/app_localizations_fr.dart index 8d6714ade4e..88c2e5ec1b2 100644 --- a/app/lib/l10n/app_localizations_fr.dart +++ b/app/lib/l10n/app_localizations_fr.dart @@ -10005,4 +10005,34 @@ class AppLocalizationsFr extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Tâche'; + + @override + String get chatBlockGoal => 'Objectif'; + + @override + String get chatBlockConversation => 'Conversation'; + + @override + String get chatBlockMemory => 'Souvenir'; + + @override + String get chatBlockQuestion => 'Question'; + + @override + String get chatBlockOpenInGoals => 'Ouvrir dans Objectifs'; + + @override + String get chatBlockOpenConversation => 'Ouvrir la conversation'; + + @override + String get chatBlockOpenInMemories => 'Ouvrir dans Souvenirs'; + + @override + String get chatBlockUnavailable => 'N’est plus disponible'; + + @override + String get chatBlockRecommendedNextSteps => 'Prochaines étapes recommandées'; } diff --git a/app/lib/l10n/app_localizations_he.dart b/app/lib/l10n/app_localizations_he.dart index 084beae8c97..335d8b7feaa 100644 --- a/app/lib/l10n/app_localizations_he.dart +++ b/app/lib/l10n/app_localizations_he.dart @@ -9839,4 +9839,34 @@ class AppLocalizationsHe extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'משימה'; + + @override + String get chatBlockGoal => 'יעד'; + + @override + String get chatBlockConversation => 'שיחה'; + + @override + String get chatBlockMemory => 'זיכרון'; + + @override + String get chatBlockQuestion => 'שאלה'; + + @override + String get chatBlockOpenInGoals => 'פתיחה ביעדים'; + + @override + String get chatBlockOpenConversation => 'פתיחת השיחה'; + + @override + String get chatBlockOpenInMemories => 'פתיחה בזיכרונות'; + + @override + String get chatBlockUnavailable => 'אינו זמין עוד'; + + @override + String get chatBlockRecommendedNextSteps => 'השלבים הבאים המומלצים'; } diff --git a/app/lib/l10n/app_localizations_hi.dart b/app/lib/l10n/app_localizations_hi.dart index 1d977d3959d..dfdf6998164 100644 --- a/app/lib/l10n/app_localizations_hi.dart +++ b/app/lib/l10n/app_localizations_hi.dart @@ -9897,4 +9897,34 @@ class AppLocalizationsHi extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'कार्य'; + + @override + String get chatBlockGoal => 'लक्ष्य'; + + @override + String get chatBlockConversation => 'बातचीत'; + + @override + String get chatBlockMemory => 'स्मृति'; + + @override + String get chatBlockQuestion => 'प्रश्न'; + + @override + String get chatBlockOpenInGoals => 'लक्ष्यों में खोलें'; + + @override + String get chatBlockOpenConversation => 'बातचीत खोलें'; + + @override + String get chatBlockOpenInMemories => 'स्मृतियों में खोलें'; + + @override + String get chatBlockUnavailable => 'अब उपलब्ध नहीं है'; + + @override + String get chatBlockRecommendedNextSteps => 'अनुशंसित अगले कदम'; } diff --git a/app/lib/l10n/app_localizations_hr.dart b/app/lib/l10n/app_localizations_hr.dart index f2dd41831b8..235742e143a 100644 --- a/app/lib/l10n/app_localizations_hr.dart +++ b/app/lib/l10n/app_localizations_hr.dart @@ -9954,4 +9954,34 @@ class AppLocalizationsHr extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Zadatak'; + + @override + String get chatBlockGoal => 'Cilj'; + + @override + String get chatBlockConversation => 'Razgovor'; + + @override + String get chatBlockMemory => 'Sjećanje'; + + @override + String get chatBlockQuestion => 'Pitanje'; + + @override + String get chatBlockOpenInGoals => 'Otvori u Ciljevima'; + + @override + String get chatBlockOpenConversation => 'Otvori razgovor'; + + @override + String get chatBlockOpenInMemories => 'Otvori u Sjećanjima'; + + @override + String get chatBlockUnavailable => 'Više nije dostupno'; + + @override + String get chatBlockRecommendedNextSteps => 'Preporučeni sljedeći koraci'; } diff --git a/app/lib/l10n/app_localizations_hu.dart b/app/lib/l10n/app_localizations_hu.dart index a6ae9104b01..66d4f906747 100644 --- a/app/lib/l10n/app_localizations_hu.dart +++ b/app/lib/l10n/app_localizations_hu.dart @@ -9959,4 +9959,34 @@ class AppLocalizationsHu extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Feladat'; + + @override + String get chatBlockGoal => 'Cél'; + + @override + String get chatBlockConversation => 'Beszélgetés'; + + @override + String get chatBlockMemory => 'Emlék'; + + @override + String get chatBlockQuestion => 'Kérdés'; + + @override + String get chatBlockOpenInGoals => 'Megnyitás a Célokban'; + + @override + String get chatBlockOpenConversation => 'Beszélgetés megnyitása'; + + @override + String get chatBlockOpenInMemories => 'Megnyitás az Emlékekben'; + + @override + String get chatBlockUnavailable => 'Már nem érhető el'; + + @override + String get chatBlockRecommendedNextSteps => 'Javasolt következő lépések'; } diff --git a/app/lib/l10n/app_localizations_id.dart b/app/lib/l10n/app_localizations_id.dart index 413f740e8a2..978cd51c93e 100644 --- a/app/lib/l10n/app_localizations_id.dart +++ b/app/lib/l10n/app_localizations_id.dart @@ -9929,4 +9929,34 @@ class AppLocalizationsId extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Tugas'; + + @override + String get chatBlockGoal => 'Tujuan'; + + @override + String get chatBlockConversation => 'Percakapan'; + + @override + String get chatBlockMemory => 'Memori'; + + @override + String get chatBlockQuestion => 'Pertanyaan'; + + @override + String get chatBlockOpenInGoals => 'Buka di Tujuan'; + + @override + String get chatBlockOpenConversation => 'Buka percakapan'; + + @override + String get chatBlockOpenInMemories => 'Buka di Memori'; + + @override + String get chatBlockUnavailable => 'Tidak lagi tersedia'; + + @override + String get chatBlockRecommendedNextSteps => 'Langkah berikutnya yang disarankan'; } diff --git a/app/lib/l10n/app_localizations_it.dart b/app/lib/l10n/app_localizations_it.dart index 1bc142e3457..04051a5d3ed 100644 --- a/app/lib/l10n/app_localizations_it.dart +++ b/app/lib/l10n/app_localizations_it.dart @@ -9975,4 +9975,34 @@ class AppLocalizationsIt extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Attività'; + + @override + String get chatBlockGoal => 'Obiettivo'; + + @override + String get chatBlockConversation => 'Conversazione'; + + @override + String get chatBlockMemory => 'Ricordo'; + + @override + String get chatBlockQuestion => 'Domanda'; + + @override + String get chatBlockOpenInGoals => 'Apri in Obiettivi'; + + @override + String get chatBlockOpenConversation => 'Apri conversazione'; + + @override + String get chatBlockOpenInMemories => 'Apri in Ricordi'; + + @override + String get chatBlockUnavailable => 'Non è più disponibile'; + + @override + String get chatBlockRecommendedNextSteps => 'Prossimi passi consigliati'; } diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index dfa1193b482..91a2ac7e8c3 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -9749,4 +9749,34 @@ class AppLocalizationsJa extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'タスク'; + + @override + String get chatBlockGoal => '目標'; + + @override + String get chatBlockConversation => '会話'; + + @override + String get chatBlockMemory => 'メモリー'; + + @override + String get chatBlockQuestion => '質問'; + + @override + String get chatBlockOpenInGoals => '目標で開く'; + + @override + String get chatBlockOpenConversation => '会話を開く'; + + @override + String get chatBlockOpenInMemories => 'メモリーで開く'; + + @override + String get chatBlockUnavailable => '現在は利用できません'; + + @override + String get chatBlockRecommendedNextSteps => 'おすすめの次のステップ'; } diff --git a/app/lib/l10n/app_localizations_kn.dart b/app/lib/l10n/app_localizations_kn.dart index 467da0cbc69..00f9a317820 100644 --- a/app/lib/l10n/app_localizations_kn.dart +++ b/app/lib/l10n/app_localizations_kn.dart @@ -9950,4 +9950,34 @@ class AppLocalizationsKn extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'ಕಾರ್ಯ'; + + @override + String get chatBlockGoal => 'ಗುರಿ'; + + @override + String get chatBlockConversation => 'ಸಂಭಾಷಣೆ'; + + @override + String get chatBlockMemory => 'ನೆನಪು'; + + @override + String get chatBlockQuestion => 'ಪ್ರಶ್ನೆ'; + + @override + String get chatBlockOpenInGoals => 'ಗುರಿಗಳಲ್ಲಿ ತೆರೆಯಿರಿ'; + + @override + String get chatBlockOpenConversation => 'ಸಂಭಾಷಣೆ ತೆರೆಯಿರಿ'; + + @override + String get chatBlockOpenInMemories => 'ನೆನಪುಗಳಲ್ಲಿ ತೆರೆಯಿರಿ'; + + @override + String get chatBlockUnavailable => 'ಇನ್ನು ಲಭ್ಯವಿಲ್ಲ'; + + @override + String get chatBlockRecommendedNextSteps => 'ಶಿಫಾರಸು ಮಾಡಿದ ಮುಂದಿನ ಹಂತಗಳು'; } diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index 86891004add..61d812c176a 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -9752,4 +9752,34 @@ class AppLocalizationsKo extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => '작업'; + + @override + String get chatBlockGoal => '목표'; + + @override + String get chatBlockConversation => '대화'; + + @override + String get chatBlockMemory => '메모리'; + + @override + String get chatBlockQuestion => '질문'; + + @override + String get chatBlockOpenInGoals => '목표에서 열기'; + + @override + String get chatBlockOpenConversation => '대화 열기'; + + @override + String get chatBlockOpenInMemories => '메모리에서 열기'; + + @override + String get chatBlockUnavailable => '더 이상 사용할 수 없음'; + + @override + String get chatBlockRecommendedNextSteps => '권장 다음 단계'; } diff --git a/app/lib/l10n/app_localizations_lt.dart b/app/lib/l10n/app_localizations_lt.dart index bc3e6d45773..2e97e38fc80 100644 --- a/app/lib/l10n/app_localizations_lt.dart +++ b/app/lib/l10n/app_localizations_lt.dart @@ -9938,4 +9938,34 @@ class AppLocalizationsLt extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Užduotis'; + + @override + String get chatBlockGoal => 'Tikslas'; + + @override + String get chatBlockConversation => 'Pokalbis'; + + @override + String get chatBlockMemory => 'Prisiminimas'; + + @override + String get chatBlockQuestion => 'Klausimas'; + + @override + String get chatBlockOpenInGoals => 'Atidaryti skiltyje „Tikslai“'; + + @override + String get chatBlockOpenConversation => 'Atidaryti pokalbį'; + + @override + String get chatBlockOpenInMemories => 'Atidaryti skiltyje „Prisiminimai“'; + + @override + String get chatBlockUnavailable => 'Nebepasiekiama'; + + @override + String get chatBlockRecommendedNextSteps => 'Rekomenduojami tolesni veiksmai'; } diff --git a/app/lib/l10n/app_localizations_lv.dart b/app/lib/l10n/app_localizations_lv.dart index c0b5077ac26..4f3d8daf5e8 100644 --- a/app/lib/l10n/app_localizations_lv.dart +++ b/app/lib/l10n/app_localizations_lv.dart @@ -9942,4 +9942,34 @@ class AppLocalizationsLv extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Uzdevums'; + + @override + String get chatBlockGoal => 'Mērķis'; + + @override + String get chatBlockConversation => 'Saruna'; + + @override + String get chatBlockMemory => 'Atmiņa'; + + @override + String get chatBlockQuestion => 'Jautājums'; + + @override + String get chatBlockOpenInGoals => 'Atvērt sadaļā “Mērķi”'; + + @override + String get chatBlockOpenConversation => 'Atvērt sarunu'; + + @override + String get chatBlockOpenInMemories => 'Atvērt sadaļā “Atmiņas”'; + + @override + String get chatBlockUnavailable => 'Vairs nav pieejams'; + + @override + String get chatBlockRecommendedNextSteps => 'Ieteicamie nākamie soļi'; } diff --git a/app/lib/l10n/app_localizations_mk.dart b/app/lib/l10n/app_localizations_mk.dart index daf0e995181..f751308ce6c 100644 --- a/app/lib/l10n/app_localizations_mk.dart +++ b/app/lib/l10n/app_localizations_mk.dart @@ -9971,4 +9971,34 @@ class AppLocalizationsMk extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Задача'; + + @override + String get chatBlockGoal => 'Цел'; + + @override + String get chatBlockConversation => 'Разговор'; + + @override + String get chatBlockMemory => 'Спомен'; + + @override + String get chatBlockQuestion => 'Прашање'; + + @override + String get chatBlockOpenInGoals => 'Отвори во Цели'; + + @override + String get chatBlockOpenConversation => 'Отвори разговор'; + + @override + String get chatBlockOpenInMemories => 'Отвори во Спомени'; + + @override + String get chatBlockUnavailable => 'Веќе не е достапно'; + + @override + String get chatBlockRecommendedNextSteps => 'Препорачани следни чекори'; } diff --git a/app/lib/l10n/app_localizations_mr.dart b/app/lib/l10n/app_localizations_mr.dart index 7e6582f3c39..b32083f9d9b 100644 --- a/app/lib/l10n/app_localizations_mr.dart +++ b/app/lib/l10n/app_localizations_mr.dart @@ -9927,4 +9927,34 @@ class AppLocalizationsMr extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'कार्य'; + + @override + String get chatBlockGoal => 'ध्येय'; + + @override + String get chatBlockConversation => 'संभाषण'; + + @override + String get chatBlockMemory => 'स्मृती'; + + @override + String get chatBlockQuestion => 'प्रश्न'; + + @override + String get chatBlockOpenInGoals => 'ध्येयांमध्ये उघडा'; + + @override + String get chatBlockOpenConversation => 'संभाषण उघडा'; + + @override + String get chatBlockOpenInMemories => 'स्मृतींमध्ये उघडा'; + + @override + String get chatBlockUnavailable => 'आता उपलब्ध नाही'; + + @override + String get chatBlockRecommendedNextSteps => 'शिफारस केलेली पुढील पावले'; } diff --git a/app/lib/l10n/app_localizations_ms.dart b/app/lib/l10n/app_localizations_ms.dart index b1c2bcdff78..6f7124abbb5 100644 --- a/app/lib/l10n/app_localizations_ms.dart +++ b/app/lib/l10n/app_localizations_ms.dart @@ -9944,4 +9944,34 @@ class AppLocalizationsMs extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Tugas'; + + @override + String get chatBlockGoal => 'Matlamat'; + + @override + String get chatBlockConversation => 'Perbualan'; + + @override + String get chatBlockMemory => 'Memori'; + + @override + String get chatBlockQuestion => 'Soalan'; + + @override + String get chatBlockOpenInGoals => 'Buka dalam Matlamat'; + + @override + String get chatBlockOpenConversation => 'Buka perbualan'; + + @override + String get chatBlockOpenInMemories => 'Buka dalam Memori'; + + @override + String get chatBlockUnavailable => 'Tidak lagi tersedia'; + + @override + String get chatBlockRecommendedNextSteps => 'Langkah seterusnya yang disyorkan'; } diff --git a/app/lib/l10n/app_localizations_nl.dart b/app/lib/l10n/app_localizations_nl.dart index 20aec894a81..33de39798c4 100644 --- a/app/lib/l10n/app_localizations_nl.dart +++ b/app/lib/l10n/app_localizations_nl.dart @@ -9945,4 +9945,34 @@ class AppLocalizationsNl extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Taak'; + + @override + String get chatBlockGoal => 'Doel'; + + @override + String get chatBlockConversation => 'Gesprek'; + + @override + String get chatBlockMemory => 'Herinnering'; + + @override + String get chatBlockQuestion => 'Vraag'; + + @override + String get chatBlockOpenInGoals => 'Openen in Doelen'; + + @override + String get chatBlockOpenConversation => 'Gesprek openen'; + + @override + String get chatBlockOpenInMemories => 'Openen in Herinneringen'; + + @override + String get chatBlockUnavailable => 'Niet langer beschikbaar'; + + @override + String get chatBlockRecommendedNextSteps => 'Aanbevolen volgende stappen'; } diff --git a/app/lib/l10n/app_localizations_no.dart b/app/lib/l10n/app_localizations_no.dart index f77e837c581..d0ba3e57519 100644 --- a/app/lib/l10n/app_localizations_no.dart +++ b/app/lib/l10n/app_localizations_no.dart @@ -9916,4 +9916,34 @@ class AppLocalizationsNo extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Oppgave'; + + @override + String get chatBlockGoal => 'Mål'; + + @override + String get chatBlockConversation => 'Samtale'; + + @override + String get chatBlockMemory => 'Minne'; + + @override + String get chatBlockQuestion => 'Spørsmål'; + + @override + String get chatBlockOpenInGoals => 'Åpne i Mål'; + + @override + String get chatBlockOpenConversation => 'Åpne samtale'; + + @override + String get chatBlockOpenInMemories => 'Åpne i Minner'; + + @override + String get chatBlockUnavailable => 'Ikke lenger tilgjengelig'; + + @override + String get chatBlockRecommendedNextSteps => 'Anbefalte neste trinn'; } diff --git a/app/lib/l10n/app_localizations_pl.dart b/app/lib/l10n/app_localizations_pl.dart index ea189fbcbe3..f187b269893 100644 --- a/app/lib/l10n/app_localizations_pl.dart +++ b/app/lib/l10n/app_localizations_pl.dart @@ -9948,4 +9948,34 @@ class AppLocalizationsPl extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Zadanie'; + + @override + String get chatBlockGoal => 'Cel'; + + @override + String get chatBlockConversation => 'Rozmowa'; + + @override + String get chatBlockMemory => 'Wspomnienie'; + + @override + String get chatBlockQuestion => 'Pytanie'; + + @override + String get chatBlockOpenInGoals => 'Otwórz w Celach'; + + @override + String get chatBlockOpenConversation => 'Otwórz rozmowę'; + + @override + String get chatBlockOpenInMemories => 'Otwórz we Wspomnieniach'; + + @override + String get chatBlockUnavailable => 'Już niedostępne'; + + @override + String get chatBlockRecommendedNextSteps => 'Zalecane kolejne kroki'; } diff --git a/app/lib/l10n/app_localizations_pt.dart b/app/lib/l10n/app_localizations_pt.dart index ce4449c9d1e..60acb7e9389 100644 --- a/app/lib/l10n/app_localizations_pt.dart +++ b/app/lib/l10n/app_localizations_pt.dart @@ -9927,4 +9927,34 @@ class AppLocalizationsPt extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Tarefa'; + + @override + String get chatBlockGoal => 'Meta'; + + @override + String get chatBlockConversation => 'Conversa'; + + @override + String get chatBlockMemory => 'Memória'; + + @override + String get chatBlockQuestion => 'Pergunta'; + + @override + String get chatBlockOpenInGoals => 'Abrir em Metas'; + + @override + String get chatBlockOpenConversation => 'Abrir conversa'; + + @override + String get chatBlockOpenInMemories => 'Abrir em Memórias'; + + @override + String get chatBlockUnavailable => 'Já não está disponível'; + + @override + String get chatBlockRecommendedNextSteps => 'Próximos passos recomendados'; } diff --git a/app/lib/l10n/app_localizations_ro.dart b/app/lib/l10n/app_localizations_ro.dart index c4c8a84cbfe..130841db30d 100644 --- a/app/lib/l10n/app_localizations_ro.dart +++ b/app/lib/l10n/app_localizations_ro.dart @@ -9965,4 +9965,34 @@ class AppLocalizationsRo extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Sarcină'; + + @override + String get chatBlockGoal => 'Obiectiv'; + + @override + String get chatBlockConversation => 'Conversație'; + + @override + String get chatBlockMemory => 'Amintire'; + + @override + String get chatBlockQuestion => 'Întrebare'; + + @override + String get chatBlockOpenInGoals => 'Deschide în Obiective'; + + @override + String get chatBlockOpenConversation => 'Deschide conversația'; + + @override + String get chatBlockOpenInMemories => 'Deschide în Amintiri'; + + @override + String get chatBlockUnavailable => 'Nu mai este disponibil'; + + @override + String get chatBlockRecommendedNextSteps => 'Pașii următori recomandați'; } diff --git a/app/lib/l10n/app_localizations_ru.dart b/app/lib/l10n/app_localizations_ru.dart index 3fb0e30d12d..980ef06c929 100644 --- a/app/lib/l10n/app_localizations_ru.dart +++ b/app/lib/l10n/app_localizations_ru.dart @@ -9955,4 +9955,34 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Задача'; + + @override + String get chatBlockGoal => 'Цель'; + + @override + String get chatBlockConversation => 'Разговор'; + + @override + String get chatBlockMemory => 'Воспоминание'; + + @override + String get chatBlockQuestion => 'Вопрос'; + + @override + String get chatBlockOpenInGoals => 'Открыть в «Целях»'; + + @override + String get chatBlockOpenConversation => 'Открыть разговор'; + + @override + String get chatBlockOpenInMemories => 'Открыть в «Воспоминаниях»'; + + @override + String get chatBlockUnavailable => 'Больше недоступно'; + + @override + String get chatBlockRecommendedNextSteps => 'Рекомендуемые следующие шаги'; } diff --git a/app/lib/l10n/app_localizations_sk.dart b/app/lib/l10n/app_localizations_sk.dart index f7ce7e9ab91..9ffa6f04ea9 100644 --- a/app/lib/l10n/app_localizations_sk.dart +++ b/app/lib/l10n/app_localizations_sk.dart @@ -9911,4 +9911,34 @@ class AppLocalizationsSk extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Úloha'; + + @override + String get chatBlockGoal => 'Cieľ'; + + @override + String get chatBlockConversation => 'Konverzácia'; + + @override + String get chatBlockMemory => 'Spomienka'; + + @override + String get chatBlockQuestion => 'Otázka'; + + @override + String get chatBlockOpenInGoals => 'Otvoriť v Cieľoch'; + + @override + String get chatBlockOpenConversation => 'Otvoriť konverzáciu'; + + @override + String get chatBlockOpenInMemories => 'Otvoriť v Spomienkach'; + + @override + String get chatBlockUnavailable => 'Už nie je k dispozícii'; + + @override + String get chatBlockRecommendedNextSteps => 'Odporúčané ďalšie kroky'; } diff --git a/app/lib/l10n/app_localizations_sl.dart b/app/lib/l10n/app_localizations_sl.dart index 296c1ebc56a..287dbdde86b 100644 --- a/app/lib/l10n/app_localizations_sl.dart +++ b/app/lib/l10n/app_localizations_sl.dart @@ -9949,4 +9949,34 @@ class AppLocalizationsSl extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Opravilo'; + + @override + String get chatBlockGoal => 'Cilj'; + + @override + String get chatBlockConversation => 'Pogovor'; + + @override + String get chatBlockMemory => 'Spomin'; + + @override + String get chatBlockQuestion => 'Vprašanje'; + + @override + String get chatBlockOpenInGoals => 'Odpri v Ciljih'; + + @override + String get chatBlockOpenConversation => 'Odpri pogovor'; + + @override + String get chatBlockOpenInMemories => 'Odpri v Spominih'; + + @override + String get chatBlockUnavailable => 'Ni več na voljo'; + + @override + String get chatBlockRecommendedNextSteps => 'Priporočeni naslednji koraki'; } diff --git a/app/lib/l10n/app_localizations_sr.dart b/app/lib/l10n/app_localizations_sr.dart index 97639a3f6de..0ef42246670 100644 --- a/app/lib/l10n/app_localizations_sr.dart +++ b/app/lib/l10n/app_localizations_sr.dart @@ -9934,4 +9934,34 @@ class AppLocalizationsSr extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Задатак'; + + @override + String get chatBlockGoal => 'Циљ'; + + @override + String get chatBlockConversation => 'Разговор'; + + @override + String get chatBlockMemory => 'Сећање'; + + @override + String get chatBlockQuestion => 'Питање'; + + @override + String get chatBlockOpenInGoals => 'Отвори у Циљевима'; + + @override + String get chatBlockOpenConversation => 'Отвори разговор'; + + @override + String get chatBlockOpenInMemories => 'Отвори у Сећањима'; + + @override + String get chatBlockUnavailable => 'Више није доступно'; + + @override + String get chatBlockRecommendedNextSteps => 'Препоручени следећи кораци'; } diff --git a/app/lib/l10n/app_localizations_sv.dart b/app/lib/l10n/app_localizations_sv.dart index 6071ae74f2b..d46014c289e 100644 --- a/app/lib/l10n/app_localizations_sv.dart +++ b/app/lib/l10n/app_localizations_sv.dart @@ -9922,4 +9922,34 @@ class AppLocalizationsSv extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Uppgift'; + + @override + String get chatBlockGoal => 'Mål'; + + @override + String get chatBlockConversation => 'Konversation'; + + @override + String get chatBlockMemory => 'Minne'; + + @override + String get chatBlockQuestion => 'Fråga'; + + @override + String get chatBlockOpenInGoals => 'Öppna i Mål'; + + @override + String get chatBlockOpenConversation => 'Öppna konversation'; + + @override + String get chatBlockOpenInMemories => 'Öppna i Minnen'; + + @override + String get chatBlockUnavailable => 'Inte längre tillgänglig'; + + @override + String get chatBlockRecommendedNextSteps => 'Rekommenderade nästa steg'; } diff --git a/app/lib/l10n/app_localizations_ta.dart b/app/lib/l10n/app_localizations_ta.dart index ac209413a90..0c0ea9cc6e7 100644 --- a/app/lib/l10n/app_localizations_ta.dart +++ b/app/lib/l10n/app_localizations_ta.dart @@ -9988,4 +9988,34 @@ class AppLocalizationsTa extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'பணி'; + + @override + String get chatBlockGoal => 'இலக்கு'; + + @override + String get chatBlockConversation => 'உரையாடல்'; + + @override + String get chatBlockMemory => 'நினைவு'; + + @override + String get chatBlockQuestion => 'கேள்வி'; + + @override + String get chatBlockOpenInGoals => 'இலக்குகளில் திறக்க'; + + @override + String get chatBlockOpenConversation => 'உரையாடலைத் திறக்க'; + + @override + String get chatBlockOpenInMemories => 'நினைவுகளில் திறக்க'; + + @override + String get chatBlockUnavailable => 'இனி கிடைக்கவில்லை'; + + @override + String get chatBlockRecommendedNextSteps => 'பரிந்துரைக்கப்பட்ட அடுத்த படிகள்'; } diff --git a/app/lib/l10n/app_localizations_te.dart b/app/lib/l10n/app_localizations_te.dart index 89fdeead36c..567d0f3a11c 100644 --- a/app/lib/l10n/app_localizations_te.dart +++ b/app/lib/l10n/app_localizations_te.dart @@ -9967,4 +9967,34 @@ class AppLocalizationsTe extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'పని'; + + @override + String get chatBlockGoal => 'లక్ష్యం'; + + @override + String get chatBlockConversation => 'సంభాషణ'; + + @override + String get chatBlockMemory => 'జ్ఞాపకం'; + + @override + String get chatBlockQuestion => 'ప్రశ్న'; + + @override + String get chatBlockOpenInGoals => 'లక్ష్యాలలో తెరవండి'; + + @override + String get chatBlockOpenConversation => 'సంభాషణను తెరవండి'; + + @override + String get chatBlockOpenInMemories => 'జ్ఞాపకాలలో తెరవండి'; + + @override + String get chatBlockUnavailable => 'ఇకపై అందుబాటులో లేదు'; + + @override + String get chatBlockRecommendedNextSteps => 'సిఫార్సు చేసిన తదుపరి దశలు'; } diff --git a/app/lib/l10n/app_localizations_th.dart b/app/lib/l10n/app_localizations_th.dart index c1129445f08..22d083fc369 100644 --- a/app/lib/l10n/app_localizations_th.dart +++ b/app/lib/l10n/app_localizations_th.dart @@ -9861,4 +9861,34 @@ class AppLocalizationsTh extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'งาน'; + + @override + String get chatBlockGoal => 'เป้าหมาย'; + + @override + String get chatBlockConversation => 'บทสนทนา'; + + @override + String get chatBlockMemory => 'ความทรงจำ'; + + @override + String get chatBlockQuestion => 'คำถาม'; + + @override + String get chatBlockOpenInGoals => 'เปิดในเป้าหมาย'; + + @override + String get chatBlockOpenConversation => 'เปิดบทสนทนา'; + + @override + String get chatBlockOpenInMemories => 'เปิดในความทรงจำ'; + + @override + String get chatBlockUnavailable => 'ไม่พร้อมใช้งานอีกต่อไป'; + + @override + String get chatBlockRecommendedNextSteps => 'ขั้นตอนถัดไปที่แนะนำ'; } diff --git a/app/lib/l10n/app_localizations_tl.dart b/app/lib/l10n/app_localizations_tl.dart index 9f9ba6319ca..34da7d59b45 100644 --- a/app/lib/l10n/app_localizations_tl.dart +++ b/app/lib/l10n/app_localizations_tl.dart @@ -10009,4 +10009,34 @@ class AppLocalizationsTl extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Gawain'; + + @override + String get chatBlockGoal => 'Layunin'; + + @override + String get chatBlockConversation => 'Pag-uusap'; + + @override + String get chatBlockMemory => 'Alaala'; + + @override + String get chatBlockQuestion => 'Tanong'; + + @override + String get chatBlockOpenInGoals => 'Buksan sa Mga Layunin'; + + @override + String get chatBlockOpenConversation => 'Buksan ang pag-uusap'; + + @override + String get chatBlockOpenInMemories => 'Buksan sa Mga Alaala'; + + @override + String get chatBlockUnavailable => 'Hindi na available'; + + @override + String get chatBlockRecommendedNextSteps => 'Mga inirerekomendang susunod na hakbang'; } diff --git a/app/lib/l10n/app_localizations_tr.dart b/app/lib/l10n/app_localizations_tr.dart index 4f2745087df..c25176823b9 100644 --- a/app/lib/l10n/app_localizations_tr.dart +++ b/app/lib/l10n/app_localizations_tr.dart @@ -9930,4 +9930,34 @@ class AppLocalizationsTr extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Görev'; + + @override + String get chatBlockGoal => 'Hedef'; + + @override + String get chatBlockConversation => 'Konuşma'; + + @override + String get chatBlockMemory => 'Anı'; + + @override + String get chatBlockQuestion => 'Soru'; + + @override + String get chatBlockOpenInGoals => 'Hedefler’de aç'; + + @override + String get chatBlockOpenConversation => 'Konuşmayı aç'; + + @override + String get chatBlockOpenInMemories => 'Anılar’da aç'; + + @override + String get chatBlockUnavailable => 'Artık kullanılamıyor'; + + @override + String get chatBlockRecommendedNextSteps => 'Önerilen sonraki adımlar'; } diff --git a/app/lib/l10n/app_localizations_uk.dart b/app/lib/l10n/app_localizations_uk.dart index 5113917c8d3..a5ccfea7b3f 100644 --- a/app/lib/l10n/app_localizations_uk.dart +++ b/app/lib/l10n/app_localizations_uk.dart @@ -9940,4 +9940,34 @@ class AppLocalizationsUk extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Завдання'; + + @override + String get chatBlockGoal => 'Ціль'; + + @override + String get chatBlockConversation => 'Розмова'; + + @override + String get chatBlockMemory => 'Спогад'; + + @override + String get chatBlockQuestion => 'Питання'; + + @override + String get chatBlockOpenInGoals => 'Відкрити в «Цілях»'; + + @override + String get chatBlockOpenConversation => 'Відкрити розмову'; + + @override + String get chatBlockOpenInMemories => 'Відкрити у «Спогадах»'; + + @override + String get chatBlockUnavailable => 'Більше недоступно'; + + @override + String get chatBlockRecommendedNextSteps => 'Рекомендовані наступні кроки'; } diff --git a/app/lib/l10n/app_localizations_ur.dart b/app/lib/l10n/app_localizations_ur.dart index 264c91f7d60..ea3dff55fc7 100644 --- a/app/lib/l10n/app_localizations_ur.dart +++ b/app/lib/l10n/app_localizations_ur.dart @@ -9930,4 +9930,34 @@ class AppLocalizationsUr extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'کام'; + + @override + String get chatBlockGoal => 'ہدف'; + + @override + String get chatBlockConversation => 'گفتگو'; + + @override + String get chatBlockMemory => 'یاد'; + + @override + String get chatBlockQuestion => 'سوال'; + + @override + String get chatBlockOpenInGoals => 'اہداف میں کھولیں'; + + @override + String get chatBlockOpenConversation => 'گفتگو کھولیں'; + + @override + String get chatBlockOpenInMemories => 'یادوں میں کھولیں'; + + @override + String get chatBlockUnavailable => 'اب دستیاب نہیں'; + + @override + String get chatBlockRecommendedNextSteps => 'تجویز کردہ اگلے اقدامات'; } diff --git a/app/lib/l10n/app_localizations_vi.dart b/app/lib/l10n/app_localizations_vi.dart index 5820f211651..580d03ed74d 100644 --- a/app/lib/l10n/app_localizations_vi.dart +++ b/app/lib/l10n/app_localizations_vi.dart @@ -9913,4 +9913,34 @@ class AppLocalizationsVi extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => 'Nhiệm vụ'; + + @override + String get chatBlockGoal => 'Mục tiêu'; + + @override + String get chatBlockConversation => 'Cuộc trò chuyện'; + + @override + String get chatBlockMemory => 'Ký ức'; + + @override + String get chatBlockQuestion => 'Câu hỏi'; + + @override + String get chatBlockOpenInGoals => 'Mở trong Mục tiêu'; + + @override + String get chatBlockOpenConversation => 'Mở cuộc trò chuyện'; + + @override + String get chatBlockOpenInMemories => 'Mở trong Ký ức'; + + @override + String get chatBlockUnavailable => 'Không còn khả dụng'; + + @override + String get chatBlockRecommendedNextSteps => 'Các bước tiếp theo được đề xuất'; } diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index ff6b7bb401f..50ed174d780 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -9730,4 +9730,34 @@ class AppLocalizationsZh extends AppLocalizations { @override String get tapPlusToStartRecording => 'Tap + to start recording'; + + @override + String get chatBlockTask => '任务'; + + @override + String get chatBlockGoal => '目标'; + + @override + String get chatBlockConversation => '对话'; + + @override + String get chatBlockMemory => '记忆'; + + @override + String get chatBlockQuestion => '问题'; + + @override + String get chatBlockOpenInGoals => '在目标中打开'; + + @override + String get chatBlockOpenConversation => '打开对话'; + + @override + String get chatBlockOpenInMemories => '在记忆中打开'; + + @override + String get chatBlockUnavailable => '已不再可用'; + + @override + String get chatBlockRecommendedNextSteps => '建议的后续步骤'; } diff --git a/app/lib/l10n/app_lt.arb b/app/lib/l10n/app_lt.arb index 5e9b64ef4eb..2fa79af2a33 100644 --- a/app/lib/l10n/app_lt.arb +++ b/app/lib/l10n/app_lt.arb @@ -3234,5 +3234,15 @@ "pendantRecordingSyncBlocked": "Pendant vis dar įrašinėja, todėl išsaugoto garso perkelti negalima. Paspauskite Pendant mygtuką, kad sustabdytumėte įrašymą, tada sinchronizuokite dar kartą.", "pendantFullSyncBlocked": "Pendant atmintis pilna ir jis vis dar įrašymo režime, todėl išsaugoto garso perkelti negalima. Paspauskite Pendant mygtuką, kad sustabdytumėte įrašymą, tada sinchronizuokite iš naujo.", "conversationsNotCapturedCount": "Neįrašyta ({count})", - "transcriptionNoAudio": "Transkripcija negauna garso" + "transcriptionNoAudio": "Transkripcija negauna garso", + "chatBlockTask": "Užduotis", + "chatBlockGoal": "Tikslas", + "chatBlockConversation": "Pokalbis", + "chatBlockMemory": "Prisiminimas", + "chatBlockQuestion": "Klausimas", + "chatBlockOpenInGoals": "Atidaryti skiltyje „Tikslai“", + "chatBlockOpenConversation": "Atidaryti pokalbį", + "chatBlockOpenInMemories": "Atidaryti skiltyje „Prisiminimai“", + "chatBlockUnavailable": "Nebepasiekiama", + "chatBlockRecommendedNextSteps": "Rekomenduojami tolesni veiksmai" } diff --git a/app/lib/l10n/app_lv.arb b/app/lib/l10n/app_lv.arb index 309ff91428e..6065a5420c3 100644 --- a/app/lib/l10n/app_lv.arb +++ b/app/lib/l10n/app_lv.arb @@ -3234,5 +3234,15 @@ "pendantRecordingSyncBlocked": "Pendant joprojām ieraksta, tāpēc saglabāto audio nevar pārsūtīt. Nospiediet Pendant pogu, lai apturētu ierakstīšanu, un pēc tam sinhronizējiet vēlreiz.", "pendantFullSyncBlocked": "Pendant atmiņa ir pilna, un tas joprojām ir ierakstīšanas režīmā, tāpēc saglabāto audio nevar pārsūtīt. Nospiediet Pendant pogu, lai apturētu ierakstīšanu, un pēc tam sinhronizējiet vēlreiz.", "conversationsNotCapturedCount": "Nav ierakstīts ({count})", - "transcriptionNoAudio": "Transkripcija nesaņem audio" + "transcriptionNoAudio": "Transkripcija nesaņem audio", + "chatBlockTask": "Uzdevums", + "chatBlockGoal": "Mērķis", + "chatBlockConversation": "Saruna", + "chatBlockMemory": "Atmiņa", + "chatBlockQuestion": "Jautājums", + "chatBlockOpenInGoals": "Atvērt sadaļā “Mērķi”", + "chatBlockOpenConversation": "Atvērt sarunu", + "chatBlockOpenInMemories": "Atvērt sadaļā “Atmiņas”", + "chatBlockUnavailable": "Vairs nav pieejams", + "chatBlockRecommendedNextSteps": "Ieteicamie nākamie soļi" } diff --git a/app/lib/l10n/app_mk.arb b/app/lib/l10n/app_mk.arb index fab55a326b8..17418f68261 100644 --- a/app/lib/l10n/app_mk.arb +++ b/app/lib/l10n/app_mk.arb @@ -10800,5 +10800,15 @@ "pendantRecordingSyncBlocked": "Pendant сè уште снима, па складираното аудио не може да се пренесе. Притиснете го копчето на Pendant за да го запрете снимањето, а потоа синхронизирајте повторно.", "pendantFullSyncBlocked": "Меморијата на Pendant е полна и тој сè уште е во режим на снимање, па зачуваното аудио не може да се пренесе. Притиснете го копчето на Pendant за да го запрете снимањето, а потоа синхронизирајте повторно.", "conversationsNotCapturedCount": "Не е снимено ({count})", - "transcriptionNoAudio": "Транскрипцијата не прима аудио" + "transcriptionNoAudio": "Транскрипцијата не прима аудио", + "chatBlockTask": "Задача", + "chatBlockGoal": "Цел", + "chatBlockConversation": "Разговор", + "chatBlockMemory": "Спомен", + "chatBlockQuestion": "Прашање", + "chatBlockOpenInGoals": "Отвори во Цели", + "chatBlockOpenConversation": "Отвори разговор", + "chatBlockOpenInMemories": "Отвори во Спомени", + "chatBlockUnavailable": "Веќе не е достапно", + "chatBlockRecommendedNextSteps": "Препорачани следни чекори" } diff --git a/app/lib/l10n/app_mr.arb b/app/lib/l10n/app_mr.arb index d3735c38ba7..8a061834922 100644 --- a/app/lib/l10n/app_mr.arb +++ b/app/lib/l10n/app_mr.arb @@ -10800,5 +10800,15 @@ "pendantRecordingSyncBlocked": "Pendant अजूनही रेकॉर्ड करत आहे, त्यामुळे साठवलेला ऑडिओ हस्तांतरित करता येत नाही. रेकॉर्डिंग थांबवण्यासाठी Pendant चे बटण दाबा, नंतर पुन्हा सिंक करा.", "pendantFullSyncBlocked": "Pendant चे स्टोरेज भरले आहे आणि ते अजूनही रेकॉर्डिंग मोडमध्ये आहे, त्यामुळे साठवलेला ऑडिओ हस्तांतरित करता येत नाही. रेकॉर्डिंग थांबवण्यासाठी Pendant चे बटण दाबा, नंतर पुन्हा सिंक करा.", "conversationsNotCapturedCount": "रेकॉर्ड झाले नाही ({count})", - "transcriptionNoAudio": "ट्रान्सक्रिप्शन ऑडिओ घेत नाही" + "transcriptionNoAudio": "ट्रान्सक्रिप्शन ऑडिओ घेत नाही", + "chatBlockTask": "कार्य", + "chatBlockGoal": "ध्येय", + "chatBlockConversation": "संभाषण", + "chatBlockMemory": "स्मृती", + "chatBlockQuestion": "प्रश्न", + "chatBlockOpenInGoals": "ध्येयांमध्ये उघडा", + "chatBlockOpenConversation": "संभाषण उघडा", + "chatBlockOpenInMemories": "स्मृतींमध्ये उघडा", + "chatBlockUnavailable": "आता उपलब्ध नाही", + "chatBlockRecommendedNextSteps": "शिफारस केलेली पुढील पावले" } diff --git a/app/lib/l10n/app_ms.arb b/app/lib/l10n/app_ms.arb index 163adc81e17..91856f04117 100644 --- a/app/lib/l10n/app_ms.arb +++ b/app/lib/l10n/app_ms.arb @@ -3234,5 +3234,15 @@ "pendantRecordingSyncBlocked": "Pendant masih merakam, jadi audio yang tersimpan tidak dapat dipindahkan. Tekan butang Pendant untuk menghentikan rakaman, kemudian segerakkan semula.", "pendantFullSyncBlocked": "Storan Pendant penuh dan ia masih dalam mod rakaman, jadi audio yang tersimpan tidak dapat dipindahkan. Tekan butang Pendant untuk menghentikan rakaman, kemudian segerakkan semula.", "conversationsNotCapturedCount": "Tidak dirakam ({count})", - "transcriptionNoAudio": "Transkripsi tidak menerima audio" + "transcriptionNoAudio": "Transkripsi tidak menerima audio", + "chatBlockTask": "Tugas", + "chatBlockGoal": "Matlamat", + "chatBlockConversation": "Perbualan", + "chatBlockMemory": "Memori", + "chatBlockQuestion": "Soalan", + "chatBlockOpenInGoals": "Buka dalam Matlamat", + "chatBlockOpenConversation": "Buka perbualan", + "chatBlockOpenInMemories": "Buka dalam Memori", + "chatBlockUnavailable": "Tidak lagi tersedia", + "chatBlockRecommendedNextSteps": "Langkah seterusnya yang disyorkan" } diff --git a/app/lib/l10n/app_nl.arb b/app/lib/l10n/app_nl.arb index 11aa28c09bc..7cfd3f79c61 100644 --- a/app/lib/l10n/app_nl.arb +++ b/app/lib/l10n/app_nl.arb @@ -3234,5 +3234,15 @@ "pendantRecordingSyncBlocked": "Je Pendant is nog aan het opnemen, dus de opgeslagen audio kan niet worden overgezet. Druk op de knop van de Pendant om de opname te stoppen en synchroniseer opnieuw.", "pendantFullSyncBlocked": "De opslag van je Pendant is vol en hij staat nog in de opnamemodus, dus de opgeslagen audio kan niet worden overgedragen. Druk op de knop van de Pendant om de opname te stoppen en synchroniseer daarna opnieuw.", "conversationsNotCapturedCount": "Niet opgenomen ({count})", - "transcriptionNoAudio": "Transcriptie ontvangt geen audio" + "transcriptionNoAudio": "Transcriptie ontvangt geen audio", + "chatBlockTask": "Taak", + "chatBlockGoal": "Doel", + "chatBlockConversation": "Gesprek", + "chatBlockMemory": "Herinnering", + "chatBlockQuestion": "Vraag", + "chatBlockOpenInGoals": "Openen in Doelen", + "chatBlockOpenConversation": "Gesprek openen", + "chatBlockOpenInMemories": "Openen in Herinneringen", + "chatBlockUnavailable": "Niet langer beschikbaar", + "chatBlockRecommendedNextSteps": "Aanbevolen volgende stappen" } diff --git a/app/lib/l10n/app_no.arb b/app/lib/l10n/app_no.arb index a120837a187..ee6bdeca6f2 100644 --- a/app/lib/l10n/app_no.arb +++ b/app/lib/l10n/app_no.arb @@ -3234,5 +3234,15 @@ "pendantRecordingSyncBlocked": "Pendant tar fortsatt opp, så den lagrede lyden kan ikke overføres. Trykk på knappen på Pendant for å stoppe opptaket, og synkroniser på nytt.", "pendantFullSyncBlocked": "Lagringen på Pendant er full, og den er fortsatt i opptaksmodus, så den lagrede lyden kan ikke overføres. Trykk på knappen på Pendant for å stoppe opptaket, og synkroniser på nytt.", "conversationsNotCapturedCount": "Ikke fanget opp ({count})", - "transcriptionNoAudio": "Transkripsjon mottar ikke lyd" + "transcriptionNoAudio": "Transkripsjon mottar ikke lyd", + "chatBlockTask": "Oppgave", + "chatBlockGoal": "Mål", + "chatBlockConversation": "Samtale", + "chatBlockMemory": "Minne", + "chatBlockQuestion": "Spørsmål", + "chatBlockOpenInGoals": "Åpne i Mål", + "chatBlockOpenConversation": "Åpne samtale", + "chatBlockOpenInMemories": "Åpne i Minner", + "chatBlockUnavailable": "Ikke lenger tilgjengelig", + "chatBlockRecommendedNextSteps": "Anbefalte neste trinn" } diff --git a/app/lib/l10n/app_pl.arb b/app/lib/l10n/app_pl.arb index 7adfc3c4340..deb7a44abfd 100644 --- a/app/lib/l10n/app_pl.arb +++ b/app/lib/l10n/app_pl.arb @@ -3269,5 +3269,15 @@ "pendantRecordingSyncBlocked": "Pendant wciąż nagrywa, więc zapisany dźwięk nie może zostać przesłany. Naciśnij przycisk Pendanta, aby zatrzymać nagrywanie, a następnie zsynchronizuj ponownie.", "pendantFullSyncBlocked": "Pamięć Pendanta jest pełna i wciąż jest on w trybie nagrywania, więc zapisanego dźwięku nie można przenieść. Naciśnij przycisk Pendanta, aby zatrzymać nagrywanie, a następnie zsynchronizuj ponownie.", "conversationsNotCapturedCount": "Nie nagrano ({count})", - "transcriptionNoAudio": "Transkrypcja nie odbiera dźwięku" + "transcriptionNoAudio": "Transkrypcja nie odbiera dźwięku", + "chatBlockTask": "Zadanie", + "chatBlockGoal": "Cel", + "chatBlockConversation": "Rozmowa", + "chatBlockMemory": "Wspomnienie", + "chatBlockQuestion": "Pytanie", + "chatBlockOpenInGoals": "Otwórz w Celach", + "chatBlockOpenConversation": "Otwórz rozmowę", + "chatBlockOpenInMemories": "Otwórz we Wspomnieniach", + "chatBlockUnavailable": "Już niedostępne", + "chatBlockRecommendedNextSteps": "Zalecane kolejne kroki" } diff --git a/app/lib/l10n/app_pt.arb b/app/lib/l10n/app_pt.arb index 8f9384d58fb..d82868e58e6 100644 --- a/app/lib/l10n/app_pt.arb +++ b/app/lib/l10n/app_pt.arb @@ -3270,5 +3270,15 @@ "pendantRecordingSyncBlocked": "O Pendant ainda está gravando, então o áudio armazenado não pode ser transferido. Pressione o botão do Pendant para parar a gravação e sincronize novamente.", "pendantFullSyncBlocked": "O armazenamento do Pendant está cheio e ele ainda está no modo de gravação, então o áudio armazenado não pode ser transferido. Pressione o botão do Pendant para parar a gravação e sincronize novamente.", "conversationsNotCapturedCount": "Não capturado ({count})", - "transcriptionNoAudio": "A transcrição não está recebendo áudio" + "transcriptionNoAudio": "A transcrição não está recebendo áudio", + "chatBlockTask": "Tarefa", + "chatBlockGoal": "Meta", + "chatBlockConversation": "Conversa", + "chatBlockMemory": "Memória", + "chatBlockQuestion": "Pergunta", + "chatBlockOpenInGoals": "Abrir em Metas", + "chatBlockOpenConversation": "Abrir conversa", + "chatBlockOpenInMemories": "Abrir em Memórias", + "chatBlockUnavailable": "Já não está disponível", + "chatBlockRecommendedNextSteps": "Próximos passos recomendados" } diff --git a/app/lib/l10n/app_ro.arb b/app/lib/l10n/app_ro.arb index e5d062a7f18..8fc46d49c5f 100644 --- a/app/lib/l10n/app_ro.arb +++ b/app/lib/l10n/app_ro.arb @@ -3234,5 +3234,15 @@ "pendantRecordingSyncBlocked": "Pendant încă înregistrează, așa că sunetul stocat nu poate fi transferat. Apasă butonul Pendant pentru a opri înregistrarea, apoi sincronizează din nou.", "pendantFullSyncBlocked": "Spațiul de stocare al Pendantului este plin și acesta este încă în modul de înregistrare, așa că audio-ul stocat nu poate fi transferat. Apăsați butonul Pendantului pentru a opri înregistrarea, apoi sincronizați din nou.", "conversationsNotCapturedCount": "Neînregistrat ({count})", - "transcriptionNoAudio": "Transcrierea nu primește audio" + "transcriptionNoAudio": "Transcrierea nu primește audio", + "chatBlockTask": "Sarcină", + "chatBlockGoal": "Obiectiv", + "chatBlockConversation": "Conversație", + "chatBlockMemory": "Amintire", + "chatBlockQuestion": "Întrebare", + "chatBlockOpenInGoals": "Deschide în Obiective", + "chatBlockOpenConversation": "Deschide conversația", + "chatBlockOpenInMemories": "Deschide în Amintiri", + "chatBlockUnavailable": "Nu mai este disponibil", + "chatBlockRecommendedNextSteps": "Pașii următori recomandați" } diff --git a/app/lib/l10n/app_ru.arb b/app/lib/l10n/app_ru.arb index 2454ce4dd0d..6e6158a2343 100644 --- a/app/lib/l10n/app_ru.arb +++ b/app/lib/l10n/app_ru.arb @@ -3269,5 +3269,15 @@ "pendantRecordingSyncBlocked": "Pendant всё ещё ведёт запись, поэтому сохранённое аудио нельзя передать. Нажмите кнопку Pendant, чтобы остановить запись, затем синхронизируйте снова.", "pendantFullSyncBlocked": "Память Pendant заполнена, и он всё ещё в режиме записи, поэтому сохранённое аудио нельзя передать. Нажмите кнопку Pendant, чтобы остановить запись, затем синхронизируйте снова.", "conversationsNotCapturedCount": "Не записано ({count})", - "transcriptionNoAudio": "Транскрипция не получает аудио" + "transcriptionNoAudio": "Транскрипция не получает аудио", + "chatBlockTask": "Задача", + "chatBlockGoal": "Цель", + "chatBlockConversation": "Разговор", + "chatBlockMemory": "Воспоминание", + "chatBlockQuestion": "Вопрос", + "chatBlockOpenInGoals": "Открыть в «Целях»", + "chatBlockOpenConversation": "Открыть разговор", + "chatBlockOpenInMemories": "Открыть в «Воспоминаниях»", + "chatBlockUnavailable": "Больше недоступно", + "chatBlockRecommendedNextSteps": "Рекомендуемые следующие шаги" } diff --git a/app/lib/l10n/app_sk.arb b/app/lib/l10n/app_sk.arb index 27f840ca6f3..ba0a3ae8e7b 100644 --- a/app/lib/l10n/app_sk.arb +++ b/app/lib/l10n/app_sk.arb @@ -3239,5 +3239,15 @@ "pendantRecordingSyncBlocked": "Pendant stále nahráva, takže uložený zvuk nie je možné preniesť. Stlačením tlačidla na Pendante zastavte nahrávanie a potom synchronizujte znova.", "pendantFullSyncBlocked": "Úložisko Pendantu je plné a stále je v režime nahrávania, takže uložený zvuk nemožno preniesť. Stlačením tlačidla na Pendante zastavte nahrávanie a potom znova synchronizujte.", "conversationsNotCapturedCount": "Nezaznamenané ({count})", - "transcriptionNoAudio": "Transkripcia neprijíma zvuk" + "transcriptionNoAudio": "Transkripcia neprijíma zvuk", + "chatBlockTask": "Úloha", + "chatBlockGoal": "Cieľ", + "chatBlockConversation": "Konverzácia", + "chatBlockMemory": "Spomienka", + "chatBlockQuestion": "Otázka", + "chatBlockOpenInGoals": "Otvoriť v Cieľoch", + "chatBlockOpenConversation": "Otvoriť konverzáciu", + "chatBlockOpenInMemories": "Otvoriť v Spomienkach", + "chatBlockUnavailable": "Už nie je k dispozícii", + "chatBlockRecommendedNextSteps": "Odporúčané ďalšie kroky" } diff --git a/app/lib/l10n/app_sl.arb b/app/lib/l10n/app_sl.arb index 8fd389940ea..843ad7fedc1 100644 --- a/app/lib/l10n/app_sl.arb +++ b/app/lib/l10n/app_sl.arb @@ -10800,5 +10800,15 @@ "pendantRecordingSyncBlocked": "Pendant še vedno snema, zato shranjenega zvoka ni mogoče prenesti. Pritisnite gumb na Pendantu, da ustavite snemanje, nato znova sinhronizirajte.", "pendantFullSyncBlocked": "Pomnilnik Pendanta je poln in je še vedno v načinu snemanja, zato shranjenega zvoka ni mogoče prenesti. Pritisnite gumb na Pendantu, da ustavite snemanje, nato znova sinhronizirajte.", "conversationsNotCapturedCount": "Ni posneto ({count})", - "transcriptionNoAudio": "Transkripcija ne prejema zvoka" + "transcriptionNoAudio": "Transkripcija ne prejema zvoka", + "chatBlockTask": "Opravilo", + "chatBlockGoal": "Cilj", + "chatBlockConversation": "Pogovor", + "chatBlockMemory": "Spomin", + "chatBlockQuestion": "Vprašanje", + "chatBlockOpenInGoals": "Odpri v Ciljih", + "chatBlockOpenConversation": "Odpri pogovor", + "chatBlockOpenInMemories": "Odpri v Spominih", + "chatBlockUnavailable": "Ni več na voljo", + "chatBlockRecommendedNextSteps": "Priporočeni naslednji koraki" } diff --git a/app/lib/l10n/app_sr.arb b/app/lib/l10n/app_sr.arb index 03643d2a738..98784aa7886 100644 --- a/app/lib/l10n/app_sr.arb +++ b/app/lib/l10n/app_sr.arb @@ -10800,5 +10800,15 @@ "pendantRecordingSyncBlocked": "Pendant и даље снима, па сачувани звук не може да се пренесе. Притисните дугме на Pendant-у да зауставите снимање, а затим поново синхронизујте.", "pendantFullSyncBlocked": "Меморија Pendant-а је пуна и он је и даље у режиму снимања, па сачувани звук не може да се пренесе. Притисните дугме на Pendant-у да зауставите снимање, а затим поново синхронизујте.", "conversationsNotCapturedCount": "Није снимљено ({count})", - "transcriptionNoAudio": "Транскрипција не прима аудио" + "transcriptionNoAudio": "Транскрипција не прима аудио", + "chatBlockTask": "Задатак", + "chatBlockGoal": "Циљ", + "chatBlockConversation": "Разговор", + "chatBlockMemory": "Сећање", + "chatBlockQuestion": "Питање", + "chatBlockOpenInGoals": "Отвори у Циљевима", + "chatBlockOpenConversation": "Отвори разговор", + "chatBlockOpenInMemories": "Отвори у Сећањима", + "chatBlockUnavailable": "Више није доступно", + "chatBlockRecommendedNextSteps": "Препоручени следећи кораци" } diff --git a/app/lib/l10n/app_sv.arb b/app/lib/l10n/app_sv.arb index 63c9de4cb35..d3e24e770de 100644 --- a/app/lib/l10n/app_sv.arb +++ b/app/lib/l10n/app_sv.arb @@ -3234,5 +3234,15 @@ "pendantRecordingSyncBlocked": "Pendant spelar fortfarande in, så det lagrade ljudet kan inte överföras. Tryck på Pendantens knapp för att stoppa inspelningen och synkronisera igen.", "pendantFullSyncBlocked": "Lagringen på din Pendant är full och den är fortfarande i inspelningsläge, så det lagrade ljudet kan inte överföras. Tryck på Pendantens knapp för att stoppa inspelningen och synkronisera sedan igen.", "conversationsNotCapturedCount": "Inte fångat ({count})", - "transcriptionNoAudio": "Transkrieringen tar inte emot ljud" + "transcriptionNoAudio": "Transkrieringen tar inte emot ljud", + "chatBlockTask": "Uppgift", + "chatBlockGoal": "Mål", + "chatBlockConversation": "Konversation", + "chatBlockMemory": "Minne", + "chatBlockQuestion": "Fråga", + "chatBlockOpenInGoals": "Öppna i Mål", + "chatBlockOpenConversation": "Öppna konversation", + "chatBlockOpenInMemories": "Öppna i Minnen", + "chatBlockUnavailable": "Inte längre tillgänglig", + "chatBlockRecommendedNextSteps": "Rekommenderade nästa steg" } diff --git a/app/lib/l10n/app_ta.arb b/app/lib/l10n/app_ta.arb index 3c701df4c43..82d40731f0a 100644 --- a/app/lib/l10n/app_ta.arb +++ b/app/lib/l10n/app_ta.arb @@ -10800,5 +10800,15 @@ "pendantRecordingSyncBlocked": "Pendant இன்னும் பதிவு செய்து கொண்டிருக்கிறது, எனவே சேமிக்கப்பட்ட ஆடியோவை மாற்ற முடியாது. பதிவை நிறுத்த Pendant பொத்தானை அழுத்தி, பிறகு மீண்டும் ஒத்திசைக்கவும்.", "pendantFullSyncBlocked": "Pendant-இன் சேமிப்பகம் நிரம்பிவிட்டது, அது இன்னும் பதிவு பயன்முறையில் உள்ளது, எனவே சேமிக்கப்பட்ட ஆடியோவை மாற்ற முடியாது. பதிவை நிறுத்த Pendant-இன் பொத்தானை அழுத்தி, பின்னர் மீண்டும் ஒத்திசைக்கவும்.", "conversationsNotCapturedCount": "பதிவு செய்யப்படவில்லை ({count})", - "transcriptionNoAudio": "நகலெடுப்பு ஆடியோவைப் பெறவில்லை" + "transcriptionNoAudio": "நகலெடுப்பு ஆடியோவைப் பெறவில்லை", + "chatBlockTask": "பணி", + "chatBlockGoal": "இலக்கு", + "chatBlockConversation": "உரையாடல்", + "chatBlockMemory": "நினைவு", + "chatBlockQuestion": "கேள்வி", + "chatBlockOpenInGoals": "இலக்குகளில் திறக்க", + "chatBlockOpenConversation": "உரையாடலைத் திறக்க", + "chatBlockOpenInMemories": "நினைவுகளில் திறக்க", + "chatBlockUnavailable": "இனி கிடைக்கவில்லை", + "chatBlockRecommendedNextSteps": "பரிந்துரைக்கப்பட்ட அடுத்த படிகள்" } diff --git a/app/lib/l10n/app_te.arb b/app/lib/l10n/app_te.arb index 8f267ad026d..650be3a9cee 100644 --- a/app/lib/l10n/app_te.arb +++ b/app/lib/l10n/app_te.arb @@ -10800,5 +10800,15 @@ "pendantRecordingSyncBlocked": "Pendant ఇంకా రికార్డ్ చేస్తోంది, కాబట్టి నిల్వ చేసిన ఆడియోను బదిలీ చేయలేము. రికార్డింగ్ ఆపడానికి Pendant బటన్ నొక్కి, ఆపై మళ్లీ సింక్ చేయండి.", "pendantFullSyncBlocked": "Pendant నిల్వ నిండిపోయింది మరియు అది ఇంకా రికార్డింగ్ మోడ్‌లో ఉంది, కాబట్టి నిల్వ చేసిన ఆడియోను బదిలీ చేయడం సాధ్యం కాదు. రికార్డింగ్ ఆపడానికి Pendant బటన్‌ను నొక్కి, ఆపై మళ్లీ సింక్ చేయండి.", "conversationsNotCapturedCount": "రికార్డ్ కాలేదు ({count})", - "transcriptionNoAudio": "ట్రాన్స్‌క్రిప్షన్ ఆడియో స్వీకరించడం లేదు" + "transcriptionNoAudio": "ట్రాన్స్‌క్రిప్షన్ ఆడియో స్వీకరించడం లేదు", + "chatBlockTask": "పని", + "chatBlockGoal": "లక్ష్యం", + "chatBlockConversation": "సంభాషణ", + "chatBlockMemory": "జ్ఞాపకం", + "chatBlockQuestion": "ప్రశ్న", + "chatBlockOpenInGoals": "లక్ష్యాలలో తెరవండి", + "chatBlockOpenConversation": "సంభాషణను తెరవండి", + "chatBlockOpenInMemories": "జ్ఞాపకాలలో తెరవండి", + "chatBlockUnavailable": "ఇకపై అందుబాటులో లేదు", + "chatBlockRecommendedNextSteps": "సిఫార్సు చేసిన తదుపరి దశలు" } diff --git a/app/lib/l10n/app_th.arb b/app/lib/l10n/app_th.arb index f44dc8624c0..4592edc4c52 100644 --- a/app/lib/l10n/app_th.arb +++ b/app/lib/l10n/app_th.arb @@ -3234,5 +3234,15 @@ "pendantRecordingSyncBlocked": "Pendant ยังบันทึกอยู่ จึงไม่สามารถถ่ายโอนเสียงที่จัดเก็บไว้ได้ กดปุ่มของ Pendant เพื่อหยุดบันทึก แล้วซิงค์อีกครั้ง", "pendantFullSyncBlocked": "พื้นที่จัดเก็บของ Pendant เต็มและยังอยู่ในโหมดบันทึกเสียง จึงไม่สามารถถ่ายโอนเสียงที่บันทึกไว้ได้ กดปุ่มของ Pendant เพื่อหยุดการบันทึก แล้วซิงค์อีกครั้ง", "conversationsNotCapturedCount": "ไม่ได้บันทึก ({count})", - "transcriptionNoAudio": "การถอดเสียงไม่ได้รับเสียง" + "transcriptionNoAudio": "การถอดเสียงไม่ได้รับเสียง", + "chatBlockTask": "งาน", + "chatBlockGoal": "เป้าหมาย", + "chatBlockConversation": "บทสนทนา", + "chatBlockMemory": "ความทรงจำ", + "chatBlockQuestion": "คำถาม", + "chatBlockOpenInGoals": "เปิดในเป้าหมาย", + "chatBlockOpenConversation": "เปิดบทสนทนา", + "chatBlockOpenInMemories": "เปิดในความทรงจำ", + "chatBlockUnavailable": "ไม่พร้อมใช้งานอีกต่อไป", + "chatBlockRecommendedNextSteps": "ขั้นตอนถัดไปที่แนะนำ" } diff --git a/app/lib/l10n/app_tl.arb b/app/lib/l10n/app_tl.arb index f0c161b19af..6bdb7a9d8cb 100644 --- a/app/lib/l10n/app_tl.arb +++ b/app/lib/l10n/app_tl.arb @@ -10800,5 +10800,15 @@ "pendantRecordingSyncBlocked": "Nagre-record pa rin ang Pendant, kaya hindi mailipat ang naka-imbak na audio. Pindutin ang button ng Pendant para ihinto ang pag-record, pagkatapos ay mag-sync muli.", "pendantFullSyncBlocked": "Puno na ang storage ng Pendant at nasa recording mode pa rin ito, kaya hindi mailipat ang naka-imbak na audio. Pindutin ang button ng Pendant para ihinto ang pag-record, pagkatapos ay mag-sync muli.", "conversationsNotCapturedCount": "Hindi na-record ({count})", - "transcriptionNoAudio": "Hindi tumatanggap ng audio ang transkripsyon" + "transcriptionNoAudio": "Hindi tumatanggap ng audio ang transkripsyon", + "chatBlockTask": "Gawain", + "chatBlockGoal": "Layunin", + "chatBlockConversation": "Pag-uusap", + "chatBlockMemory": "Alaala", + "chatBlockQuestion": "Tanong", + "chatBlockOpenInGoals": "Buksan sa Mga Layunin", + "chatBlockOpenConversation": "Buksan ang pag-uusap", + "chatBlockOpenInMemories": "Buksan sa Mga Alaala", + "chatBlockUnavailable": "Hindi na available", + "chatBlockRecommendedNextSteps": "Mga inirerekomendang susunod na hakbang" } diff --git a/app/lib/l10n/app_tr.arb b/app/lib/l10n/app_tr.arb index 0241ee2e363..f8fc24da082 100644 --- a/app/lib/l10n/app_tr.arb +++ b/app/lib/l10n/app_tr.arb @@ -3269,5 +3269,15 @@ "pendantRecordingSyncBlocked": "Pendant hâlâ kayıt yapıyor, bu yüzden depolanan ses aktarılamıyor. Kaydı durdurmak için Pendant'ın düğmesine basın, ardından yeniden senkronize edin.", "pendantFullSyncBlocked": "Pendant'ın depolama alanı dolu ve hâlâ kayıt modunda olduğu için kayıtlı ses aktarılamıyor. Kaydı durdurmak için Pendant'ın düğmesine basın, ardından yeniden senkronize edin.", "conversationsNotCapturedCount": "Kaydedilmedi ({count})", - "transcriptionNoAudio": "Transkripsiyon ses almıyor" + "transcriptionNoAudio": "Transkripsiyon ses almıyor", + "chatBlockTask": "Görev", + "chatBlockGoal": "Hedef", + "chatBlockConversation": "Konuşma", + "chatBlockMemory": "Anı", + "chatBlockQuestion": "Soru", + "chatBlockOpenInGoals": "Hedefler’de aç", + "chatBlockOpenConversation": "Konuşmayı aç", + "chatBlockOpenInMemories": "Anılar’da aç", + "chatBlockUnavailable": "Artık kullanılamıyor", + "chatBlockRecommendedNextSteps": "Önerilen sonraki adımlar" } diff --git a/app/lib/l10n/app_uk.arb b/app/lib/l10n/app_uk.arb index c43dc156a3e..a8f88914905 100644 --- a/app/lib/l10n/app_uk.arb +++ b/app/lib/l10n/app_uk.arb @@ -3234,5 +3234,15 @@ "pendantRecordingSyncBlocked": "Pendant усе ще записує, тому збережене аудіо не можна передати. Натисніть кнопку Pendant, щоб зупинити запис, а потім синхронізуйте знову.", "pendantFullSyncBlocked": "Пам'ять Pendant заповнена, і він досі в режимі запису, тому збережене аудіо не можна передати. Натисніть кнопку Pendant, щоб зупинити запис, а потім синхронізуйте знову.", "conversationsNotCapturedCount": "Не записано ({count})", - "transcriptionNoAudio": "Транскрипція не отримує аудіо" + "transcriptionNoAudio": "Транскрипція не отримує аудіо", + "chatBlockTask": "Завдання", + "chatBlockGoal": "Ціль", + "chatBlockConversation": "Розмова", + "chatBlockMemory": "Спогад", + "chatBlockQuestion": "Питання", + "chatBlockOpenInGoals": "Відкрити в «Цілях»", + "chatBlockOpenConversation": "Відкрити розмову", + "chatBlockOpenInMemories": "Відкрити у «Спогадах»", + "chatBlockUnavailable": "Більше недоступно", + "chatBlockRecommendedNextSteps": "Рекомендовані наступні кроки" } diff --git a/app/lib/l10n/app_ur.arb b/app/lib/l10n/app_ur.arb index fe6951c0cc5..9048a26fdc8 100644 --- a/app/lib/l10n/app_ur.arb +++ b/app/lib/l10n/app_ur.arb @@ -10800,5 +10800,15 @@ "pendantRecordingSyncBlocked": "Pendant ابھی بھی ریکارڈ کر رہا ہے، اس لیے محفوظ شدہ آڈیو منتقل نہیں کی جا سکتی۔ ریکارڈنگ روکنے کے لیے Pendant کا بٹن دبائیں، پھر دوبارہ مطابقت پذیری کریں۔", "pendantFullSyncBlocked": "Pendant کی اسٹوریج بھر گئی ہے اور یہ ابھی بھی ریکارڈنگ موڈ میں ہے، اس لیے محفوظ شدہ آڈیو منتقل نہیں کی جا سکتی۔ ریکارڈنگ روکنے کے لیے Pendant کا بٹن دبائیں، پھر دوبارہ مطابقت پذیری کریں۔", "conversationsNotCapturedCount": "ریکارڈ نہیں ہوا ({count})", - "transcriptionNoAudio": "ٹرانسکرپشن آڈیو وصول نہیں کر رہی" + "transcriptionNoAudio": "ٹرانسکرپشن آڈیو وصول نہیں کر رہی", + "chatBlockTask": "کام", + "chatBlockGoal": "ہدف", + "chatBlockConversation": "گفتگو", + "chatBlockMemory": "یاد", + "chatBlockQuestion": "سوال", + "chatBlockOpenInGoals": "اہداف میں کھولیں", + "chatBlockOpenConversation": "گفتگو کھولیں", + "chatBlockOpenInMemories": "یادوں میں کھولیں", + "chatBlockUnavailable": "اب دستیاب نہیں", + "chatBlockRecommendedNextSteps": "تجویز کردہ اگلے اقدامات" } diff --git a/app/lib/l10n/app_vi.arb b/app/lib/l10n/app_vi.arb index 0e8e74bfcb9..3dadcdf57f4 100644 --- a/app/lib/l10n/app_vi.arb +++ b/app/lib/l10n/app_vi.arb @@ -3239,5 +3239,15 @@ "pendantRecordingSyncBlocked": "Pendant vẫn đang ghi âm nên không thể chuyển âm thanh đã lưu. Nhấn nút trên Pendant để dừng ghi âm, sau đó đồng bộ lại.", "pendantFullSyncBlocked": "Bộ nhớ của Pendant đã đầy và nó vẫn đang ở chế độ ghi âm, nên không thể chuyển âm thanh đã lưu. Nhấn nút của Pendant để dừng ghi âm, sau đó đồng bộ lại.", "conversationsNotCapturedCount": "Không được ghi âm ({count})", - "transcriptionNoAudio": "Bản ghi âm không nhận được âm thanh" + "transcriptionNoAudio": "Bản ghi âm không nhận được âm thanh", + "chatBlockTask": "Nhiệm vụ", + "chatBlockGoal": "Mục tiêu", + "chatBlockConversation": "Cuộc trò chuyện", + "chatBlockMemory": "Ký ức", + "chatBlockQuestion": "Câu hỏi", + "chatBlockOpenInGoals": "Mở trong Mục tiêu", + "chatBlockOpenConversation": "Mở cuộc trò chuyện", + "chatBlockOpenInMemories": "Mở trong Ký ức", + "chatBlockUnavailable": "Không còn khả dụng", + "chatBlockRecommendedNextSteps": "Các bước tiếp theo được đề xuất" } diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index 1d1c33098cb..c9f4af841ec 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -3256,5 +3256,15 @@ "pendantRecordingSyncBlocked": "Pendant 仍在录音,因此无法传输已存储的音频。请按下 Pendant 的按钮停止录音,然后重新同步。", "pendantFullSyncBlocked": "Pendant 的存储空间已满,且仍处于录音模式,因此无法传输已存储的音频。请按下 Pendant 的按钮停止录音,然后重新同步。", "conversationsNotCapturedCount": "未记录 ({count})", - "transcriptionNoAudio": "转录未接收到音频" + "transcriptionNoAudio": "转录未接收到音频", + "chatBlockTask": "任务", + "chatBlockGoal": "目标", + "chatBlockConversation": "对话", + "chatBlockMemory": "记忆", + "chatBlockQuestion": "问题", + "chatBlockOpenInGoals": "在目标中打开", + "chatBlockOpenConversation": "打开对话", + "chatBlockOpenInMemories": "在记忆中打开", + "chatBlockUnavailable": "已不再可用", + "chatBlockRecommendedNextSteps": "建议的后续步骤" } From 78b9f083bc470a1c4f4b2c43a72a3350b76fc6e5 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 02:57:19 -0400 Subject: [PATCH 12/29] feat(app): render chat content blocks as interactable components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every block type that macOS renders as a control now has a mobile component, driven from the same wire schema (#12598): - taskCard: a live checkbox wired to the single tasks mutation path, ActionItemsProvider.updateActionItemState. The tasks API is list-only, so the card resolves against the loaded list and mirrors the macOS loading / unavailable states rather than inventing a fetch-by-id. - goalLink: mobile has no goal detail route, so the card opens a bottom sheet with the goal's title and progress resolved from GoalsProvider, and shows the unavailable state when the id is not in the list. - captureLink / conversationLink: push ConversationDetailPage through the citation preamble already shipped in chat (grouped-map hit, then fetch by id). conversationLink also lists its recommended action items as plain rows; mobile creates tasks from the tasks surface, so the block mutates nothing. - memoryLink: opens the existing memory sheet for the resolved memory. - questionCard: options send their preparedAnswer down the normal chat send path, so the runtime stays authoritative for what an answer means. A deferral option is not special — it sends its own prepared answer. Once selectedOptionId is set only the chosen option remains, disabled, so no stale chip ever looks tappable. text/thinking/toolCall/discoveryCard/citation/agentSpawn/agentCompletion and unknown types render nothing extra — the body (or its synthesized fallback) already carries them — but they never hide the message. Where the body is only that fallback, the components replace it instead of repeating it. Every interactive element carries Key('chat-block--...'). Co-Authored-By: Claude Fable 5.1 --- app/lib/pages/chat/widgets/ai_message.dart | 40 ++- .../content_blocks/chat_block_chrome.dart | 215 +++++++++++++ .../chat_content_block_list.dart | 89 ++++++ .../conversation_link_blocks.dart | 196 ++++++++++++ .../content_blocks/goal_link_block.dart | 98 ++++++ .../content_blocks/memory_link_block.dart | 57 ++++ .../content_blocks/question_card_block.dart | 68 +++++ .../content_blocks/task_card_block.dart | 122 ++++++++ .../widgets/chat_content_blocks_test.dart | 286 ++++++++++++++++++ 9 files changed, 1164 insertions(+), 7 deletions(-) create mode 100644 app/lib/pages/chat/widgets/content_blocks/chat_block_chrome.dart create mode 100644 app/lib/pages/chat/widgets/content_blocks/chat_content_block_list.dart create mode 100644 app/lib/pages/chat/widgets/content_blocks/conversation_link_blocks.dart create mode 100644 app/lib/pages/chat/widgets/content_blocks/goal_link_block.dart create mode 100644 app/lib/pages/chat/widgets/content_blocks/memory_link_block.dart create mode 100644 app/lib/pages/chat/widgets/content_blocks/question_card_block.dart create mode 100644 app/lib/pages/chat/widgets/content_blocks/task_card_block.dart create mode 100644 app/test/widgets/chat_content_blocks_test.dart diff --git a/app/lib/pages/chat/widgets/ai_message.dart b/app/lib/pages/chat/widgets/ai_message.dart index 1b9e1e530d0..5613a3978bc 100644 --- a/app/lib/pages/chat/widgets/ai_message.dart +++ b/app/lib/pages/chat/widgets/ai_message.dart @@ -20,6 +20,7 @@ import 'package:omi/backend/schema/app.dart'; import 'package:omi/backend/schema/conversation.dart'; import 'package:omi/backend/schema/message.dart'; import 'package:omi/models/chat_evidence_reference.dart'; +import 'package:omi/pages/chat/widgets/content_blocks/chat_content_block_list.dart'; import 'package:omi/pages/chat/widgets/files_handler_widget.dart'; import 'package:omi/pages/chat/widgets/typing_indicator.dart'; import 'package:omi/pages/conversation_detail/conversation_detail_provider.dart'; @@ -284,8 +285,26 @@ Widget buildMessageWidget( bool showThinkingAfterText = false, Future Function(String id)? fetchConversation, }) { + final contentBlocks = ChatContentBlockList.hasRenderableBlocks(message) + ? ChatContentBlockList( + message: message, + sendMessage: sendMessage, + fetchConversation: fetchConversation, + ) + : null; + // A message whose text is only the fallback synthesized from its blocks has + // nothing to say that the components do not already show, so the components + // replace the body instead of repeating it. + final blocksReplaceBody = contentBlocks != null && + message.memories.isEmpty && + message.type != MessageType.daySummary && + !displayOptions && + message.textIsStructuredFallback; + final Widget messageWidget; - if (message.memories.isNotEmpty) { + if (blocksReplaceBody) { + messageWidget = contentBlocks; + } else if (message.memories.isNotEmpty) { messageWidget = MemoriesMessageWidget( showTypingIndicator: showTypingIndicator, messageMemories: message.memories, @@ -324,17 +343,24 @@ Widget buildMessageWidget( } final evidence = visibleSupplementalEvidence(message); - if (evidence == null) return messageWidget; + final appendBlocks = contentBlocks != null && !blocksReplaceBody; + if (evidence == null && !appendBlocks) return messageWidget; return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ messageWidget, - const SizedBox(height: 8), - // The released mobile surface has no trusted evidence navigator yet. - // Keep cards non-actionable until one is supplied; arbitrary URI fields - // can never become an external action. - ChatEvidenceReferenceList(envelope: evidence), + if (appendBlocks) ...[ + const SizedBox(height: 8), + contentBlocks, + ], + if (evidence != null) ...[ + const SizedBox(height: 8), + // The released mobile surface has no trusted evidence navigator yet. + // Keep cards non-actionable until one is supplied; arbitrary URI fields + // can never become an external action. + ChatEvidenceReferenceList(envelope: evidence), + ], ], ); } diff --git a/app/lib/pages/chat/widgets/content_blocks/chat_block_chrome.dart b/app/lib/pages/chat/widgets/content_blocks/chat_block_chrome.dart new file mode 100644 index 00000000000..d3e7f03a1aa --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/chat_block_chrome.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; + +/// Shared visual chrome for chat content-block components. +/// +/// Deliberately mirrors [ChatEvidenceReferenceCard]'s paddings, radius, and +/// colors so structured blocks read as one family inside the transcript. +class ChatBlockCard extends StatelessWidget { + const ChatBlockCard({ + super.key, + required this.child, + this.onTap, + this.semanticsLabel, + }); + + final Widget child; + final VoidCallback? onTap; + final String? semanticsLabel; + + static const double radius = 10; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final card = Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: colorScheme.outline.withValues(alpha: 0.55)), + ), + child: child, + ); + + final content = onTap == null + ? card + : InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(radius), + child: card, + ); + + if (semanticsLabel == null) return content; + return Semantics( + container: true, + label: semanticsLabel, + button: onTap != null, + enabled: onTap != null, + child: content, + ); + } +} + +/// Small caption row naming the block's entity ("Task", "Goal", ...). +class ChatBlockEyebrow extends StatelessWidget { + const ChatBlockEyebrow({super.key, required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +/// Terminal state for a block whose entity cannot be resolved any more. +class ChatBlockUnavailable extends StatelessWidget { + const ChatBlockUnavailable({ + super.key, + required this.icon, + required this.label, + required this.message, + }); + + final IconData icon; + final String label; + final String message; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ChatBlockCard( + semanticsLabel: '$label: $message', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ChatBlockEyebrow(icon: icon, label: label), + const SizedBox(height: 6), + Text( + message, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ); + } +} + +/// Placeholder while the owning store is still hydrating the entity. +class ChatBlockLoading extends StatelessWidget { + const ChatBlockLoading({ + super.key, + required this.icon, + required this.label, + required this.message, + }); + + final IconData icon; + final String label; + final String message; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ChatBlockCard( + semanticsLabel: '$label: $message', + child: Row( + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onSurfaceVariant), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ], + ), + ); + } +} + +/// Summary + single destination action, shared by the goal/capture/conversation +/// /memory link blocks. +class ChatBlockLinkCard extends StatelessWidget { + const ChatBlockLinkCard({ + super.key, + required this.icon, + required this.label, + required this.summary, + required this.actionTitle, + required this.actionKey, + required this.onAction, + this.isOpening = false, + this.footer, + }); + + final IconData icon; + final String label; + final String summary; + final String actionTitle; + final Key actionKey; + final VoidCallback? onAction; + final bool isOpening; + final Widget? footer; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ChatBlockCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ChatBlockEyebrow(icon: icon, label: label), + const SizedBox(height: 6), + Text(summary, style: Theme.of(context).textTheme.bodyMedium), + if (footer != null) ...[const SizedBox(height: 8), footer!], + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + key: actionKey, + onPressed: isOpening ? null : onAction, + icon: isOpening + ? SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onSurfaceVariant), + ) + : const Icon(Icons.open_in_new, size: 16), + label: Text(actionTitle), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: colorScheme.onSurface, + ), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/pages/chat/widgets/content_blocks/chat_content_block_list.dart b/app/lib/pages/chat/widgets/content_blocks/chat_content_block_list.dart new file mode 100644 index 00000000000..5f0237b8be8 --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/chat_content_block_list.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +import 'package:omi/backend/schema/chat_content_block.dart'; +import 'package:omi/backend/schema/conversation.dart'; +import 'package:omi/backend/schema/message.dart'; + +import 'conversation_link_blocks.dart'; +import 'goal_link_block.dart'; +import 'memory_link_block.dart'; +import 'question_card_block.dart'; +import 'task_card_block.dart'; + +/// Renders the interactable components for a message's `content_blocks`. +/// +/// Only blocks that have their own mobile component are rendered here. text, +/// thinking, toolCall, discoveryCard, citation, agentSpawn, agentCompletion and +/// unknown types are already covered by the message body (or its synthesized +/// fallback text) and deliberately render nothing extra — but they never hide +/// the message. +class ChatContentBlockList extends StatelessWidget { + const ChatContentBlockList({ + super.key, + required this.message, + required this.sendMessage, + this.fetchConversation, + }); + + final ServerMessage message; + final void Function(String) sendMessage; + final Future Function(String id)? fetchConversation; + + /// True when at least one block in [message] has an interactable component. + static bool hasRenderableBlocks(ServerMessage message) { + return message.typedContentBlocks.any(_isRenderable); + } + + static bool _isRenderable(ChatContentBlock block) { + return block is TaskCardContentBlock || + block is GoalLinkContentBlock || + block is CaptureLinkContentBlock || + block is ConversationLinkContentBlock || + block is MemoryLinkContentBlock || + block is QuestionCardContentBlock; + } + + Widget? _build(ChatContentBlock block) { + switch (block) { + case TaskCardContentBlock(): + return TaskCardBlock(block: block); + case GoalLinkContentBlock(): + return GoalLinkBlock(block: block); + case CaptureLinkContentBlock(): + return CaptureLinkBlock(block: block, fetchConversation: fetchConversation); + case ConversationLinkContentBlock(): + return ConversationLinkBlock(block: block, fetchConversation: fetchConversation); + case MemoryLinkContentBlock(): + return MemoryLinkBlock(block: block); + case QuestionCardContentBlock(): + return QuestionCardBlock(block: block, sendMessage: sendMessage); + case TextContentBlock(): + case ThinkingContentBlock(): + case ToolCallContentBlock(): + case DiscoveryCardContentBlock(): + case CitationContentBlock(): + case AgentSpawnContentBlock(): + case AgentCompletionContentBlock(): + case UnknownContentBlock(): + return null; + } + } + + @override + Widget build(BuildContext context) { + final children = []; + for (final block in message.typedContentBlocks) { + final widget = _build(block); + if (widget == null) continue; + if (children.isNotEmpty) children.add(const SizedBox(height: 8)); + children.add(widget); + } + if (children.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: children, + ); + } +} diff --git a/app/lib/pages/chat/widgets/content_blocks/conversation_link_blocks.dart b/app/lib/pages/chat/widgets/content_blocks/conversation_link_blocks.dart new file mode 100644 index 00000000000..957c5de0306 --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/conversation_link_blocks.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/backend/http/api/conversations.dart'; +import 'package:omi/backend/schema/chat_content_block.dart'; +import 'package:omi/backend/schema/conversation.dart'; +import 'package:omi/pages/chat/widgets/ai_message.dart' show resolveChatCitationConversation; +import 'package:omi/pages/conversation_detail/conversation_detail_provider.dart'; +import 'package:omi/pages/conversation_detail/page.dart'; +import 'package:omi/providers/conversation_provider.dart'; +import 'package:omi/utils/l10n_extensions.dart'; + +import 'chat_block_chrome.dart'; + +/// Opens the conversation behind a `captureLink` / `conversationLink` block. +/// +/// Reuses the citation preamble already shipped in chat: resolve from the +/// grouped provider map, fall back to a fetch by id, then push +/// [ConversationDetailPage]. Returns false when the conversation is gone so the +/// caller can show the unavailable state instead of a dead end. +Future openChatBlockConversation( + BuildContext context, { + required String conversationId, + Future Function(String id)? fetchConversation, +}) async { + final conversations = Provider.of(context, listen: false); + final fetch = fetchConversation ?? getConversationById; + final conversation = await resolveChatCitationConversation( + conversations: conversations, + conversationId: conversationId, + fetchConversation: fetch, + ); + if (!context.mounted) return false; + if (conversation == null) return false; + + var located = conversations.getConversationDateAndIndexById(conversation.id); + var date = located?.$1; + if (date == null) { + (_, date) = conversations.addConversationWithDateGrouped(conversation); + } + + context.read().updateConversation(conversation.id, date); + await Navigator.of(context).push( + MaterialPageRoute(builder: (c) => ConversationDetailPage(conversation: conversation)), + ); + return true; +} + +/// Renders a `captureLink` block: a pointer back to the capture that produced +/// the answer. +class CaptureLinkBlock extends StatefulWidget { + const CaptureLinkBlock({super.key, required this.block, this.fetchConversation}); + + final CaptureLinkContentBlock block; + final Future Function(String id)? fetchConversation; + + @override + State createState() => _CaptureLinkBlockState(); +} + +class _CaptureLinkBlockState extends State { + bool _isOpening = false; + bool _isUnavailable = false; + + Future _open() async { + if (_isOpening) return; + setState(() => _isOpening = true); + final opened = await openChatBlockConversation( + context, + conversationId: widget.block.conversationId, + fetchConversation: widget.fetchConversation, + ); + if (!mounted) return; + setState(() { + _isOpening = false; + _isUnavailable = !opened; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + if (_isUnavailable) { + return ChatBlockUnavailable( + key: Key('chat-block-captureLink-${widget.block.id}-unavailable'), + icon: Icons.graphic_eq, + label: l10n.chatBlockConversation, + message: l10n.chatBlockUnavailable, + ); + } + return ChatBlockLinkCard( + key: Key('chat-block-captureLink-${widget.block.id}'), + icon: Icons.graphic_eq, + label: l10n.chatBlockConversation, + summary: widget.block.summary, + actionTitle: l10n.chatBlockOpenConversation, + actionKey: Key('chat-block-captureLink-${widget.block.id}-open'), + isOpening: _isOpening, + onAction: _open, + ); + } +} + +/// Renders a `conversationLink` block: the conversation plus the action items +/// it recommends. Recommended items are plain rows — mobile creates tasks from +/// the tasks surface, so this block never mutates anything. +class ConversationLinkBlock extends StatefulWidget { + const ConversationLinkBlock({super.key, required this.block, this.fetchConversation}); + + final ConversationLinkContentBlock block; + final Future Function(String id)? fetchConversation; + + @override + State createState() => _ConversationLinkBlockState(); +} + +class _ConversationLinkBlockState extends State { + bool _isOpening = false; + bool _isUnavailable = false; + + Future _open() async { + if (_isOpening) return; + setState(() => _isOpening = true); + final opened = await openChatBlockConversation( + context, + conversationId: widget.block.conversationId, + fetchConversation: widget.fetchConversation, + ); + if (!mounted) return; + setState(() { + _isOpening = false; + _isUnavailable = !opened; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + if (_isUnavailable) { + return ChatBlockUnavailable( + key: Key('chat-block-conversationLink-${widget.block.id}-unavailable'), + icon: Icons.subject, + label: l10n.chatBlockConversation, + message: l10n.chatBlockUnavailable, + ); + } + + final colorScheme = Theme.of(context).colorScheme; + final items = widget.block.recommendedActionItems; + return ChatBlockLinkCard( + key: Key('chat-block-conversationLink-${widget.block.id}'), + icon: Icons.subject, + label: l10n.chatBlockConversation, + summary: widget.block.summary, + actionTitle: l10n.chatBlockOpenConversation, + actionKey: Key('chat-block-conversationLink-${widget.block.id}-open'), + isOpening: _isOpening, + onAction: _open, + footer: items.isEmpty + ? null + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.chatBlockRecommendedNextSteps, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + for (final item in items) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.circle, size: 5, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + item.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/pages/chat/widgets/content_blocks/goal_link_block.dart b/app/lib/pages/chat/widgets/content_blocks/goal_link_block.dart new file mode 100644 index 00000000000..7680081d20b --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/goal_link_block.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/backend/http/api/goals.dart'; +import 'package:omi/backend/schema/chat_content_block.dart'; +import 'package:omi/providers/goals_provider.dart'; +import 'package:omi/utils/l10n_extensions.dart'; + +import 'chat_block_chrome.dart'; + +/// Renders a `goalLink` block. +/// +/// Mobile has no goal detail route (goals live as a flat list behind +/// [GoalsProvider] and are rendered inline by the goals widget), so the block +/// opens a bottom sheet with the resolved goal's title and progress instead of +/// inventing a navigation destination. A goal that is not in the loaded list +/// renders the unavailable state rather than a dead button. +class GoalLinkBlock extends StatelessWidget { + const GoalLinkBlock({super.key, required this.block}); + + final GoalLinkContentBlock block; + + Goal? _resolve(GoalsProvider provider) { + for (final goal in provider.goals) { + if (goal.id == block.goalId) return goal; + } + return null; + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Consumer( + builder: (context, provider, _) { + final goal = _resolve(provider); + if (goal == null && !provider.isLoading) { + return ChatBlockUnavailable( + key: Key('chat-block-goalLink-${block.id}-unavailable'), + icon: Icons.flag_outlined, + label: l10n.chatBlockGoal, + message: l10n.chatBlockUnavailable, + ); + } + + return ChatBlockLinkCard( + key: Key('chat-block-goalLink-${block.id}'), + icon: Icons.flag_outlined, + label: l10n.chatBlockGoal, + summary: block.summary, + actionTitle: l10n.chatBlockOpenInGoals, + actionKey: Key('chat-block-goalLink-${block.id}-open'), + isOpening: goal == null, + onAction: goal == null ? null : () => _showGoalSheet(context, goal), + ); + }, + ); + } + + void _showGoalSheet(BuildContext context, Goal goal) { + showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetContext) { + final colorScheme = Theme.of(sheetContext).colorScheme; + final unit = goal.unit?.trim(); + final progress = '${_format(goal.currentValue)} / ${_format(goal.targetValue)}' + '${unit == null || unit.isEmpty ? '' : ' $unit'}'; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ChatBlockEyebrow(icon: Icons.flag_outlined, label: sheetContext.l10n.chatBlockGoal), + const SizedBox(height: 8), + Text(goal.title, style: Theme.of(sheetContext).textTheme.titleMedium), + const SizedBox(height: 8), + Text( + progress, + key: Key('chat-block-goalLink-${block.id}-progress'), + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ); + }, + ); + } + + static String _format(double value) { + return value == value.roundToDouble() ? value.toStringAsFixed(0) : value.toStringAsFixed(1); + } +} diff --git a/app/lib/pages/chat/widgets/content_blocks/memory_link_block.dart b/app/lib/pages/chat/widgets/content_blocks/memory_link_block.dart new file mode 100644 index 00000000000..f91924006fa --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/memory_link_block.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/backend/schema/chat_content_block.dart'; +import 'package:omi/backend/schema/memory.dart'; +import 'package:omi/pages/memories/widgets/memory_dialog.dart'; +import 'package:omi/providers/memories_provider.dart'; +import 'package:omi/utils/l10n_extensions.dart'; + +import 'chat_block_chrome.dart'; + +/// Renders a `memoryLink` block. +/// +/// Memories are loaded as a list, so the block resolves the id against +/// [MemoriesProvider] and opens the existing memory sheet. An id that is not in +/// the loaded list renders the unavailable state. +class MemoryLinkBlock extends StatelessWidget { + const MemoryLinkBlock({super.key, required this.block}); + + final MemoryLinkContentBlock block; + + Memory? _resolve(MemoriesProvider provider) { + for (final memory in provider.memories) { + if (memory.id == block.memoryId) return memory; + } + return null; + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Consumer( + builder: (context, provider, _) { + final memory = _resolve(provider); + if (memory == null && !provider.loading) { + return ChatBlockUnavailable( + key: Key('chat-block-memoryLink-${block.id}-unavailable'), + icon: Icons.psychology_outlined, + label: l10n.chatBlockMemory, + message: l10n.chatBlockUnavailable, + ); + } + + return ChatBlockLinkCard( + key: Key('chat-block-memoryLink-${block.id}'), + icon: Icons.psychology_outlined, + label: l10n.chatBlockMemory, + summary: block.summary, + actionTitle: l10n.chatBlockOpenInMemories, + actionKey: Key('chat-block-memoryLink-${block.id}-open'), + isOpening: memory == null, + onAction: memory == null ? null : () => showMemoryDialog(context, provider, memory: memory), + ); + }, + ); + } +} diff --git a/app/lib/pages/chat/widgets/content_blocks/question_card_block.dart b/app/lib/pages/chat/widgets/content_blocks/question_card_block.dart new file mode 100644 index 00000000000..0a92f811230 --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/question_card_block.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +import 'package:omi/backend/schema/chat_content_block.dart'; +import 'package:omi/utils/l10n_extensions.dart'; + +import 'chat_block_chrome.dart'; + +/// Renders a `questionCard` block: the question plus its prepared answers. +/// +/// Tapping an option sends its `preparedAnswer` as a normal chat message — the +/// same path the initial suggestion chips already use — so the runtime stays +/// authoritative for what an answer means. A deferral option is not special: +/// it sends its own prepared answer. Once `selectedOptionId` is set the card +/// keeps the question readable and shows only the chosen option, disabled, so +/// no stale chip ever looks tappable. +class QuestionCardBlock extends StatelessWidget { + const QuestionCardBlock({super.key, required this.block, required this.sendMessage}); + + final QuestionCardContentBlock block; + final void Function(String) sendMessage; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final colorScheme = Theme.of(context).colorScheme; + final selectedId = block.selectedOptionId; + final answered = selectedId != null; + final options = answered + ? block.options.where((option) => option.optionId == selectedId).toList(growable: false) + : block.options; + + return ChatBlockCard( + key: Key('chat-block-questionCard-${block.id}'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ChatBlockEyebrow(icon: Icons.help_outline, label: l10n.chatBlockQuestion), + const SizedBox(height: 6), + Text(block.text, style: Theme.of(context).textTheme.bodyMedium), + if (options.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final option in options) + OutlinedButton( + key: Key('chat-block-questionCard-${block.id}-option-${option.optionId}'), + onPressed: answered ? null : () => sendMessage(option.preparedAnswer), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: colorScheme.onSurface, + side: BorderSide(color: colorScheme.outline.withValues(alpha: 0.55)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + child: Text(option.label, style: Theme.of(context).textTheme.bodySmall), + ), + ], + ), + ], + ], + ), + ); + } +} diff --git a/app/lib/pages/chat/widgets/content_blocks/task_card_block.dart b/app/lib/pages/chat/widgets/content_blocks/task_card_block.dart new file mode 100644 index 00000000000..86be4c2939e --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/task_card_block.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/backend/schema/action_item.dart'; +import 'package:omi/backend/schema/chat_content_block.dart'; +import 'package:omi/providers/action_items_provider.dart'; +import 'package:omi/utils/l10n_extensions.dart'; + +import 'chat_block_chrome.dart'; + +/// Renders a `taskCard` block as a live, toggleable task row. +/// +/// The tasks API is list-only (there is no fetch-by-id), so the card resolves +/// against the loaded [ActionItemsProvider] list and mirrors the macOS states: +/// loading while the list is still hydrating, unavailable once it has loaded +/// without the task. +class TaskCardBlock extends StatefulWidget { + const TaskCardBlock({super.key, required this.block}); + + final TaskCardContentBlock block; + + @override + State createState() => _TaskCardBlockState(); +} + +class _TaskCardBlockState extends State { + bool _isToggling = false; + bool _hydrated = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + await context.read().ensureLoaded(); + if (mounted) setState(() => _hydrated = true); + }); + } + + ActionItemWithMetadata? _resolve(ActionItemsProvider provider) { + for (final item in provider.actionItems) { + if (item.id == widget.block.taskId || item.taskId == widget.block.taskId) return item; + } + return null; + } + + Future _toggle(ActionItemsProvider provider, ActionItemWithMetadata item) async { + if (_isToggling) return; + setState(() => _isToggling = true); + try { + await provider.updateActionItemState(item, !item.completed); + } finally { + if (mounted) setState(() => _isToggling = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Consumer( + builder: (context, provider, _) { + final item = _resolve(provider); + if (item == null) { + if (!_hydrated || provider.isLoading) { + return ChatBlockLoading( + key: Key('chat-block-taskCard-${widget.block.id}-loading'), + icon: Icons.checklist, + label: l10n.chatBlockTask, + message: l10n.loading, + ); + } + return ChatBlockUnavailable( + key: Key('chat-block-taskCard-${widget.block.id}-unavailable'), + icon: Icons.checklist, + label: l10n.chatBlockTask, + message: l10n.chatBlockUnavailable, + ); + } + + final colorScheme = Theme.of(context).colorScheme; + return ChatBlockCard( + key: Key('chat-block-taskCard-${widget.block.id}'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ChatBlockEyebrow(icon: Icons.checklist, label: l10n.chatBlockTask), + const SizedBox(height: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + key: Key('chat-block-taskCard-${widget.block.id}-toggle'), + onPressed: _isToggling ? null : () => _toggle(provider, item), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + icon: Icon( + item.completed ? Icons.check_circle : Icons.circle_outlined, + size: 20, + color: item.completed ? Colors.green : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item.description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: item.completed ? colorScheme.onSurfaceVariant : colorScheme.onSurface, + decoration: item.completed ? TextDecoration.lineThrough : null, + ), + ), + ), + ], + ), + ], + ), + ); + }, + ); + } +} diff --git a/app/test/widgets/chat_content_blocks_test.dart b/app/test/widgets/chat_content_blocks_test.dart new file mode 100644 index 00000000000..3fe2f53101c --- /dev/null +++ b/app/test/widgets/chat_content_blocks_test.dart @@ -0,0 +1,286 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/backend/http/api/messages.dart'; +import 'package:omi/backend/schema/action_item.dart'; +import 'package:omi/backend/schema/gen/action_items_folders_wire.g.dart' as wire; +import 'package:omi/backend/schema/memory.dart'; +import 'package:omi/backend/schema/message.dart'; +import 'package:omi/l10n/app_localizations.dart'; +import 'package:omi/pages/chat/widgets/ai_message.dart'; +import 'package:omi/providers/action_items_provider.dart'; +import 'package:omi/providers/connectivity_provider.dart'; +import 'package:omi/providers/conversation_provider.dart'; +import 'package:omi/providers/goals_provider.dart'; +import 'package:omi/providers/memories_provider.dart'; +import 'package:omi/providers/message_provider.dart'; + +/// Records the single task-mutation path instead of hitting the network. +class _RecordingActionItemsProvider extends ActionItemsProvider { + _RecordingActionItemsProvider(this._items) + : super( + getActionItems: ({ + int limit = 100, + int offset = 0, + bool? completed, + String? conversationId, + DateTime? startDate, + DateTime? endDate, + }) async => + const wire.GeneratedActionItemsResponse(actionItems: []), + ); + + final List _items; + final List<(String, bool)> updates = []; + + @override + List get actionItems => _items; + + @override + bool get isLoading => false; + + @override + Future ensureLoaded({bool showShimmer = false}) async {} + + @override + Future updateActionItemState(ActionItemWithMetadata item, bool newState) async { + updates.add((item.id, newState)); + notifyListeners(); + } +} + +/// Records what the question card asked the chat to send. +class _RecordingMessageProvider extends MessageProvider { + final List sent = []; + + @override + Future sendMessageStreamToServer(String text, {ChatPageContext? context}) async { + sent.add(text); + } +} + +class _StubMemoriesProvider extends MemoriesProvider { + _StubMemoriesProvider(this._memories); + + final List _memories; + + @override + List get memories => _memories; + + @override + bool get loading => false; +} + +class _StubGoalsProvider extends GoalsProvider { + @override + bool get isLoading => false; +} + +void main() { + ActionItemWithMetadata task({required String id, bool completed = false}) { + return wire.GeneratedActionItemResponse( + id: id, + description: 'Send the launch email', + completed: completed, + ); + } + + ServerMessage messageWithBlocks({String? selectedOptionId}) { + return ServerMessage( + 'ai-1', + DateTime.parse('2026-09-01T12:00:00Z'), + 'Here is what I found.', + MessageSender.ai, + MessageType.text, + null, + false, + const [], + const [], + const [], + contentBlocks: [ + {'type': 'text', 'id': 'block-text', 'text': 'Here is what I found.'}, + {'type': 'taskCard', 'id': 'block-task', 'taskId': 'task-1'}, + {'type': 'goalLink', 'id': 'block-goal', 'goalId': 'goal-1', 'summary': 'Ship the release'}, + { + 'type': 'captureLink', + 'id': 'block-capture', + 'conversationId': 'conversation-1', + 'summary': 'Monday standup', + }, + { + 'type': 'conversationLink', + 'id': 'block-conversation', + 'conversationId': 'conversation-2', + 'summary': 'Weekly planning', + 'recommendedActionItems': [ + {'description': 'Draft the launch plan'}, + ], + }, + {'type': 'memoryLink', 'id': 'block-memory', 'memoryId': 'memory-1', 'summary': 'Prefers dark mode'}, + { + 'type': 'questionCard', + 'id': 'block-question', + 'questionId': 'question-1', + 'text': 'What should we do next?', + 'subject': {'kind': 'goal', 'id': 'goal-1'}, + 'options': [ + {'optionId': 'ship', 'label': 'Ship it', 'preparedAnswer': 'Ship it today'}, + {'optionId': 'later', 'label': 'Ask me later', 'preparedAnswer': 'Remind me tomorrow', 'defer': true}, + ], + if (selectedOptionId != null) 'selectedOptionId': selectedOptionId, + }, + ], + ); + } + + Future< + ( + _RecordingActionItemsProvider, + _RecordingMessageProvider, + )> pumpBlocks( + WidgetTester tester, { + required ServerMessage message, + List tasks = const [], + }) async { + final actionItems = _RecordingActionItemsProvider(tasks); + final messages = _RecordingMessageProvider(); + final conversations = ConversationProvider(isSignedIn: () => false); + addTearDown(conversations.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: actionItems), + ChangeNotifierProvider.value(value: messages), + ChangeNotifierProvider(create: (_) => _StubGoalsProvider()), + ChangeNotifierProvider(create: (_) => _StubMemoriesProvider(const [])), + ChangeNotifierProvider.value(value: conversations), + ChangeNotifierProvider(create: (_) => ConnectivityProvider()), + ], + child: MaterialApp( + theme: ThemeData.dark(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: AIMessage( + message: message, + sendMessage: (text) => messages.sendMessageStreamToServer(text), + displayOptions: false, + updateConversation: (_) {}, + setMessageNps: (int value, {String? reason}) {}, + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + return (actionItems, messages); + } + + testWidgets('every interactable block renders its keyed component', (tester) async { + await pumpBlocks( + tester, + message: messageWithBlocks(), + tasks: [task(id: 'task-1')], + ); + + expect(find.byKey(const Key('chat-block-taskCard-block-task')), findsOneWidget); + expect(find.byKey(const Key('chat-block-taskCard-block-task-toggle')), findsOneWidget); + // No goal is loaded, so the goal link shows its unavailable state rather + // than a button that cannot resolve. + expect(find.byKey(const Key('chat-block-goalLink-block-goal-unavailable')), findsOneWidget); + expect(find.byKey(const Key('chat-block-captureLink-block-capture')), findsOneWidget); + expect(find.byKey(const Key('chat-block-captureLink-block-capture-open')), findsOneWidget); + expect(find.byKey(const Key('chat-block-conversationLink-block-conversation')), findsOneWidget); + expect(find.byKey(const Key('chat-block-conversationLink-block-conversation-open')), findsOneWidget); + expect(find.byKey(const Key('chat-block-memoryLink-block-memory-unavailable')), findsOneWidget); + expect(find.byKey(const Key('chat-block-questionCard-block-question')), findsOneWidget); + + // Message text is preserved beside the components. + expect(find.textContaining('Here is what I found.'), findsWidgets); + // Conversation link's recommended items are listed. + expect(find.text('Draft the launch plan'), findsOneWidget); + // Task description comes from the resolved task, not the block. + expect(find.text('Send the launch email'), findsOneWidget); + }); + + testWidgets('an unresolved task shows the unavailable state', (tester) async { + await pumpBlocks(tester, message: messageWithBlocks()); + + expect(find.byKey(const Key('chat-block-taskCard-block-task-unavailable')), findsOneWidget); + expect(find.byKey(const Key('chat-block-taskCard-block-task-toggle')), findsNothing); + }); + + testWidgets('tapping the task checkbox toggles it through the tasks provider', (tester) async { + final (actionItems, _) = await pumpBlocks( + tester, + message: messageWithBlocks(), + tasks: [task(id: 'task-1')], + ); + + final toggle = find.byKey(const Key('chat-block-taskCard-block-task-toggle')); + await tester.ensureVisible(toggle); + await tester.tap(toggle); + await tester.pump(); + + expect(actionItems.updates, [('task-1', true)]); + }); + + testWidgets('tapping a question option sends its prepared answer', (tester) async { + final (_, messages) = await pumpBlocks(tester, message: messageWithBlocks()); + + final option = find.byKey(const Key('chat-block-questionCard-block-question-option-ship')); + await tester.ensureVisible(option); + await tester.tap(option); + await tester.pump(); + + expect(messages.sent, ['Ship it today']); + }); + + testWidgets('a deferral option sends its prepared answer too', (tester) async { + final (_, messages) = await pumpBlocks(tester, message: messageWithBlocks()); + + final option = find.byKey(const Key('chat-block-questionCard-block-question-option-later')); + await tester.ensureVisible(option); + await tester.tap(option); + await tester.pump(); + + expect(messages.sent, ['Remind me tomorrow']); + }); + + testWidgets('an answered question keeps only the chosen option, disabled', (tester) async { + final (_, messages) = await pumpBlocks( + tester, + message: messageWithBlocks(selectedOptionId: 'ship'), + ); + + expect(find.byKey(const Key('chat-block-questionCard-block-question-option-later')), findsNothing); + final chosen = find.byKey(const Key('chat-block-questionCard-block-question-option-ship')); + expect(chosen, findsOneWidget); + expect(tester.widget(chosen).onPressed, isNull); + expect(messages.sent, isEmpty); + }); + + testWidgets('a chrome-only message renders components instead of its fallback dump', (tester) async { + final message = ServerMessage.fromJson({ + 'id': 'ai-2', + 'created_at': '2026-09-01T12:00:00Z', + 'text': '', + 'sender': 'ai', + 'type': 'text', + 'content_blocks': [ + {'type': 'goalLink', 'id': 'block-goal', 'goalId': 'goal-1', 'summary': 'Ship the release'}, + {'type': 'taskCard', 'id': 'block-task', 'taskId': 'task-1'}, + ], + }); + + await pumpBlocks(tester, message: message, tasks: [task(id: 'task-1')]); + + expect(find.byKey(const Key('chat-block-taskCard-block-task')), findsOneWidget); + expect(find.textContaining('Goal - Ship the release'), findsNothing); + }); +} From c7625071cba8b71401601ab4e5e7110a047837f7 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 03:43:57 -0400 Subject: [PATCH 13/29] docs: record the presentation-cohort-drops-journaled-content failure class Co-Authored-By: Claude Fable 5.1 --- ...tation-cohort-drops-journaled-content.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/failure-classes/FC-presentation-cohort-drops-journaled-content.json diff --git a/.github/failure-classes/FC-presentation-cohort-drops-journaled-content.json b/.github/failure-classes/FC-presentation-cohort-drops-journaled-content.json new file mode 100644 index 00000000000..d41d0750aa7 --- /dev/null +++ b/.github/failure-classes/FC-presentation-cohort-drops-journaled-content.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "id": "FC-presentation-cohort-drops-journaled-content", + "violated_contract": "A client-side presentation cohort must never decide whether durable, journaled content is drawn. The macOS chat journaled taskCard, goalLink, captureLink, conversationLink, memoryLink and questionCard blocks for every account, but only the shell sampled into the chat-first cohort was handed a ChatFirstRichBlockContext; every other host (legacy shell, task chat panel, floating bar, notch) decoded the same blocks and rendered EmptyView. A task created during onboarding was therefore in the transcript and invisible, and the same transcript looked different per shell, per cohort, and per surface. The defect reads as 'the task was never created', not as a rendering gate, because the journal is correct.", + "canonical_prevention": "One renderer, no optional rendering context: every chat surface receives a non-optional rich-block context, and a behavioural test journals one message carrying every block type and asserts the shared renderer yields the interactable view for each. A static tripwire forbids the retired cohort/legacy symbols and any 'context == nil' branch from returning. Capability gates may disable an action on a card; they may not decide whether the card exists.", + "canonical_prevention_artifact": [ + "desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockGroupView.swift", + "desktop/macos/Desktop/Tests/OneChatShellRichBlockTests.swift", + "desktop/macos/scripts/check-single-chat-shell.py", + "app/test/widgets/chat_content_blocks_test.dart" + ], + "scope_hints": [ + "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift", + "desktop/macos/Desktop/Sources/MainWindow/ChatFirst/**", + "app/lib/pages/chat/widgets/**" + ], + "status": "open", + "evidence_prs": [] +} From b9c0aaa0880ba29645a18e417391197386d7d365 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 03:51:59 -0400 Subject: [PATCH 14/29] fix(desktop): give the reader a selectable copy instead of a selectable transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selection change this branch shipped is reverted. `OmiMarkdown` disables native text selection again at both sites, explicitly, and the `\.chatTextSelectable` environment key, `ChatTextSelectionPolicy` and `OmiChatTextSelectability` are gone along with the host wiring in `AIResponseView`, `FloatingControlBarView` and `OnboardingChatView` — those hosts now carry no `textSelection` modifier around `OmiMarkdown` at all, since the ones that were there before this branch were dead code under the inner `.disabled`. The settled-row gate was not enough. PR #10834 made the same argument and reopened FC-selection-overlay-layout-loop in Omi Beta 0.12.146: every sampled main-thread stack sat in `SelectionOverlay`, `setFont`, intrinsic-size invalidation and AttributeGraph, and memory grew without bound. A settled row is still rebuilt by transcript loading, scrolling, window resize and parent-state updates, which is all that loop needs. `.github/scripts/check_chat_selection_boundary.py` rejects the escape hatch and names the remedy: the existing copy actions, or a separate non-live reading surface. So this adds the reading surface. "Select Text…" sits on the row's context menu next to "Copy Message" and as an ibeam button in the hover strip, on assistant and user rows alike. It opens `ChatSelectableTextPopover`: one `NSTextView` over one message's copyable text — `isEditable` false, `isSelectable` true, ⌘A and ⌘C native, Escape closes, sized to content with a 360 pt cap and internal scrolling. It is outside the transcript's layout and does not mount until the reader asks for it, so it cannot take part in the loading, scrolling and resize passes that made in-place selection unsafe. No SwiftUI `textSelection` anywhere in it — AppKit selection is what an `NSTextView` already is. The strip now carries four controls plus the timestamp (thumbs, thumbs, copy, select, info-when-present). At 24 pt each that still leaves the timestamp its own room, so both affordances stay rather than context-menu only. Everything else on this branch is unchanged: right-click Copy, focus-gated ⌘C, the metadata-band policy, the transcript rhythm, the interrupted-turn marker, adjacent duplicate collapse, and the accessibility fixes. Co-Authored-By: Claude Fable 5.1 --- .../FloatingControlBar/AIResponseView.swift | 8 -- .../FloatingControlBarView.swift | 8 -- .../MainWindow/Components/ChatBubble.swift | 47 ++++++- .../Components/ChatBubbleSupport.swift | 40 ------ .../ChatSelectableTextPopover.swift | 116 ++++++++++++++++++ .../MainWindow/Components/OmiMarkdown.swift | 31 ++--- .../Onboarding/OnboardingChatView.swift | 12 -- .../Tests/ChatRowErgonomicsTests.swift | 43 +++++-- .../20260902-chat-row-ergonomics.json | 2 +- .../macos/e2e/flows/chat-first-cohesive.yaml | 5 + 10 files changed, 211 insertions(+), 101 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableTextPopover.swift diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift index 288513bb8c3..ff2458702b8 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift @@ -196,10 +196,6 @@ struct AIResponseView: View { switch group { case .text(_, let text): OmiMarkdown(text: text, sender: .ai, citations: message.inlineCitationReferences) - .environment( - \.chatTextSelectable, - ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) - ) .environment(\.colorScheme, .dark) .frame(maxWidth: .infinity, alignment: .leading) case .commentary(_, let text): @@ -262,10 +258,6 @@ struct AIResponseView: View { } } else if !message.text.isEmpty { OmiMarkdown(text: message.text, sender: .ai, citations: message.inlineCitationReferences) - .environment( - \.chatTextSelectable, - ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) - ) .environment(\.colorScheme, .dark) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift index 4c564efd87e..764c0d45c16 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift @@ -2156,10 +2156,6 @@ private struct AgentMainChatView: View { case .text(_, let text): if !text.isEmpty { OmiMarkdown(text: text, sender: .ai, citations: message.inlineCitationReferences) - .environment( - \.chatTextSelectable, - ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) - ) .environment(\.colorScheme, .dark) .frame(maxWidth: .infinity, alignment: .leading) } @@ -2220,10 +2216,6 @@ private struct AgentMainChatView: View { let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { OmiMarkdown(text: trimmed, sender: .ai, citations: message.inlineCitationReferences) - .environment( - \.chatTextSelectable, - ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) - ) .environment(\.fontScale, 0.88) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift index fdbc788381c..734576a3ba5 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift @@ -94,6 +94,7 @@ struct ChatBubble: View { @State private var showCopied = false @State private var showRatingFeedback = false @State private var showInfoPopover = false + @State private var showSelectableText = false /// Automation seam: the bridge's `main_chat_open_response_context` posts this /// with a message id so harnesses can open the Response Context popover for a @@ -270,11 +271,7 @@ struct ChatBubble: View { } .contentShape(Rectangle()) .onHover { updateMetadataHover(.row, hovering: $0) } - // A settled row may be selected with the cursor; a streaming one may not. - .environment( - \.chatTextSelectable, - ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) - ) + .overlay(alignment: .bottomLeading) { selectableTextAnchor } // Copy without hunting for the hover strip — and the only copy affordance a // user turn has ever had. .contextMenu { messageContextMenu } @@ -302,9 +299,24 @@ struct ChatBubble: View { private var messageContextMenu: some View { if !copyPayload.isEmpty { Button("Copy Message") { copyMessageToPasteboard() } + // The live transcript can never be selectable (FC-selection-overlay-layout-loop); + // this opens the same words on a surface that is not the transcript. + Button("Select Text\u{2026}") { showSelectableText = true } } } + /// One anchor for the reading surface, shared by the context menu and the + /// hover strip, so the popover is never mounted twice on the same row. + @ViewBuilder + private var selectableTextAnchor: some View { + Color.clear + .frame(width: 1, height: 1) + .accessibilityHidden(true) + .popover(isPresented: $showSelectableText, arrowEdge: .bottom) { + ChatSelectableTextPopover(text: copyPayload) { showSelectableText = false } + } + } + @ViewBuilder private func messageContentView(_ groupedBlocks: [ContentBlockGroup]) -> some View { if message.isStreaming && message.text.isEmpty && message.contentBlocks.isEmpty { @@ -581,7 +593,7 @@ struct ChatBubble: View { let isVisible = metadataRevealOverrideForTesting ?? (metadataHoverState.keepsMetadataVisible || isMetadataControlFocused || showRatingFeedback - || showCopied || showInfoPopover) + || showCopied || showInfoPopover || showSelectableText) // **One cluster under the message.** Controls far left and timestamp far right // of one line is how two halves of a row end up reading as page furniture. HStack(alignment: .center, spacing: OmiSpacing.sm) { @@ -591,6 +603,9 @@ struct ChatBubble: View { if includeCopyButton { copyButton } + if includeCopyButton { + selectTextButton + } if includeCopyButton, message.metadata != nil { infoButton } @@ -709,6 +724,26 @@ struct ChatBubble: View { .help("Copy message") } + /// Opens the message on `ChatSelectableTextPopover`. The transcript itself + /// stays selection-free; this is the "separate non-live reading surface" the + /// selection boundary names as the remedy. + @ViewBuilder + private var selectTextButton: some View { + Button(action: { showSelectableText = true }) { + Image(systemName: "character.cursor.ibeam") + .scaledFont(size: OmiType.caption) + .foregroundColor(showSelectableText ? Ink.primary : Ink.secondary) + .frame( + width: ChatBubbleMetadataControlMetrics.targetSize, + height: ChatBubbleMetadataControlMetrics.targetSize + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focused($isMetadataControlFocused) + .help("Select text") + } + /// Response Context popover — observed turn evidence (tools, screenshot, /// admitted kernel sources). Only fresh responses carry metadata; it is /// in-memory only and not persisted across restarts. diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift index d72754b72e3..087d61dd69e 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift @@ -500,46 +500,6 @@ struct ChatSuggestedTaskRow: View { } } -/// **When chat prose may host AppKit's selection overlay.** -/// -/// `.textSelection(.enabled)` costs one AppKit `SelectionOverlay` per `Text`. -/// A *streaming* row rewrites its body on every flush, so those overlays turn -/// into a font/intrinsic-size/layout loop — the measured 400-segment, 2-second -/// hang that made `OmiMarkdown` disable selection outright. A *settled* row is -/// rewritten only by journal replay, so it can carry selection safely, and the -/// reader can finally drag a date or a name out of an answer. -enum ChatTextSelectionPolicy { - static func isSelectable(isStreaming: Bool) -> Bool { !isStreaming } -} - -private struct ChatTextSelectableKey: EnvironmentKey { - static let defaultValue = false -} - -extension EnvironmentValues { - /// Opt-in switch read by `OmiMarkdown`. The default keeps every host that has - /// not reasoned about the cost above selection-free. - var chatTextSelectable: Bool { - get { self[ChatTextSelectableKey.self] } - set { self[ChatTextSelectableKey.self] = newValue } - } -} - -/// `.textSelection` takes two different concrete types, so the choice cannot be -/// a ternary. One modifier keeps the branch in a single place. -struct OmiChatTextSelectability: ViewModifier { - let isEnabled: Bool - - @ViewBuilder - func body(content: Content) -> some View { - if isEnabled { - content.textSelection(.enabled) - } else { - content.textSelection(.disabled) - } - } -} - /// **How a turn that stopped mid-sentence tells the reader it was cut off.** /// /// A voice barge-in persists whatever the assistant had said so far with a diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableTextPopover.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableTextPopover.swift new file mode 100644 index 00000000000..a35dbd4c089 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableTextPopover.swift @@ -0,0 +1,116 @@ +import AppKit +import OmiTheme +import SwiftUI + +/// **The separate, non-live reading surface for one chat message.** +/// +/// The transcript itself can never host native text selection: PR #10834 put +/// `.textSelection(.enabled)` back on settled rows and reopened +/// FC-selection-overlay-layout-loop in Omi Beta 0.12.146 — `SelectionOverlay` +/// pinned the main thread through `setFont`/intrinsic-size/AttributeGraph while +/// memory grew without bound. `.github/scripts/check_chat_selection_boundary.py` +/// keeps that door shut. +/// +/// So the reader gets the other half of the remedy instead. "Select Text" opens +/// this popover, which is one `NSTextView` over one message, outside the +/// transcript's layout, mounted only when asked for and torn down on close. It +/// cannot participate in transcript loading, scrolling or resize, which is what +/// made selection unsafe in the first place. Nothing here uses SwiftUI's +/// `textSelection` — AppKit's own selection is what an `NSTextView` already is. +struct ChatSelectableTextPopover: View { + let text: String + let onClose: () -> Void + + /// Wide enough for a normal answer line without rewrapping it into a column, + /// capped so a long reply scrolls inside the popover rather than growing one + /// taller than the window. + private static let width: CGFloat = 420 + private static let maxTextHeight: CGFloat = 360 + + var body: some View { + VStack(alignment: .leading, spacing: OmiSpacing.sm) { + HStack(spacing: OmiSpacing.sm) { + Text("Select text") + .scaledFont(size: OmiType.caption, weight: .semibold) + .foregroundColor(Ink.primary) + Spacer(minLength: 0) + Text("⌘A · ⌘C") + .scaledFont(size: OmiType.micro) + .foregroundColor(Ink.secondary) + } + + OmiSelectableTextView(text: text, maxHeight: Self.maxTextHeight) + .frame(width: Self.width) + .frame(maxHeight: Self.maxTextHeight) + } + .padding(OmiSpacing.md) + // Escape. `.popover` is transient, but a click never has to happen for the + // reader to be done reading. + .onExitCommand(perform: onClose) + .accessibilityLabel("Selectable message text") + } +} + +/// Read-only, selectable AppKit text. Deliberately **not** a SwiftUI `Text`: +/// one `NSTextView` owns its own selection, so there is no per-`Text` +/// `SelectionOverlay` to install and nothing for a parent rebuild to thrash. +struct OmiSelectableTextView: NSViewRepresentable { + let text: String + let maxHeight: CGFloat + + func makeNSView(context: Context) -> NSScrollView { Self.makeScrollView(text: text) } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + Self.apply(text: text, to: scrollView) + } + + /// The AppKit configuration, reachable without an `NSViewRepresentableContext` + /// so a test can assert what this surface actually is. + static func makeScrollView(text: String) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + + guard let textView = scrollView.documentView as? NSTextView else { return scrollView } + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = false + textView.drawsBackground = false + textView.textContainerInset = NSSize(width: 0, height: 0) + textView.font = .systemFont(ofSize: 13) + textView.textColor = .labelColor + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.string = text + + // The reader asked for this surface in order to select; give them the + // caret without a first click. + DispatchQueue.main.async { + textView.window?.makeFirstResponder(textView) + } + return scrollView + } + + /// A rebuild replaces a string on the one text view; it never installs a + /// second selection overlay. + static func apply(text: String, to scrollView: NSScrollView) { + guard let textView = scrollView.documentView as? NSTextView else { return } + if textView.string != text { textView.string = text } + } + + /// Hug short messages; scroll long ones instead of growing past the cap. + func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSScrollView, context: Context) -> CGSize? { + guard let textView = nsView.documentView as? NSTextView, + let container = textView.textContainer, + let layoutManager = textView.layoutManager + else { return nil } + + let width = proposal.width ?? container.size.width + container.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) + layoutManager.ensureLayout(for: container) + let used = layoutManager.usedRect(for: container).height + return CGSize(width: width, height: min(max(used, 20), maxHeight)) + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift index 7f526c39b64..e6d9f4ab47f 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift @@ -13,14 +13,19 @@ import OmiTheme /// - Thematic breaks render as a quiet, branded section divider rather than /// leaking their Markdown source (`---`) into a response. /// -/// Native SwiftUI text selection is **opt-in per host**, through -/// `\.chatTextSelectable`. A row that is still streaming rewrites its body on -/// every flush, and AppKit-backed selection overlays turn those updates into a -/// non-converging font/intrinsic-size/layout loop — so the default is off and -/// `ChatTextSelectionPolicy` is what lets a settled row through. +/// Live chat Markdown deliberately disables native SwiftUI text selection, and +/// **there is no opt-in** — not per host, not for settled rows. PR #10834 tried +/// exactly that and reopened FC-selection-overlay-layout-loop in Omi Beta +/// 0.12.146: every sampled main-thread stack sat in `SelectionOverlay`, +/// `setFont`, intrinsic-size invalidation and AttributeGraph while memory grew +/// without bound. Settled rows are not safe either — they still participate in +/// transcript loading, scrolling, window resizing and parent-state updates. +/// `.github/scripts/check_chat_selection_boundary.py` enforces this. /// -/// Chat bubbles retain whole-message copy actions, while code blocks and tables -/// keep their focused copy controls. +/// Chat bubbles retain whole-message copy actions, code blocks and tables keep +/// their focused copy controls, and a reader who needs to drag a date out of an +/// answer opens `ChatSelectableTextPopover` — a separate, non-live surface that +/// never mounts inside the transcript. struct OmiMarkdown: View { enum Style: Equatable { case assistant @@ -33,7 +38,6 @@ struct OmiMarkdown: View { let citations: [ChatCitationReference] let onOpenCitation: ((ChatCitationReference) -> Void)? @Environment(\.fontScale) private var fontScale - @Environment(\.chatTextSelectable) private var chatTextSelectable init( text: String, @@ -76,7 +80,7 @@ struct OmiMarkdown: View { onOpenCitation: onOpenCitation) } } - .modifier(OmiChatTextSelectability(isEnabled: chatTextSelectable)) + .textSelection(.disabled) } static func containsGFMTable(_ content: String) -> Bool { @@ -1445,7 +1449,6 @@ private struct OmiMarkdownTableView: View { let fontScale: CGFloat let citations: [ChatCitationReference] let onOpenCitation: ((ChatCitationReference) -> Void)? - @Environment(\.chatTextSelectable) private var chatTextSelectable private var allRows: [[String]] { [table.header] + table.rows @@ -1485,10 +1488,10 @@ private struct OmiMarkdownTableView: View { .stroke(borderColor, lineWidth: 1) ) .fixedSize(horizontal: false, vertical: true) - // A live (streaming) transcript does not create one AppKit SelectionOverlay - // per cell; a settled row opts in through `\.chatTextSelectable` so a value - // can be dragged out of a table the same way it can out of a sentence. - .modifier(OmiChatTextSelectability(isEnabled: chatTextSelectable)) + // Tables do not create one AppKit SelectionOverlay per cell inside the + // live transcript. Copy remains available only on fenced code blocks, and + // "Select Text" opens the whole answer on a non-live surface. + .textSelection(.disabled) .accessibilityElement(children: .contain) .accessibilityIdentifier("omi-markdown-table") } diff --git a/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift b/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift index 1ed131398c2..c483c10763f 100644 --- a/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift +++ b/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift @@ -1905,10 +1905,6 @@ struct OnboardingChatBubble: View { // Fallback for messages loaded from backend (no contentBlocks, only flat text) if !message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { OmiMarkdown(text: message.text, style: .assistant) - .environment( - \.chatTextSelectable, - ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) - ) .padding(.horizontal, OmiSpacing.md) .padding(.vertical, OmiSpacing.sm) .glassCard() @@ -1920,10 +1916,6 @@ struct OnboardingChatBubble: View { if !allText.isEmpty { OmiMarkdown(text: allText, style: .assistant) - .environment( - \.chatTextSelectable, - ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) - ) .padding(.horizontal, OmiSpacing.md) .padding(.vertical, OmiSpacing.sm) .glassCard() @@ -1951,10 +1943,6 @@ struct OnboardingChatBubble: View { } else { if !message.text.isEmpty { OmiMarkdown(text: message.text, style: .onboardingUser) - .environment( - \.chatTextSelectable, - ChatTextSelectionPolicy.isSelectable(isStreaming: message.isStreaming) - ) .padding(.horizontal, OmiSpacing.md) .padding(.vertical, OmiSpacing.sm) .glassCard(emphasized: true) diff --git a/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift b/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift index cd0c7bbbf8c..5526271a2e8 100644 --- a/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift +++ b/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift @@ -4,18 +4,37 @@ import XCTest @testable import Omi_Computer -/// Selection is the difference between a transcript you can read and one you can -/// use: before this, a date or a name in an answer could only be retyped. -final class ChatTextSelectionPolicyTests: XCTestCase { - func testASettledRowIsSelectable() { - XCTAssertTrue(ChatTextSelectionPolicy.isSelectable(isStreaming: false)) - } - - /// The measured hang this gate exists for: a streaming row rewrites its body - /// every flush, and one AppKit selection overlay per `Text` turns that into a - /// non-converging layout loop. - func testAStreamingRowIsNotSelectable() { - XCTAssertFalse(ChatTextSelectionPolicy.isSelectable(isStreaming: true)) +/// The transcript can never host native selection (FC-selection-overlay-layout-loop: +/// PR #10834 reopened it in Omi Beta 0.12.146). The remedy the boundary names is a +/// separate non-live reading surface, and this is it — one AppKit text view over +/// one message, mounted only when the reader asks for it. +@MainActor +final class ChatSelectableTextSurfaceTests: XCTestCase { + private func textView(for text: String) throws -> NSTextView { + let scrollView = OmiSelectableTextView.makeScrollView(text: text) + return try XCTUnwrap(scrollView.documentView as? NSTextView) + } + + func testTheReadingSurfaceIsSelectableButNotEditable() throws { + let view = try textView(for: "They arrive on Saturday.") + XCTAssertTrue(view.isSelectable, "selecting is the entire point of this surface") + XCTAssertFalse(view.isEditable, "a transcript row is not a document the reader may rewrite") + } + + func testTheReadingSurfaceCarriesTheMessageItWasOpenedFor() throws { + XCTAssertEqual(try textView(for: "Booking confirmed.").string, "Booking confirmed.") + } + + /// It is one AppKit view, so a rebuild replaces a string rather than + /// installing another selection overlay. + func testUpdatingTheSurfaceReplacesTheTextInPlace() throws { + let scrollView = OmiSelectableTextView.makeScrollView(text: "first") + let first = try XCTUnwrap(scrollView.documentView as? NSTextView) + + OmiSelectableTextView.apply(text: "second", to: scrollView) + + XCTAssertIdentical(scrollView.documentView as? NSTextView, first) + XCTAssertEqual(first.string, "second") } } diff --git a/desktop/macos/changelog/unreleased/20260902-chat-row-ergonomics.json b/desktop/macos/changelog/unreleased/20260902-chat-row-ergonomics.json index 112cedc90ec..d2b9749c80d 100644 --- a/desktop/macos/changelog/unreleased/20260902-chat-row-ergonomics.json +++ b/desktop/macos/changelog/unreleased/20260902-chat-row-ergonomics.json @@ -1,3 +1,3 @@ { - "change": "Chat answers are now selectable, copyable from a right-click menu, tighter in the transcript, and a reply cut off by an interruption says so instead of looking finished" + "change": "Added \"Select Text\" to every chat message — it opens a selectable copy you can drag through and copy from — alongside a right-click Copy, a tighter transcript, and a mark on any reply that was cut off" } diff --git a/desktop/macos/e2e/flows/chat-first-cohesive.yaml b/desktop/macos/e2e/flows/chat-first-cohesive.yaml index 73b48c856c1..96d3e8bfec8 100644 --- a/desktop/macos/e2e/flows/chat-first-cohesive.yaml +++ b/desktop/macos/e2e/flows/chat-first-cohesive.yaml @@ -40,6 +40,11 @@ covers: - desktop/macos/Desktop/Sources/Providers/ChatProvider+AutomationSnapshot.swift - desktop/macos/Desktop/Sources/Chat/AgentQueryResult.swift - desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift + # Every rendered assistant row mounts this file's popover anchor. The flow + # covers that mount and teardown; the selectable text view's own behaviour + # (selectable, not editable, one view across rebuilds) is asserted + # hermetically in ChatSelectableTextSurfaceTests, not here. + - desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableTextPopover.swift - desktop/macos/Desktop/Sources/MainWindow/Components/StableChatCardHeader.swift - desktop/macos/Desktop/Sources/MainWindow/Components/ChatConversationReferencePill.swift # S8 drives the Tasks-page closure, including its bounded attempt/terminal telemetry. From a4c42e14555443c3909693b464aa738e60155d74 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 06:16:14 -0400 Subject: [PATCH 15/29] test(desktop): drop the deleted shell files from the static guards Co-Authored-By: Claude Fable 5.1 --- .../Tests/ScreenRecordingPermissionPolicyTests.swift | 9 ++++----- desktop/macos/Desktop/Tests/ShellGlassChromeTests.swift | 1 - .../macos/Desktop/Tests/StartupWarmupPolicyTests.swift | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/desktop/macos/Desktop/Tests/ScreenRecordingPermissionPolicyTests.swift b/desktop/macos/Desktop/Tests/ScreenRecordingPermissionPolicyTests.swift index 2de49d178aa..93ace43a8e7 100644 --- a/desktop/macos/Desktop/Tests/ScreenRecordingPermissionPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/ScreenRecordingPermissionPolicyTests.swift @@ -45,10 +45,11 @@ final class ScreenRecordingPermissionPolicyTests: XCTestCase { // register-first helper (each of these files had an open-then-register path). for path in [ "Sources/MainWindow/Pages/PermissionsPage.swift", - "Sources/MainWindow/SidebarView.swift", "Sources/Rewind/UI/RewindPage.swift", - // DashboardPage's capture toggle now delegates to CaptureListeningLogic, - // which owns the register-first screen-recording grant. + // The legacy sidebar shell (SidebarView.swift) and DashboardPage were + // deleted with the one-chat-shell migration; ChatFirstShell's capture + // toggle now delegates to CaptureListeningLogic, which owns the + // register-first screen-recording grant. "Sources/MainWindow/CaptureListeningLogic.swift", // OmiApp's menu-bar toggle now delegates to SystemCaptureControls, which owns the // register-first screen-recording grant for both the menu bar and the notch cluster. @@ -63,9 +64,7 @@ final class ScreenRecordingPermissionPolicyTests: XCTestCase { // Negative guard: the register-after-open-Settings anti-pattern is gone. for path in [ "Sources/MainWindow/Pages/PermissionsPage.swift", - "Sources/MainWindow/SidebarView.swift", "Sources/Rewind/UI/RewindPage.swift", - "Sources/MainWindow/Pages/DashboardPage.swift", "Sources/MainWindow/CaptureListeningLogic.swift", ] { let src = try sourceFile(path) diff --git a/desktop/macos/Desktop/Tests/ShellGlassChromeTests.swift b/desktop/macos/Desktop/Tests/ShellGlassChromeTests.swift index 6a77e083fc7..cfba0eefdd6 100644 --- a/desktop/macos/Desktop/Tests/ShellGlassChromeTests.swift +++ b/desktop/macos/Desktop/Tests/ShellGlassChromeTests.swift @@ -229,7 +229,6 @@ final class ShellGlassChromeTests: XCTestCase { private static let shellSources = [ "MainWindow/GlassShellChrome.swift", "MainWindow/DesktopHomeView.swift", - "MainWindow/SidebarView.swift", "MainWindow/DesktopTopBar.swift", "MainWindow/ChatFirst/ChatFirstShell.swift", "MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift", diff --git a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift index 4fca2282838..d93cfdd5e4f 100644 --- a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift @@ -402,7 +402,7 @@ final class StartupWarmupPolicyTests: XCTestCase { let dashboardURL = testsURL .deletingLastPathComponent() - .appendingPathComponent("Sources/MainWindow/Pages/DashboardPage.swift") + .appendingPathComponent("Sources/MainWindow/Dashboard/DashboardViewModel.swift") let containerSource = try String(contentsOf: containerURL, encoding: .utf8) let dashboardSource = try String(contentsOf: dashboardURL, encoding: .utf8) From 803c4dd669123953c1f1f36fe1de274538a872a3 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 06:19:28 -0400 Subject: [PATCH 16/29] test(desktop): repoint ptt-lifecycle covers at ChatToolExecutor RealtimeConversationToolProjection.swift was folded into ChatToolExecutor in 758cd3f1fb and the flow kept the stale path, which fails desktop-flow-lint. Co-Authored-By: Claude Fable 5.1 --- desktop/macos/e2e/flows/ptt-lifecycle.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/macos/e2e/flows/ptt-lifecycle.yaml b/desktop/macos/e2e/flows/ptt-lifecycle.yaml index 4d61ecdd752..d4f52d16543 100644 --- a/desktop/macos/e2e/flows/ptt-lifecycle.yaml +++ b/desktop/macos/e2e/flows/ptt-lifecycle.yaml @@ -9,7 +9,7 @@ covers: - desktop/macos/Desktop/Sources/Providers/ChatProvider.swift - desktop/macos/Desktop/Sources/Providers/ChatProvider+AutomationSnapshot.swift - desktop/macos/Desktop/Sources/Chat/AgentQueryResult.swift - - desktop/macos/Desktop/Sources/Providers/RealtimeConversationToolProjection.swift + - desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift - desktop/macos/Desktop/Sources/Chat/ExternalSurfaceRunAuthority.swift - desktop/macos/Desktop/Sources/DefaultsKey.swift - desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift From d06a603ad029901ec0656f6b8e5fe3e632f897c4 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 15:20:44 -0400 Subject: [PATCH 17/29] fix(desktop): a reply the reader watched arrive stays whole when it settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ChatBubbleTruncation` clamps any body over 500 characters, and only once `isStreaming` goes false. So an answer rendered in full while it streamed — with the transcript following it down — collapsed to its own first paragraph the instant it finished. A forty-item list became three items and a "Show more", and the document shrank by thousands of points under a reader pinned to the live edge. Watching that happen reads as "the chat stopped scrolling": everything you just followed is taken back at the end. Truncation is for restored history, where a long transcript should not be mostly one old reply. An answer that just settled here is the opposite case, so it keeps its full body. Co-Authored-By: Claude Opus 5 --- .../MainWindow/Components/ChatBubble.swift | 7 ++++++ .../Components/ChatBubbleSupport.swift | 13 ++++++++++ .../Tests/HomeRedesignRegressionTests.swift | 25 +++++++++++++++++++ .../20260902-chat-answer-and-task-reach.json | 3 +++ 4 files changed, 48 insertions(+) create mode 100644 desktop/macos/changelog/unreleased/20260902-chat-answer-and-task-reach.json diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift index 734576a3ba5..55c354c7897 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift @@ -270,6 +270,13 @@ struct ChatBubble: View { } } .contentShape(Rectangle()) + .onChange(of: message.isStreaming) { wasStreaming, isStreaming in + guard + ChatBubbleTruncation.settlingKeepsFullBody( + wasStreaming: wasStreaming, isStreaming: isStreaming) + else { return } + isExpanded = true + } .onHover { updateMetadataHover(.row, hovering: $0) } .overlay(alignment: .bottomLeading) { selectableTextAnchor } // Copy without hunting for the hover strip — and the only copy affordance a diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift index 087d61dd69e..518a2dab024 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift @@ -7,6 +7,19 @@ import SwiftUI enum ChatBubbleTruncation { static let threshold = 500 + /// Whether an answer that has just finished streaming keeps its full body. + /// + /// Truncation is for restored history — a long transcript should not be + /// mostly one old reply. An answer the reader just watched arrive is the + /// opposite case: clamping it at the moment it settles takes back everything + /// they read, and shrinks the document by thousands of points under a + /// transcript that was following the live edge, so the reply they were + /// reading is replaced by its own first paragraph. A forty-item list + /// collapsed to three the instant it finished. + static func settlingKeepsFullBody(wasStreaming: Bool?, isStreaming: Bool?) -> Bool { + wasStreaming == true && isStreaming != true + } + static func shouldTruncate(text: String, isStreaming: Bool, isExpanded: Bool) -> Bool { !isStreaming && text.count > threshold && !isExpanded } diff --git a/desktop/macos/Desktop/Tests/HomeRedesignRegressionTests.swift b/desktop/macos/Desktop/Tests/HomeRedesignRegressionTests.swift index 43b6430b878..35411db0ab4 100644 --- a/desktop/macos/Desktop/Tests/HomeRedesignRegressionTests.swift +++ b/desktop/macos/Desktop/Tests/HomeRedesignRegressionTests.swift @@ -653,6 +653,31 @@ final class ChatBubbleLayoutRegressionTests: XCTestCase { ) } + /// An answer collapsing at the moment it settles is the one case truncation + /// must not cover: the reader watched the whole thing arrive, and the + /// transcript was following it down. + func testAnAnswerTheReaderJustWatchedArriveIsNotCollapsedWhenItSettles() { + XCTAssertTrue( + ChatBubbleTruncation.settlingKeepsFullBody(wasStreaming: true, isStreaming: false), + "a stream that just ended keeps the body the reader was reading") + XCTAssertTrue( + ChatBubbleTruncation.settlingKeepsFullBody(wasStreaming: true, isStreaming: nil), + "a row that loses its streaming flag entirely settled just the same") + } + + /// Restored history is what truncation is for, so nothing about merely + /// appearing may expand a row. + func testRestoredHistoryStillCollapses() { + XCTAssertFalse( + ChatBubbleTruncation.settlingKeepsFullBody(wasStreaming: false, isStreaming: false), + "a row that was never streaming here is history, and history stays compact") + XCTAssertFalse( + ChatBubbleTruncation.settlingKeepsFullBody(wasStreaming: nil, isStreaming: false)) + XCTAssertFalse( + ChatBubbleTruncation.settlingKeepsFullBody(wasStreaming: true, isStreaming: true), + "still streaming is not settled") + } + } final class ChatTranscriptWindowTests: XCTestCase { diff --git a/desktop/macos/changelog/unreleased/20260902-chat-answer-and-task-reach.json b/desktop/macos/changelog/unreleased/20260902-chat-answer-and-task-reach.json new file mode 100644 index 00000000000..70f022fe5f2 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260902-chat-answer-and-task-reach.json @@ -0,0 +1,3 @@ +{ + "change": "A long reply now stays whole once it finishes instead of collapsing to its first paragraph, and asking by voice what's on your list returns the same tasks the Tasks page shows — including ones overdue by more than a week and ones that have been sitting there without a date" +} From 172947d865a50316af5c576dddf16a51a771d57d Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 15:20:58 -0400 Subject: [PATCH 18/29] fix(desktop): the assistant reads the same task list the Tasks page shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking by voice what was on the list answered "you don't have any tasks overdue or due today" to someone looking at thirty of them. `get_tasks` is backed by `TasksStore.loadDashboardTasks`, which narrowed the list twice in ways the Tasks page never has: - a seven-day recency window, as a lower bound on overdue due dates and as a `createdAfter` cutoff on undated rows. The page buckets on `dueAt < startOfTomorrow` alone and ages nothing out, so a backlog older than a week was invisible to the assistant and only to the assistant. - a source filter that dropped every AI-capture row. That gate was written when a capture could still land in `action_items` unreviewed. INV-TASK-2 has since made capture suggestion-only — `TaskCaptureModePolicy.usesLegacyStaging` is false for every mode, and a capture stays a Candidate until an explicit gesture accepts it — so the gate no longer separated reviewed from unreviewed. It hid the user's own backlog, and anything they created by voice, since `create_action_item` comes back stamped `conversation`. On the reporting account those two took thirty visible tasks to zero. The buckets now carry what the page carries: 82 overdue + 3 due today against the page's "Today: 85". The per-bucket cap moves 50 → 500, because the count is spoken and 50 would have understated it. `isPendingSuggestion` stays for proactive nudges, the one consumer still asking whether a capture pipeline wrote a row. `DashboardTaskLanePolicy` had no other caller and goes. The voice tool descriptions said "overdue + due today", which is where the spoken wording came from; they now describe the whole open list. Co-Authored-By: Claude Opus 5 --- .../Generated/GeneratedRealtimeTools.swift | 4 +- .../Generated/GeneratedToolCapabilities.swift | 8 +- .../Generated/GeneratedToolExecutors.swift | 4 +- .../Stores/DashboardTaskRefreshPolicy.swift | 9 -- .../Desktop/Sources/Stores/TasksStore.swift | 58 +++++--- .../Tests/DashboardTaskLaneReachTests.swift | 139 ++++++++++++++++++ .../Tests/TaskSuggestionTriageTests.swift | 17 ++- .../agent/src/runtime/omi-tool-manifest.ts | 12 +- .../agent/tests/fixtures/tool-manifest.json | 12 +- 9 files changed, 208 insertions(+), 55 deletions(-) create mode 100644 desktop/macos/Desktop/Tests/DashboardTaskLaneReachTests.swift diff --git a/desktop/macos/Desktop/Sources/Generated/GeneratedRealtimeTools.swift b/desktop/macos/Desktop/Sources/Generated/GeneratedRealtimeTools.swift index 54ef500e2ed..f15d9cf81ba 100644 --- a/desktop/macos/Desktop/Sources/Generated/GeneratedRealtimeTools.swift +++ b/desktop/macos/Desktop/Sources/Generated/GeneratedRealtimeTools.swift @@ -479,7 +479,7 @@ enum GeneratedRealtimeTools { { "type": "function", "name": "get_action_items", - "description": "Read the user's tasks / to-dos from the backend, with optional filters. Use for COMPLETED tasks ('what did I finish'), a DATE RANGE ('what's due next week'), or the FULL list ('all my tasks') — for plain 'what's due today / overdue', prefer get_tasks. Fast synchronous read. Speak a short summary of what it returns.", + "description": "Read the user's tasks / to-dos from the backend, with optional filters. Use for COMPLETED tasks ('what did I finish') or a DATE RANGE ('what's due next week') — for any plain question about the open list, prefer get_tasks. Fast synchronous read. Speak a short summary of what it returns.", "parameters": { "type": "object", "properties": { @@ -613,7 +613,7 @@ enum GeneratedRealtimeTools { { "type": "function", "name": "get_tasks", - "description": "Read the user's tasks (overdue + due today) locally and get them back as text to speak. Fast synchronous read — use this for 'what are my tasks', 'what's due today', 'what's on my list'. Reading tasks is always a direct call, never background work.", + "description": "Read the user's open tasks locally and get them back as text to speak: everything overdue, everything due today, and everything on the list with no due date. This is the same list the Tasks page shows, so an empty result means the user genuinely has no open tasks — never say they have none without calling this first. Fast synchronous read — use it for 'what are my tasks', 'what's due today', 'what's on my list', 'what should I work on'. Reading tasks is always a direct call, never background work.", "parameters": { "type": "object", "properties": {}, diff --git a/desktop/macos/Desktop/Sources/Generated/GeneratedToolCapabilities.swift b/desktop/macos/Desktop/Sources/Generated/GeneratedToolCapabilities.swift index c3afa9fdeb0..1631a58771a 100644 --- a/desktop/macos/Desktop/Sources/Generated/GeneratedToolCapabilities.swift +++ b/desktop/macos/Desktop/Sources/Generated/GeneratedToolCapabilities.swift @@ -565,8 +565,8 @@ enum GeneratedToolCapabilities { surfaces: Set([.desktopChat, .realtimeHub]), summary: "Retrieve the user's tasks with optional completion and due-date filters.", bullets: [ - "Use for completed tasks, date ranges, or the full task list.", - "For voice, prefer get_tasks for plain overdue/due-today questions." + "Use for completed tasks or an explicit date range.", + "For voice, prefer get_tasks for any plain question about the open list." ] ), Capability( @@ -690,10 +690,10 @@ enum GeneratedToolCapabilities { title: "Get Tasks", latency: .fastLocal, surfaces: Set([.realtimeHub]), - summary: "Read the user's overdue and due-today tasks locally.", + summary: "Read the user's open tasks locally: overdue, due today, and undated.", bullets: [ "Use for plain voice questions like what are my tasks, what's due today, or what's on my list.", - "Prefer get_action_items for completed tasks, date ranges, or the full list." + "Prefer get_action_items for completed tasks or an explicit date range." ] ), Capability( diff --git a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift index ebf9d361e9d..8b2231c2cb5 100644 --- a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift +++ b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift @@ -54,8 +54,8 @@ enum GeneratedSwiftToolExecutor: String { enum GeneratedToolExecutors { static let manifestVersion = 1 - static let manifestDigest = "sha256:fef83a43659e9914982d9f913789af004c298e419b4fa736ef264d033c9c2a25" - static let chatFirstManifestDigest = "sha256:cf4e4ece9bfb94cfea82a5874c9552a3b6052d32f6454b858d4ea3f78df9d2ef" + static let manifestDigest = "sha256:bb3ddf9efd89ddb68be7de310756422fe0ccb8d9eea6da0b7d8bc44fd456f2c2" + static let chatFirstManifestDigest = "sha256:219fc273c8559a3074c9eee26801cc95a463989bf6adc6cb5e8998eedb4b537a" static let aliasToCanonical: [String: GeneratedSwiftTool] = [ "search_screen_history": .semanticSearch, diff --git a/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift b/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift index dc5451648fd..07270cd7716 100644 --- a/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift +++ b/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift @@ -12,15 +12,6 @@ enum DashboardTaskRefreshPolicy { static let maxServerFetchPages = 3 } -/// Dashboard widgets, SuggestionAssistant grounding, and realtime `getTasks` -/// stay gated on explicit acceptance. The Tasks page reads `incompleteTasks` -/// and shows leftover extractor rows as ordinary due-date tasks. -enum DashboardTaskLanePolicy { - static func admits(_ task: TaskActionItem) -> Bool { - !task.isPendingSuggestion - } -} - enum DashboardExactTaskFetchPolicy { static let maxConcurrentRequests = 6 diff --git a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift index a6f16c27766..706b5364a49 100644 --- a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift +++ b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift @@ -15,7 +15,6 @@ struct ActionItemMetadataBox: @unchecked Sendable { /// Both Dashboard and Tasks tab observe this store /// /// Tasks are loaded separately for incomplete vs completed to minimize memory usage. -/// By default, only recent (7 days) incomplete tasks are loaded. @MainActor class TasksStore: ObservableObject { static let shared = TasksStore() @@ -438,16 +437,29 @@ class TasksStore: ObservableObject { return a.createdAt > b.createdAt } - /// Overdue tasks (due date in the past but within 7 days) — loaded from SQLite + /// Overdue tasks — every incomplete task due before today, loaded from SQLite. + /// Together with `todaysTasks` this is the Tasks page's "Today" category. @Published var overdueTasks: [TaskActionItem] = [] /// Today's tasks (due today) — loaded from SQLite @Published var todaysTasks: [TaskActionItem] = [] - /// Tasks without due date (created within last 7 days) — loaded from SQLite + /// Tasks without a due date — the Tasks page's "No Deadline", loaded from SQLite @Published var tasksWithoutDueDate: [TaskActionItem] = [] - /// Load dashboard task lists directly from SQLite (avoids pagination issues) + /// How many rows a bucket may hold. The spoken answer reads the first 15, but + /// the bucket's *count* is spoken too ("Overdue (82)"), so the cap has to sit + /// well clear of a real backlog or the assistant states a number the Tasks + /// page contradicts — at the old 50 it did. These are small rows, and the + /// Tasks page already materializes every incomplete dated task. + static let dashboardBucketLimit = 500 + + /// Load dashboard task lists directly from SQLite (avoids pagination issues). + /// + /// These three buckets are what the assistant knows about the user's tasks: + /// the voice `get_tasks` tool, the About-user card, and `SuggestionAssistant` + /// grounding all read them. They must partition the same rows the Tasks page + /// shows, or the assistant contradicts the list the user is looking at. func loadDashboardTasks( expectedOwnerID: String? = nil, authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil, @@ -462,30 +474,32 @@ class TasksStore: ObservableObject { let calendar = Calendar.current let startOfToday = calendar.startOfDay(for: Date()) let endOfToday = calendar.date(byAdding: .day, value: 1, to: startOfToday)! - let sevenDaysAgo = calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date() do { let snapshot: DashboardTaskSnapshot if let loader { snapshot = try await loader() } else { + // No lower bound. The Tasks page buckets by `dueAt < startOfTomorrow` + // alone (`TasksViewModel.categoryFor`), so a task overdue by more than a + // week is still on the user's list — it was only missing from this one. async let overdueResult = ActionItemStorage.shared.getFilteredActionItems( - limit: 50, + limit: Self.dashboardBucketLimit, completedStates: [false], - dueDateAfter: sevenDaysAgo, dueDateBefore: startOfToday ) async let todayResult = ActionItemStorage.shared.getFilteredActionItems( - limit: 50, + limit: Self.dashboardBucketLimit, completedStates: [false], dueDateAfter: startOfToday, dueDateBefore: endOfToday ) + // Likewise no creation cutoff: "No Deadline" on the Tasks page is every + // undated incomplete task, however long it has been sitting there. async let noDueDateResult = ActionItemStorage.shared.getFilteredActionItems( - limit: 50, + limit: Self.dashboardBucketLimit, completedStates: [false], - dueDateIsNull: true, - createdAfter: sevenDaysAgo + dueDateIsNull: true ) let (overdue, today, noDueDate) = try await ( overdueResult, @@ -499,15 +513,19 @@ class TasksStore: ObservableObject { ) } guard isCurrent(lease) else { return } - // Unreviewed AI captures stay out of dashboard / nudge / realtime lanes. - // The Tasks page uses incompleteTasks and shows those rows as ordinary - // due-date tasks after Candidate review replaced the sparkle list. - let sortedOverdue = snapshot.overdue.filter(DashboardTaskLanePolicy.admits) - .sorted(by: Self.sortByDueDateThenSource) - let sortedToday = snapshot.today.filter(DashboardTaskLanePolicy.admits) - .sorted(by: Self.sortByDueDateThenSource) - let sortedNoDueDate = snapshot.noDueDate.filter(DashboardTaskLanePolicy.admits) - .sorted(by: Self.sortByDueDateThenSource) + // These lanes carry the same rows the Tasks page shows. They used to drop + // AI-capture sources, on the reasoning that a capture is unreviewed until + // the user accepts it — but INV-TASK-2 has since made capture + // suggestion-only (`TaskCaptureModePolicy.usesLegacyStaging` is false for + // every mode), so a capture never reaches `action_items` at all. It stays + // a Candidate until an explicit gesture accepts it. Everything in this + // table is therefore already the user's, and the filter had stopped + // separating reviewed from unreviewed: it only hid the backlog they can + // see on Tasks, plus anything they created by voice, since + // `create_action_item` comes back stamped `conversation`. + let sortedOverdue = snapshot.overdue.sorted(by: Self.sortByDueDateThenSource) + let sortedToday = snapshot.today.sorted(by: Self.sortByDueDateThenSource) + let sortedNoDueDate = snapshot.noDueDate.sorted(by: Self.sortByDueDateThenSource) // Only update @Published properties if values actually changed to avoid unnecessary objectWillChange if overdueTasks != sortedOverdue { overdueTasks = sortedOverdue } if todaysTasks != sortedToday { todaysTasks = sortedToday } diff --git a/desktop/macos/Desktop/Tests/DashboardTaskLaneReachTests.swift b/desktop/macos/Desktop/Tests/DashboardTaskLaneReachTests.swift new file mode 100644 index 00000000000..9c7c2677cea --- /dev/null +++ b/desktop/macos/Desktop/Tests/DashboardTaskLaneReachTests.swift @@ -0,0 +1,139 @@ +import XCTest + +@testable import Omi_Computer + +/// The lanes behind the voice `get_tasks` tool, the About-user card, and the +/// assistant's task grounding. +/// +/// They are the only read of the user's tasks that is not the Tasks page, and +/// they used to answer a different question than the page did. Two filters did +/// it: a seven-day recency window on both the overdue and the undated bucket, +/// and a source filter that dropped every AI-capture row. On a real account — +/// a month-old backlog, captured from conversations before capture became +/// suggestion-only — all three buckets computed to zero while the Tasks page +/// showed thirty tasks, and the assistant answered "you don't have any tasks +/// overdue or due today" to someone looking at their list. +@MainActor +final class DashboardTaskLaneReachTests: XCTestCase { + private var fixture: RewindStorageTestIsolation.Fixture? + private var previousOwnerID: String? + private var previousAuth: RewindStorageTestIsolation.AuthSnapshot? + + override func setUp() async throws { + let fixture = try await RewindStorageTestIsolation.setUp(userIdPrefix: "dashboard-lane-reach") + self.fixture = fixture + previousAuth = RewindStorageTestIsolation.captureAuthSnapshot() + previousOwnerID = RuntimeOwnerIdentity.currentOwnerId() + await transitionOwner(to: fixture.testUserId) + RewindStorageTestIsolation.signInForTests(userId: fixture.testUserId) + TasksStore.shared.resetSessionState() + } + + override func tearDown() async throws { + TasksStore.shared.resetSessionState() + if let previousAuth { RewindStorageTestIsolation.restoreAuthSnapshot(previousAuth) } + await transitionOwner(to: previousOwnerID) + await RewindStorageTestIsolation.tearDown(userDir: fixture?.userDir) + fixture = nil + } + + /// Every row the Tasks page would show under "Today" and "No Deadline" has to + /// reach the lanes the assistant reads, whatever its age and whoever captured + /// it. The four rows below are the four ways the old filters lost one. + func testTheAssistantsLanesReachEveryTaskTheTasksPageShows() async throws { + let now = Date() + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: now) + + try await ActionItemStorage.shared.syncTaskActionItems( + [ + item( + id: "overdue-by-a-month", + description: "Visit parents", + dueAt: calendar.date(byAdding: .day, value: -34, to: startOfToday), + createdAt: now.addingTimeInterval(-35 * 86_400), + source: "manual"), + item( + id: "overdue-and-captured", + description: "Apply to the matcha and mahjong event", + dueAt: calendar.date(byAdding: .day, value: -34, to: startOfToday), + createdAt: now.addingTimeInterval(-35 * 86_400), + source: "conversation"), + item( + id: "due-today", + description: "Finish the demo", + dueAt: calendar.date(byAdding: .hour, value: 9, to: startOfToday), + createdAt: now, + source: "manual"), + item( + id: "undated-and-old", + description: "Keep fishing for a stronger hook", + dueAt: nil, + createdAt: now.addingTimeInterval(-30 * 86_400), + source: "legacy"), + ], + authorization: .unrestricted) + + await TasksStore.shared.loadDashboardTasks() + + let overdue = Set(TasksStore.shared.overdueTasks.map(\.id)) + XCTAssertTrue( + overdue.contains("overdue-by-a-month"), + "a task overdue by more than a week is still on the user's list — the page has no lower bound") + XCTAssertTrue( + overdue.contains("overdue-and-captured"), + "capture is suggestion-only now (INV-TASK-2), so a row in action_items is already the user's") + XCTAssertEqual( + TasksStore.shared.todaysTasks.map(\.id), ["due-today"], + "a task due today belongs to today's bucket and nowhere else") + XCTAssertEqual( + TasksStore.shared.tasksWithoutDueDate.map(\.id), ["undated-and-old"], + "an undated task does not age out of the list it has always been sitting in") + } + + /// The spoken answer is assembled from the three buckets, so an empty answer + /// has to mean an empty list. + func testAnEmptyAnswerMeansAnEmptyList() async throws { + await TasksStore.shared.loadDashboardTasks() + + XCTAssertTrue(TasksStore.shared.overdueTasks.isEmpty) + XCTAssertTrue(TasksStore.shared.todaysTasks.isEmpty) + XCTAssertTrue(TasksStore.shared.tasksWithoutDueDate.isEmpty) + } + + private func item( + id: String, + description: String, + dueAt: Date?, + createdAt: Date, + source: String + ) -> TaskActionItem { + TaskActionItem( + id: id, + description: description, + completed: false, + createdAt: createdAt, + dueAt: dueAt, + source: source) + } + + private func transitionOwner(to ownerID: String?) async { + do { + _ = try await RuntimeOwnerIdentity.performEffectiveOwnerTransition( + plannedNextOwner: { _, _ in ownerID }, + quiesceVoice: { _, _ in }, + retargetLocalStorage: { _, _ in }, + ownerDidChange: {}, + { defaults in + defaults.removeObject(forKey: .automationOwnerOverride) + if let ownerID { + defaults.set(ownerID, forKey: .authUserId) + } else { + defaults.removeObject(forKey: .authUserId) + } + }) + } catch { + XCTFail("owner transition failed: \(error)") + } + } +} diff --git a/desktop/macos/Desktop/Tests/TaskSuggestionTriageTests.swift b/desktop/macos/Desktop/Tests/TaskSuggestionTriageTests.swift index 5e2846c151f..060b6eb9a4b 100644 --- a/desktop/macos/Desktop/Tests/TaskSuggestionTriageTests.swift +++ b/desktop/macos/Desktop/Tests/TaskSuggestionTriageTests.swift @@ -4,7 +4,8 @@ import XCTest /// `TaskActionItem.isPendingSuggestion` still names AI-captured action items so /// proactive nudges can skip leftover extractor rows. Those rows are ordinary -/// due-date tasks on the Tasks page; Candidate review is a separate surface. +/// due-date tasks everywhere the user or the assistant reads the list; Candidate +/// review is a separate surface. final class TaskSuggestionTriageTests: XCTestCase { private func task( @@ -43,11 +44,15 @@ final class TaskSuggestionTriageTests: XCTestCase { XCTAssertFalse(task(source: "screenshot", deleted: true).isPendingSuggestion) } - func testDashboardLanesExcludeUnreviewedAICaptures() { - XCTAssertFalse(DashboardTaskLanePolicy.admits(task(source: "screenshot"))) - XCTAssertFalse(DashboardTaskLanePolicy.admits(task(source: "transcription:omi"))) - XCTAssertTrue(DashboardTaskLanePolicy.admits(task(source: "manual"))) - XCTAssertTrue(DashboardTaskLanePolicy.admits(task(source: "recurring"))) + /// The dashboard/realtime lanes used to drop these rows as "unreviewed". They + /// no longer do — capture is suggestion-only under INV-TASK-2, so a row that + /// reached `action_items` is already the user's, and hiding it only made the + /// assistant contradict the Tasks page. `DashboardTaskLaneReachTests` pins + /// that reach. The classification survives for proactive nudges, which is the + /// one consumer still asking "did a capture pipeline write this?". + func testTheClassificationSurvivesForProactiveNudgesOnly() { + XCTAssertTrue(task(source: "screenshot").isPendingSuggestion) + XCTAssertFalse(task(source: "manual").isPendingSuggestion) } } diff --git a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts index 45e5787cf7e..fbf4ff4cca4 100644 --- a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts +++ b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts @@ -348,16 +348,16 @@ const swiftToolSurfacePatches: Record = { surfaces: ["realtime_voice"], capabilityDoc: doc( "Get Tasks", - "Read the user's overdue and due-today tasks locally.", + "Read the user's open tasks locally: overdue, due today, and undated.", [ "Use for plain voice questions like what are my tasks, what's due today, or what's on my list.", - "Prefer get_action_items for completed tasks, date ranges, or the full list.", + "Prefer get_action_items for completed tasks or an explicit date range.", ], ), executor: { kind: "swiftTool", executorName: "realtimeHub" }, voice: { realtimeDescription: - "Read the user's tasks (overdue + due today) locally and get them back as text to speak. Fast synchronous read — use this for 'what are my tasks', 'what's due today', 'what's on my list'. Reading tasks is always a direct call, never background work.", + "Read the user's open tasks locally and get them back as text to speak: everything overdue, everything due today, and everything on the list with no due date. This is the same list the Tasks page shows, so an empty result means the user genuinely has no open tasks — never say they have none without calling this first. Fast synchronous read — use it for 'what are my tasks', 'what's due today', 'what's on my list', 'what should I work on'. Reading tasks is always a direct call, never background work.", }, }, complete_task: { @@ -538,13 +538,13 @@ const swiftToolSurfacePatches: Record = { "Get Action Items", "Retrieve the user's tasks with optional completion and due-date filters.", [ - "Use for completed tasks, date ranges, or the full task list.", - "For voice, prefer get_tasks for plain overdue/due-today questions.", + "Use for completed tasks or an explicit date range.", + "For voice, prefer get_tasks for any plain question about the open list.", ], ), voice: { realtimeDescription: - "Read the user's tasks / to-dos from the backend, with optional filters. Use for COMPLETED tasks ('what did I finish'), a DATE RANGE ('what's due next week'), or the FULL list ('all my tasks') — for plain 'what's due today / overdue', prefer get_tasks. Fast synchronous read. Speak a short summary of what it returns.", + "Read the user's tasks / to-dos from the backend, with optional filters. Use for COMPLETED tasks ('what did I finish') or a DATE RANGE ('what's due next week') — for any plain question about the open list, prefer get_tasks. Fast synchronous read. Speak a short summary of what it returns.", }, }, create_action_item: { diff --git a/desktop/macos/agent/tests/fixtures/tool-manifest.json b/desktop/macos/agent/tests/fixtures/tool-manifest.json index f9144fc717b..a5ca7c12748 100644 --- a/desktop/macos/agent/tests/fixtures/tool-manifest.json +++ b/desktop/macos/agent/tests/fixtures/tool-manifest.json @@ -4610,12 +4610,12 @@ "title": "Get Action Items", "summary": "Retrieve the user's tasks with optional completion and due-date filters.", "bullets": [ - "Use for completed tasks, date ranges, or the full task list.", - "For voice, prefer get_tasks for plain overdue/due-today questions." + "Use for completed tasks or an explicit date range.", + "For voice, prefer get_tasks for any plain question about the open list." ] }, "voice": { - "realtimeDescription": "Read the user's tasks / to-dos from the backend, with optional filters. Use for COMPLETED tasks ('what did I finish'), a DATE RANGE ('what's due next week'), or the FULL list ('all my tasks') — for plain 'what's due today / overdue', prefer get_tasks. Fast synchronous read. Speak a short summary of what it returns." + "realtimeDescription": "Read the user's tasks / to-dos from the backend, with optional filters. Use for COMPLETED tasks ('what did I finish') or a DATE RANGE ('what's due next week') — for any plain question about the open list, prefer get_tasks. Fast synchronous read. Speak a short summary of what it returns." } }, { @@ -5247,14 +5247,14 @@ ], "capabilityDoc": { "title": "Get Tasks", - "summary": "Read the user's overdue and due-today tasks locally.", + "summary": "Read the user's open tasks locally: overdue, due today, and undated.", "bullets": [ "Use for plain voice questions like what are my tasks, what's due today, or what's on my list.", - "Prefer get_action_items for completed tasks, date ranges, or the full list." + "Prefer get_action_items for completed tasks or an explicit date range." ] }, "voice": { - "realtimeDescription": "Read the user's tasks (overdue + due today) locally and get them back as text to speak. Fast synchronous read — use this for 'what are my tasks', 'what's due today', 'what's on my list'. Reading tasks is always a direct call, never background work." + "realtimeDescription": "Read the user's open tasks locally and get them back as text to speak: everything overdue, everything due today, and everything on the list with no due date. This is the same list the Tasks page shows, so an empty result means the user genuinely has no open tasks — never say they have none without calling this first. Fast synchronous read — use it for 'what are my tasks', 'what's due today', 'what's on my list', 'what should I work on'. Reading tasks is always a direct call, never background work." } }, { From 65fcc70cb66f477fbb0da309136faad0f78cbf95 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 15:21:08 -0400 Subject: [PATCH 19/29] fix(desktop): the transcript's own follow-scroll is not the reader taking it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while tracking down the streaming-scroll report. `UserScrollDetector` promotes an open mouse press to reader ownership as soon as the clip view moves, because a scrollbar-track click repositions the viewport without ever emitting a drag. That test cannot tell the app's own follow-scroll from the reader's, and while an answer streams the transcript re-reaches the live edge every `ChatScrollFollowThrottle.interval` — so a press still open when one lands ends follow mode for the rest of the answer. Now that every content block is something you can click, a press inside a streaming transcript is ordinary. Two lifecycle holes alongside it: a release delivered to another window — the "Select Text…" popover and context menus present in their own — was dropped by the same-window guard, leaving the press candidate open for the life of the scroll view with every later follow-scroll able to promote it; and a second press registered its bounds observer without removing the first. The transcript now records when it moves its own viewport, and movement inside that window re-baselines the press instead of promoting it. A drag and a scrollbar-track click still take ownership, which the existing harness cases pin. Co-Authored-By: Claude Opus 5 --- .../Components/ChatMessagesView.swift | 11 +- .../Components/ChatScrollBehavior.swift | 111 +++++++++++++++++- .../Tests/AgentPillLifecycleTests.swift | 5 +- .../Tests/ChatScrollLiveEdgeTests.swift | 55 +++++++++ .../ChatTranscriptGestureHarnessTests.swift | 62 +++++++++- 5 files changed, 234 insertions(+), 10 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift index 7f1c4aad346..9ffba0efcae 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift @@ -467,6 +467,10 @@ struct ChatMessagesView: View { /// Set immediately by the scroll wheel monitor to win the race against /// throttled programmatic scrolls during streaming. @State private var userIsScrolling = false + /// When this transcript last moved its own viewport. The scroll detector + /// reads it so a follow-scroll landing under an open mouse press is not + /// mistaken for the reader taking the viewport. + @State private var programmaticScroll = ChatProgrammaticScrollSignal() /// Tracks work items for delayed initial bottom scrolls so they can be /// canceled on user scroll or disappear. @State private var initialScrollWorkItems: [DispatchWorkItem] = [] @@ -574,6 +578,7 @@ struct ChatMessagesView: View { userIsScrolling = false scrollMode = .freeScrolling hasActivityBelow = false + programmaticScroll.markProgrammaticScroll() OmiMotion.withGated(ChatPromptTimelineMetrics.jumpAnimation) { proxy.scrollTo(markID, anchor: .top) } @@ -943,6 +948,9 @@ struct ChatMessagesView: View { for (index, delay) in delays.enumerated() { let isLast = index == delays.index(before: delays.endIndex) let work = DispatchWorkItem { [self] in + // Both branches below move the viewport, so claim the movement before + // either runs rather than after. + programmaticScroll.markProgrammaticScroll() if !once.applied, let snapshot, let scrollView = transcriptGeometry.scrollView, @@ -1158,7 +1166,7 @@ struct ChatMessagesView: View { } onScrollViewResolved: { scrollView in transcriptGeometry.scrollView = scrollView } - UserScrollDetector { + UserScrollDetector(programmaticScroll: programmaticScroll) { scrollMode = .freeScrolling userIsScrolling = true hasActivityBelow = false @@ -1241,6 +1249,7 @@ struct ChatMessagesView: View { guard !userIsScrolling else { return } guard !messages.isEmpty else { return } transcriptGeometry.setFollowingLiveEdge(true) + programmaticScroll.markProgrammaticScroll() proxy.scrollTo("bottom-anchor", anchor: .bottom) } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatScrollBehavior.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatScrollBehavior.swift index 3d7a9975da1..443085831a8 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatScrollBehavior.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatScrollBehavior.swift @@ -105,6 +105,65 @@ enum ChatScrollFollowThrottle { } } +/// The moments the transcript moved its own viewport. +/// +/// `UserScrollDetector` promotes an open mouse press to reader ownership as +/// soon as the clip view moves, because a scrollbar-track click repositions the +/// viewport without ever emitting a drag. That test could not tell the app's +/// own follow-scroll apart from the reader's: while an answer streams the +/// transcript re-reaches the live edge every +/// `ChatScrollFollowThrottle.interval`, so a press still open when one of those +/// lands reads as "the reader took the viewport" and ends follow mode for the +/// rest of the answer. Now that every content block is something you can click, +/// a press inside a streaming transcript is ordinary. +/// +/// Read and written only on the main thread, like every other participant in +/// the transcript's scroll handling. +final class ChatProgrammaticScrollSignal: @unchecked Sendable { + private(set) var lastScrollAt: TimeInterval? + + /// Call immediately *before* moving the viewport, so the bounds change AppKit + /// posts afterwards falls inside the grace window. + func markProgrammaticScroll(at now: TimeInterval = ProcessInfo.processInfo.systemUptime) { + lastScrollAt = now + } +} + +/// Whether viewport movement observed during a press belongs to the reader. +enum ChatPressPromotionPolicy { + /// How late a programmatic scroll's bounds change may still arrive. AppKit + /// posts it on the same or the next main turn, so this only has to outlive a + /// runloop hop — not a gesture. + static let programmaticScrollGrace: TimeInterval = 0.2 + + enum Movement: Equatable { + /// The reader moved the viewport: the press owns it now. + case promotesPress + /// The transcript moved itself. Measure the reader's next movement from + /// where it left the viewport rather than from where the press began, + /// otherwise one follow-scroll's displacement is charged to the reader for + /// as long as the press stays open. + case rebaselines + /// Nothing moved far enough to mean anything. + case ignores + } + + static func classify( + movement: CGFloat, + epsilon: CGFloat, + now: TimeInterval, + lastProgrammaticScrollAt: TimeInterval? + ) -> Movement { + guard abs(movement) >= epsilon else { return .ignores } + guard let lastProgrammaticScrollAt else { return .promotesPress } + let elapsed = now - lastProgrammaticScrollAt + // A clock that went backwards must not hand the app an open-ended excuse to + // discount reader movement. + guard elapsed >= 0, elapsed <= programmaticScrollGrace else { return .promotesPress } + return .rebaselines + } +} + /// A stable representable host that tells its coordinator when SwiftUI moves it /// between transcript hierarchies. The enclosing NSScrollView is not guaranteed /// to survive a lazy document replacement, especially during a fast gesture. @@ -125,6 +184,9 @@ private final class ScrollDetectorHostView: NSView { /// Detects user scroll-wheel / trackpad gestures, mouse interactions, and /// keyboard scroll-navigation on the enclosing NSScrollView. struct UserScrollDetector: NSViewRepresentable { + /// The transcript's own record of when it last moved the viewport. Shared so + /// the coordinator can tell an app-driven bounds change from the reader's. + let programmaticScroll: ChatProgrammaticScrollSignal let onUserScroll: () -> Void var onUserScrollEnded: () -> Void = {} var onScrollSettledAtBottom: () -> Void = {} @@ -152,6 +214,7 @@ struct UserScrollDetector: NSViewRepresentable { func makeCoordinator() -> Coordinator { Coordinator( onUserScroll: onUserScroll, + programmaticScroll: programmaticScroll, onUserScrollEnded: onUserScrollEnded, onScrollSettledAtBottom: onScrollSettledAtBottom ) @@ -161,6 +224,7 @@ struct UserScrollDetector: NSViewRepresentable { let onUserScroll: () -> Void let onUserScrollEnded: () -> Void let onScrollSettledAtBottom: () -> Void + private let programmaticScroll: ChatProgrammaticScrollSignal private var monitor: Any? private weak var installedScrollView: NSScrollView? private var settleWorkItem: DispatchWorkItem? @@ -191,12 +255,17 @@ struct UserScrollDetector: NSViewRepresentable { 119, // End ] + /// `programmaticScroll` defaults to a signal that has never fired — "the app + /// has not moved the viewport" — which is what a coordinator built outside + /// the transcript means. Production always passes the transcript's own. init( onUserScroll: @escaping () -> Void, + programmaticScroll: ChatProgrammaticScrollSignal = ChatProgrammaticScrollSignal(), onUserScrollEnded: @escaping () -> Void = {}, onScrollSettledAtBottom: @escaping () -> Void ) { self.onUserScroll = onUserScroll + self.programmaticScroll = programmaticScroll self.onUserScrollEnded = onUserScrollEnded self.onScrollSettledAtBottom = onScrollSettledAtBottom } @@ -259,6 +328,16 @@ struct UserScrollDetector: NSViewRepresentable { let handler: @MainActor (NSEvent) -> NSEvent? = { [weak self] event in guard let self else { return event } + // A press must never outlive its own release. Clicking inside the + // transcript can open something that presents in its own window — the + // "Select Text\u{2026}" popover, a context menu — and the release is + // then delivered there, where the same-window guard below dropped it. + // The candidate would stay open for the life of the scroll view, and + // the next follow-scroll promote it. + if event.type == .leftMouseUp { + self.endPressCandidate(on: targetScrollView) + return event + } guard event.window == targetScrollView.window else { return event } if event.type == .keyDown { @@ -289,8 +368,6 @@ struct UserScrollDetector: NSViewRepresentable { let locationInScrollView = targetScrollView.convert(event.locationInWindow, from: nil) guard targetScrollView.bounds.contains(locationInScrollView) else { break } self.beginPressCandidate(on: targetScrollView) - case .leftMouseUp: - self.endPressCandidate(on: targetScrollView) default: // Deliberately not bounds-checked: a scrollbar drag and a // selection autoscroll both leave the transcript's bounds while @@ -360,6 +437,12 @@ struct UserScrollDetector: NSViewRepresentable { @MainActor private func beginPressCandidate(on scrollView: NSScrollView) { + // A press whose release never reached this monitor must not leave its + // observer registered behind the new one. + if let observation = pressBoundsObservation { + NotificationCenter.default.removeObserver(observation) + pressBoundsObservation = nil + } pressOriginScrollTop = Self.scrollTop(of: scrollView) pressCandidateOwnsViewport = false // A scrollbar track click repositions the viewport during mouse-down and @@ -379,9 +462,21 @@ struct UserScrollDetector: NSViewRepresentable { @MainActor private func promotePressCandidateIfMoved(on scrollView: NSScrollView) { guard let origin = pressOriginScrollTop, !pressCandidateOwnsViewport else { return } - guard abs(Self.scrollTop(of: scrollView) - origin) >= Self.dragMovementEpsilon else { return } - pressCandidateOwnsViewport = true - onUserScroll() + let current = Self.scrollTop(of: scrollView) + switch ChatPressPromotionPolicy.classify( + movement: current - origin, + epsilon: Self.dragMovementEpsilon, + now: ProcessInfo.processInfo.systemUptime, + lastProgrammaticScrollAt: programmaticScroll.lastScrollAt + ) { + case .ignores: + return + case .rebaselines: + pressOriginScrollTop = current + case .promotesPress: + pressCandidateOwnsViewport = true + onUserScroll() + } } @MainActor @@ -618,6 +713,9 @@ struct ChatScrollContainer: View { @State private var lastViewportSize: CGSize = .zero @State private var lastFollowScrollTime: TimeInterval? @State private var hasQueuedFollowScroll = false + /// Same contract as `ChatMessagesView`: a follow-scroll landing under an open + /// press is the app moving the viewport, not the reader taking it. + @State private var programmaticScroll = ChatProgrammaticScrollSignal() var body: some View { ScrollViewReader { proxy in @@ -659,7 +757,7 @@ struct ChatScrollContainer: View { } private var scrollDetectors: some View { - UserScrollDetector { + UserScrollDetector(programmaticScroll: programmaticScroll) { scrollMode = .freeScrolling userIsScrolling = true hasActivityBelow = false @@ -760,6 +858,7 @@ struct ChatScrollContainer: View { private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool) { guard scrollMode == .followingBottom, !userIsScrolling else { return } + programmaticScroll.markProgrammaticScroll() if animated { OmiMotion.withGated(.easeOut(duration: 0.15)) { proxy.scrollTo(bottomAnchorId, anchor: .bottom) diff --git a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift index 7390bd12ceb..d28b1cd5760 100644 --- a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift +++ b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift @@ -541,7 +541,10 @@ import XCTest XCTAssertFalse(responseSource.contains("proxy.scrollTo(\"bottom\", anchor: .bottom)")) XCTAssertFalse(viewSource.contains("proxy.scrollTo(\"agentBottom\", anchor: .bottom)")) XCTAssertTrue(scrollSource.contains("struct ChatScrollContainer: View")) - XCTAssertTrue(scrollSource.contains("UserScrollDetector {")) + // The detector is mounted, and mounted with the transcript's own + // programmatic-scroll signal — without it a follow-scroll landing under an + // open press reads as the reader taking the viewport. + XCTAssertTrue(scrollSource.contains("UserScrollDetector(programmaticScroll: programmaticScroll) {")) XCTAssertTrue(scrollSource.contains("onScrollSettledAtBottom")) XCTAssertTrue(scrollSource.contains("scheduleSettledBottomFollow")) XCTAssertTrue(scrollSource.contains("Self.isAtBottom(scrollView)")) diff --git a/desktop/macos/Desktop/Tests/ChatScrollLiveEdgeTests.swift b/desktop/macos/Desktop/Tests/ChatScrollLiveEdgeTests.swift index 48150338944..1cbd34b7ba7 100644 --- a/desktop/macos/Desktop/Tests/ChatScrollLiveEdgeTests.swift +++ b/desktop/macos/Desktop/Tests/ChatScrollLiveEdgeTests.swift @@ -203,6 +203,61 @@ final class ChatScrollLiveEdgeTests: XCTestCase { } } +/// Whether viewport movement observed during a press is the reader's doing. +final class ChatPressPromotionPolicyTests: XCTestCase { + private let epsilon: CGFloat = 1 + + func testMovementNobodyClaimedBelongsToTheReader() { + XCTAssertEqual( + ChatPressPromotionPolicy.classify( + movement: 40, epsilon: epsilon, now: 100, lastProgrammaticScrollAt: nil), + .promotesPress, + "with no follow-scroll to account for it, a moved viewport is the reader's doing") + } + + func testTheTranscriptsOwnFollowScrollDoesNotClaimTheViewportForTheReader() { + XCTAssertEqual( + ChatPressPromotionPolicy.classify( + movement: 40, epsilon: epsilon, now: 100.05, lastProgrammaticScrollAt: 100), + .rebaselines, + "a streamed answer re-reaching the live edge under an open press is not a drag") + } + + func testMovementLongAfterTheLastFollowScrollIsStillTheReaders() { + XCTAssertEqual( + ChatPressPromotionPolicy.classify( + movement: 40, + epsilon: epsilon, + now: 100 + ChatPressPromotionPolicy.programmaticScrollGrace + 0.01, + lastProgrammaticScrollAt: 100), + .promotesPress, + "the grace window covers one runloop hop, not the rest of the press") + } + + func testAStationaryViewportMeansNothingEitherWay() { + XCTAssertEqual( + ChatPressPromotionPolicy.classify( + movement: 0.4, epsilon: epsilon, now: 100, lastProgrammaticScrollAt: nil), + .ignores, + "a click that moved nothing is still just a click") + } + + func testABackwardsClockCannotDiscountReaderMovementForever() { + XCTAssertEqual( + ChatPressPromotionPolicy.classify( + movement: 40, epsilon: epsilon, now: 99, lastProgrammaticScrollAt: 100), + .promotesPress, + "a clock that went backwards must not leave the reader unable to take the viewport") + } + + func testTheSignalStartsWithNothingToAccountFor() { + let signal = ChatProgrammaticScrollSignal() + XCTAssertNil(signal.lastScrollAt) + signal.markProgrammaticScroll(at: 42) + XCTAssertEqual(signal.lastScrollAt, 42) + } +} + /// AppKit-backed failure harness for chat scroll ownership. Unlike the /// coordinate-only live-edge cases above, these tests drive the same native /// live-scroll lifecycle emitted by a rapid trackpad/wheel gesture. diff --git a/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift b/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift index 85b48cc9c42..b41ad709c7a 100644 --- a/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift +++ b/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift @@ -326,6 +326,59 @@ final class ChatTranscriptGestureHarnessTests: XCTestCase { + "(scrollTop=\(harness.scrollTop) of \(harness.maximumScrollTop))") } + /// A press inside the transcript is ordinary now that every content block is + /// something you can click, and the transcript re-reaches the live edge every + /// `ChatScrollFollowThrottle.interval` while an answer streams. Reading its + /// own follow-scroll as "the reader took the viewport" would abandon the + /// reader for the rest of the answer, so the press-promotion test in + /// `ChatPressPromotionPolicy` discounts movement the app just caused. + func testAClickWhileAnAnswerStreamsDoesNotStopTheTranscriptFollowingIt() throws { + let harness = try makeHarness() + defer { harness.tearDown() } + harness.settleInitialPlacement() + XCTAssertTrue(harness.isAtBottom, "precondition: the transcript opens at the live edge") + + harness.sendLeftMouseDown() + var worstDrift: CGFloat = 0 + for chunk in 0..<40 { + harness.appendStreamingText(" Streamed chunk \(chunk) with enough prose to grow the row. ") + harness.pump(0.035) + worstDrift = max(worstDrift, harness.maximumScrollTop - harness.scrollTop) + } + harness.sendLeftMouseUp() + + XCTAssertLessThan( + worstDrift, 120, + "a click that never moved the viewport must not end follow mode " + + "(drifted \(worstDrift) pt of a \(harness.viewportHeight) pt viewport)") + } + + /// A press whose release is delivered somewhere else — the "Select Text\u{2026}" + /// popover and context menus present in their own window — could leave the + /// press candidate open for the life of the scroll view, where the next + /// follow-scroll would promote it. The monitor now closes a press on any + /// release, whichever window carried it. + func testAPressReleasedInAnotherWindowDoesNotStrandTheTranscript() throws { + let harness = try makeHarness() + defer { harness.tearDown() } + harness.settleInitialPlacement() + + harness.sendLeftMouseDown() + harness.sendLeftMouseUp(inWindowNumber: harness.windowNumber + 4_242) + + var worstDrift: CGFloat = 0 + for chunk in 0..<40 { + harness.appendStreamingText(" Streamed chunk \(chunk) after the popover took the release. ") + harness.pump(0.035) + worstDrift = max(worstDrift, harness.maximumScrollTop - harness.scrollTop) + } + + XCTAssertLessThan( + worstDrift, 120, + "a release the transcript's window never saw must still close the press " + + "(drifted \(worstDrift) pt of a \(harness.viewportHeight) pt viewport)") + } + /// Dragging the scrollbar genuinely moves the viewport, so it must still take /// ownership away from live-follow. func testAMouseDragThatMovesTheViewportStillTakesOwnership() throws { @@ -435,6 +488,10 @@ final class ChatTranscriptGestureHarnessTests: XCTestCase { final class Harness { let model: TranscriptModel private let window: NSWindow + + /// The transcript's own window, so a test can address a release to some + /// other window the way a popover or a menu does. + var windowNumber: Int { window.windowNumber } private let hostingView: NSHostingView private var pendingMessages: [ChatMessage] = [] private(set) var scrollView: NSScrollView @@ -653,11 +710,12 @@ final class ChatTranscriptGestureHarnessTests: XCTestCase { pump(0.05) } - func sendLeftMouseUp() { + func sendLeftMouseUp(inWindowNumber windowNumber: Int? = nil) { guard let event = NSEvent.mouseEvent( with: .leftMouseUp, location: NSPoint(x: 450, y: 300), modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: windowNumber ?? window.windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 0) else { return } NSApplication.shared.sendEvent(event) From c0136263c7c9820c3b4aa9505558b75de385a448 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 2 Sep 2026 16:31:01 -0400 Subject: [PATCH 20/29] test(desktop): the tasks flow checks the lanes the assistant answers from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tasks page and `tasks_snapshot` read the same rows now, so the flow can say so: S6 compares the page's Today count against overdue_count + today_count. That is the comparison the bug failed — thirty tasks on the page, zero in the lanes. Co-Authored-By: Claude Opus 5 --- desktop/macos/e2e/flows/tasks.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/desktop/macos/e2e/flows/tasks.yaml b/desktop/macos/e2e/flows/tasks.yaml index f75048ac13b..11df115f3a9 100644 --- a/desktop/macos/e2e/flows/tasks.yaml +++ b/desktop/macos/e2e/flows/tasks.yaml @@ -20,6 +20,9 @@ covers: - desktop/macos/Desktop/Sources/MainWindow/Tasks/TaskDetailSourceNavigator.swift - desktop/macos/Desktop/Sources/MainWindow/Tasks/RewindEvidenceCard.swift - desktop/macos/Desktop/Sources/Stores/TasksStore.swift + # S6 reads the lanes these two build — the ones the assistant answers from — + # and compares them against the page's own Today count. + - desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift - desktop/macos/Desktop/Sources/Stores/TasksStore+BulkSelection.swift - desktop/macos/Desktop/Sources/Rewind/Core/ActionItemStorage+BulkDelete.swift preconditions: @@ -107,3 +110,19 @@ steps: do: "Verify the search bar is present at the top. Check for filter/settings icons next to the search bar and the add (+) button." expect: interactive_count: { min: 2 } + + - id: S6 + name: The assistant's lanes carry what this page shows + do: >- + Count the tasks the page lists under 'Today' (it holds overdue and + due-today rows together), then run `./scripts/omi-ctl action + tasks_snapshot`. Verify `overdue_count` + `today_count` equals the page's + Today count, and that `task_count` is non-zero whenever the page lists any + task. These are the lanes the voice `get_tasks` tool, the About-user card + and SuggestionAssistant read; a backlog older than a week, or one captured + from a conversation, used to be missing from them alone, so the assistant + said "you don't have any tasks overdue or due today" to someone looking at + thirty of them. + expect: + text_visible: + - Today From 3d71cfb4e4e69ccba5fbe06cf42f680eb243319a Mon Sep 17 00:00:00 2001 From: David Zhang Date: Thu, 3 Sep 2026 04:17:04 -0400 Subject: [PATCH 21/29] fix(desktop): the components the agent renders reach the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `render_chat_blocks` had never once been offered to the model on this Mac, and on the turns where it did run its cards were deleted three seconds later. Four separate gates, one confusion between them: *which surface is this*. One shell means main Chat and the floating bar project the same conversation, so a session the bar registered carries `surface_kind = floating_chat` while main-Chat runs execute on it. Every chat-first gate admits `main_chat` only, and three of them read the session's registration instead of the run's: the adapter metadata that sets `OMI_CHAT_FIRST_UI`, the tool-capability broker that admits the call, and the surface Swift re-validates before executing. The fourth was separate — run admission built its own context snapshot and dropped the capability on the way, because the capability map lived on `KernelSessions` where run admission could not reach it. Any one of the four was enough on its own; advertised tools go 41 -> 46 with all four fixed. Then the cards died anyway. `monotonicAcceptContentBlocks` protected exactly two block kinds across terminalization, and terminalization applies the projection *Swift* assembled from the adapter stream — text and tool calls, never a block the agent appended mid-turn, because that append is a journal mutation the surface never saw. So the replace erased every task card, goal link and memory link the turn had rendered, moments after the tool reported `ok`. The protected set is now the kinds the kernel authors and the surface never does. Confirmed live: the tool executes, and three `taskCard` blocks now survive on the finished turn. Co-Authored-By: Claude Opus 5 --- .../Sources/MainWindow/DesktopHomeView.swift | 4 + .../ChatFirstBlockToolExecutor.swift | 10 +- .../Sources/Providers/ChatProvider.swift | 6 + .../agent/src/runtime/conversation-journal.ts | 25 ++- .../macos/agent/src/runtime/kernel-core.ts | 43 ++++- .../agent/src/runtime/kernel-sessions.ts | 7 - .../agent/src/runtime/run-tool-capability.ts | 15 +- .../chat-first-capability-projection.test.ts | 147 ++++++++++++++++++ .../agent/tests/conversation-journal.test.ts | 43 +++++ 9 files changed, 288 insertions(+), 12 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index deb85bea2be..96e514c3b16 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -965,6 +965,7 @@ struct DesktopHomeView: View { ownerIsStillCurrent: false ) _ = viewModelContainer.chatProvider.configureChatFirstMainChatCapability(nil) + log("DesktopHomeView: chat-first capability off — no owner or authorization snapshot at sample time") AnalyticsManager.shared.chatFirst( .capabilityResolution( outcome: .unavailable, @@ -1032,6 +1033,9 @@ struct DesktopHomeView: View { errorClass: capabilityErrorClass ) ) + log( + "DesktopHomeView: chat-first capability resolved outcome=\(capabilityOutcome) " + + "generation=\(projection.map { String($0.controlGeneration) } ?? "none")") reportAutomationState() } diff --git a/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift b/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift index 4e4541fe975..c80f8aaf79c 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift @@ -71,8 +71,14 @@ enum ChatFirstBlockToolExecutor { return ChatToolExecutor.authorizedOwnerChangedResult() } guard let journalBlocks = ChatFirstBlockWire.journalBlocks(from: receipt) else { + log( + "ChatFirstBlockToolExecutor: backend rejected \(backendBlocks.count) block(s) " + + "[\(backendBlocks.compactMap { $0["type"] as? String }.joined(separator: ","))]") return #"{"ok":false,"error":{"code":"chat_first_blocks_rejected"}}"# } + log( + "ChatFirstBlockToolExecutor: appending \(journalBlocks.count) block(s) " + + "[\(journalBlocks.compactMap { $0["type"] as? String }.joined(separator: ","))]") let journalBlocksData = try JSONSerialization.data(withJSONObject: journalBlocks) guard let journalBlocksJSON = String(data: journalBlocksData, encoding: .utf8) else { return #"{"ok":false,"error":{"code":"chat_first_blocks_unavailable"}}"# @@ -93,7 +99,9 @@ enum ChatFirstBlockToolExecutor { backendBlocks.compactMap { citationSelection(from: $0) }.filter { !$0.sourceID.isEmpty }, runID: runID, attemptID: attemptID) - return #"{"ok":true,"rendered":#(journalBlocks.count)}"# + // `#(...)` is not interpolation in a raw string — the count was being + // reported to the model as the literal text `#(journalBlocks.count)`. + return #"{"ok":true,"rendered":\#(journalBlocks.count)}"# } catch { guard ChatToolExecutor.isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) else { diff --git a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift index 7149cf70e6e..4f05061de20 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift @@ -1897,6 +1897,12 @@ class ChatProvider: ObservableObject { for: surface, ownerID: ownerID ) + if surface.surfaceKind == "main_chat" { + log( + "ChatProvider: resolving main_chat session chatFirstCapability=" + + (projection == nil ? "absent" : "present") + + " gateConfigured=\(chatFirstMainChatProjectionGate.isConfigured(for: ownerID))") + } let session = try await resolvedAgentClient().resolveSurfaceSession( surface, creationProfile: creationProfile, diff --git a/desktop/macos/agent/src/runtime/conversation-journal.ts b/desktop/macos/agent/src/runtime/conversation-journal.ts index 218a2edb31a..4bbad5bcc6e 100644 --- a/desktop/macos/agent/src/runtime/conversation-journal.ts +++ b/desktop/macos/agent/src/runtime/conversation-journal.ts @@ -1951,13 +1951,36 @@ function markDiscardedBackendProjection(store: AgentStore, turnId: string, nowMs ); } +/** + * The block kinds the kernel writes and the visible projection never authors. + * + * Terminalization hands us the projection Swift assembled from the adapter + * stream — text, tool calls, thinking, and the cards Swift itself appends. A + * block the *agent* rendered mid-turn through `render_chat_blocks` cannot be in + * it: that append is a journal mutation, not a stream event, so the surface has + * never seen it. Replacing the turn's blocks with that projection therefore + * deleted every task card, goal link and memory link the turn had rendered, + * about three seconds after the tool reported success — which is why chat-first + * components looked like they never rendered while the tool returned `ok`. + */ +const KERNEL_AUTHORED_CONTENT_BLOCK_TYPES: ReadonlySet = new Set([ + "agentSpawn", + "agentCompletion", + "taskCard", + "goalLink", + "captureLink", + "conversationLink", + "memoryLink", + "questionCard", +]); + function monotonicAcceptContentBlocks( current: readonly ConversationContentBlock[], incoming: readonly ConversationContentBlock[], ): ConversationContentBlock[] { const protectedCurrent = new Map( current - .filter((block) => block.type === "agentSpawn" || block.type === "agentCompletion") + .filter((block) => KERNEL_AUTHORED_CONTENT_BLOCK_TYPES.has(block.type)) .map((block) => [block.id, block] as const), ); const result = incoming.map((block) => structuredClone(protectedCurrent.get(block.id) ?? block)); diff --git a/desktop/macos/agent/src/runtime/kernel-core.ts b/desktop/macos/agent/src/runtime/kernel-core.ts index c3ad2b447af..16cf5de5121 100644 --- a/desktop/macos/agent/src/runtime/kernel-core.ts +++ b/desktop/macos/agent/src/runtime/kernel-core.ts @@ -151,7 +151,14 @@ function runtimeAdapterMetadata(input: ExecuteAgentRunInput, session: AgentSessi ...(input.metadata ?? {}), executionRole: session.executionRole, providerBoundary: session.providerBoundary, - surfaceKind: session.surfaceKind, + // The run's surface, not the session's. One shell means main Chat and the + // floating bar project the same conversation, so a session first registered + // by the floating bar keeps `surface_kind = floating_chat` while main-Chat + // runs execute on it. Every chat-first gate downstream — the pi-mono env, + // `effectiveChatFirstCapability`, the tool projection — admits `main_chat` + // only, so stamping the session's surface here told them a main-Chat turn + // was a floating one and the model was never offered `render_chat_blocks`. + surfaceKind: input.surfaceKind || session.surfaceKind, chatFirstUi: input.admittedContextSnapshot?.capabilities.chatFirstUi === true, chatFirstControlGeneration: input.admittedContextSnapshot?.capabilities.chatFirstControlGeneration ?? null, @@ -166,6 +173,7 @@ import { import type { ToolInvocationIdentity } from "./tool-invocation-ledger.js"; import { normalizeOmiToolName } from "./omi-tool-manifest.js"; import { routeExternalSurfaceTool } from "./external-surface-tool-policy.js"; +import type { ChatFirstCapabilityProjection } from "./chat-first-capability.js"; import { applyExecutionProfileToSession, readSessionExecutionProfile, @@ -198,9 +206,30 @@ export class KernelCore { protected readonly bindingResolutionLocks = new Map>(); protected readonly contextDeliveryByBinding = new Map(); protected readonly toolCapabilities: RunToolCapabilityBroker; + /** + * The one immutable server-derived Main Chat sample for this process, keyed + * `ownerId:sessionId`. Process-local only: never back this with SQLite or a + * user preference. + * + * It lives on the base class because *run admission* needs it, not only + * session resolution. A run that builds its own context snapshot without it + * projects a capability-off tool surface, and the adapter metadata derived + * from that snapshot is what decides whether the model is offered + * `render_chat_blocks` at all. + */ + protected readonly chatFirstCapabilities = new Map(); private transactionDepth = 0; private pendingSubscriberEvents: AgentEvent[] = []; + protected chatFirstCapability( + sessionId: string, + ownerId: string, + surfaceKind?: string + ): ChatFirstCapabilityProjection | undefined { + if (surfaceKind !== "main_chat") return undefined; + return this.chatFirstCapabilities.get(`${ownerId}:${sessionId}`); + } + constructor(options: AgentRuntimeKernelOptions) { this.store = options.store; this.registry = options.registry; @@ -819,6 +848,12 @@ export class KernelCore { session.ownerId, Date.now(), input.surfaceKind, + // Main Chat runs arrive with no client-supplied snapshot, so this + // branch builds every one of them. Dropping the capability here + // made the run's own snapshot say capability-off however the shell + // had resolved it, and that snapshot is what + // `runtimeAdapterMetadata` hands the adapter. + this.chatFirstCapability(session.sessionId, session.ownerId, input.surfaceKind), ); const expectationCount = [ input.expectedContextSnapshotVersion, @@ -865,6 +900,12 @@ export class KernelCore { prompt: input.prompt, producingTurnId: input.producingTurnId ?? null, metadata: input.metadata ?? {}, + // The surface this run was admitted for, which is not always the one + // its session was first registered under: one shell means main Chat + // and the floating bar share a session. Recorded here because the + // tool-capability broker has to gate on the run, and the session row + // is the wrong authority for that. + surfaceKind: input.surfaceKind, contextSnapshotVersion: contextSnapshot.version, contextSnapshotGeneration: contextSnapshot.snapshotGeneration, contextRendererFingerprint: contextSnapshot.rendererFingerprint, diff --git a/desktop/macos/agent/src/runtime/kernel-sessions.ts b/desktop/macos/agent/src/runtime/kernel-sessions.ts index 4517a704a0c..60955d1e92d 100644 --- a/desktop/macos/agent/src/runtime/kernel-sessions.ts +++ b/desktop/macos/agent/src/runtime/kernel-sessions.ts @@ -147,13 +147,6 @@ import { conversationIdForSession } from "./conversation-turns.js"; import type { AuthorizedRunToolInvocation } from "./run-tool-capability.js"; export class KernelSessions extends KernelArtifacts { - /** Process-local only: never back this with SQLite or a user preference. */ - private readonly chatFirstCapabilities = new Map(); - - private chatFirstCapability(sessionId: string, ownerId: string, surfaceKind?: string): ChatFirstCapabilityProjection | undefined { - if (surfaceKind !== "main_chat") return undefined; - return this.chatFirstCapabilities.get(`${ownerId}:${sessionId}`); - } ownedSession(sessionId: string, ownerId: string): AgentSession { const session = this.readSession(sessionId); this.assertSessionOwner(session, ownerId); diff --git a/desktop/macos/agent/src/runtime/run-tool-capability.ts b/desktop/macos/agent/src/runtime/run-tool-capability.ts index 181b540eef8..b0d86993754 100644 --- a/desktop/macos/agent/src/runtime/run-tool-capability.ts +++ b/desktop/macos/agent/src/runtime/run-tool-capability.ts @@ -773,7 +773,15 @@ export class RunToolCapabilityBroker { && !Array.isArray(admitted.capabilities) ? admitted.capabilities as Record : {}; - const chatFirstUi = admittedCapabilities.chatFirstUi === true && text(row.surface_kind) === "main_chat"; + // The run's surface, falling back to the session's for runs admitted before + // it was recorded. `s.surface_kind` is where the session was first + // registered — for a shared shell that can be `floating_chat` while main + // Chat runs on it, and gating chat-first on that rejected the tool the + // model had just been offered. + const runSurfaceKind = typeof runInput.surfaceKind === "string" && runInput.surfaceKind.trim() + ? runInput.surfaceKind.trim() + : text(row.surface_kind); + const chatFirstUi = admittedCapabilities.chatFirstUi === true && runSurfaceKind === "main_chat"; const controlGeneration = Number(admittedCapabilities.chatFirstControlGeneration); return { ownerId: text(row.owner_id), @@ -782,7 +790,10 @@ export class RunToolCapabilityBroker { attemptStatus: text(row.authoritative_attempt_status) as AttemptStatus, currentAttemptId: text(latest.attempt_id), profile: this.profileForSession(sessionId), - surfaceKind: externalSurface?.authority === "swift_realtime" ? "realtime_voice" : text(row.surface_kind), + // Also the run's surface: Swift re-validates an authorized invocation with + // `surfaceKind == "main_chat"` before it will execute a chat-first tool, + // and selects the manifest digest from the same field. + surfaceKind: externalSurface?.authority === "swift_realtime" ? "realtime_voice" : runSurfaceKind, externalRefKind: row.external_ref_kind === null ? null : text(row.external_ref_kind), externalRefId: row.external_ref_id === null ? null : text(row.external_ref_id), originatingUserText: typeof runInput.prompt === "string" ? runInput.prompt : "", diff --git a/desktop/macos/agent/tests/chat-first-capability-projection.test.ts b/desktop/macos/agent/tests/chat-first-capability-projection.test.ts index 4644c138527..504c58bf01e 100644 --- a/desktop/macos/agent/tests/chat-first-capability-projection.test.ts +++ b/desktop/macos/agent/tests/chat-first-capability-projection.test.ts @@ -56,6 +56,153 @@ describe("chat-first admitted capability projection", () => { store.close(); }); + /// The desktop never sends `admittedContextSnapshot` — it is an internal + /// kernel field, absent from the wire protocol — so every real Main Chat run + /// takes the branch that builds its own snapshot. That branch dropped the + /// capability, and the adapter metadata is derived from that snapshot, so the + /// spawned model was offered the 41 base tools and never `render_chat_blocks`. + /// Every existing test here passed a snapshot in by hand and so never + /// exercised the path the product uses. + it("projects the capability onto a run that arrives without a context snapshot", async () => { + const { store, adapter, kernel } = createKernelHarness(newDatabasePath(), "acp"); + const resolved = kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "no-snapshot" }, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: true, controlGeneration: 7 }, + }); + + await kernel.executeRun({ + ownerId: "owner", + sessionId: resolved.agentSessionId, + surfaceKind: "main_chat", + externalRefKind: "chat", + externalRefId: "no-snapshot", + defaultAdapterId: "acp", + adapterId: "acp", + clientId: "no-snapshot-client", + prompt: "Show me three open tasks I could pick up right now.", + cwd: "/tmp/chat-first-no-snapshot", + requestId: "no-snapshot-request-1", + }); + + expect(adapter.opened[0]?.metadata).toMatchObject({ + surfaceKind: "main_chat", + chatFirstUi: true, + chatFirstControlGeneration: 7, + }); + store.close(); + }); + + /// One shell: main Chat and the floating bar project the same conversation, + /// so the session row can carry `floating_chat` while a main-Chat run executes + /// on it. Every chat-first gate admits `main_chat` only, so the adapter has to + /// be told the run's surface, not the session's registration. + it("stamps the run surface on a session the floating bar registered first", async () => { + const { store, adapter, kernel } = createKernelHarness(newDatabasePath(), "acp"); + const resolved = kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "floating_chat", externalRefKind: "chat", externalRefId: "shared-shell" }, + defaultAdapterId: "acp", + }); + kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "shared-shell" }, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: true, controlGeneration: 0 }, + }); + + await kernel.executeRun({ + ownerId: "owner", + sessionId: resolved.agentSessionId, + surfaceKind: "main_chat", + externalRefKind: "chat", + externalRefId: "shared-shell", + defaultAdapterId: "acp", + adapterId: "acp", + clientId: "shared-shell-client", + prompt: "List three of my open tasks.", + cwd: "/tmp/chat-first-shared-shell", + requestId: "shared-shell-request-1", + }); + + expect(adapter.opened[0]?.metadata).toMatchObject({ surfaceKind: "main_chat" }); + store.close(); + }); + + /// The tool being advertised is not the same as the tool being allowed. The + /// broker re-checks the surface when the model actually calls it, and it read + /// the session's registration rather than the run's surface — so a shared + /// shell offered `render_chat_blocks` and then answered + /// `tool_not_allowed: Tool is unavailable for this run execution profile`. + it("allows the chat-first tools on a main-Chat run of a floating-registered session", async () => { + const { store, kernel } = createKernelHarness(newDatabasePath(), "acp"); + const resolved = kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "floating_chat", externalRefKind: "chat", externalRefId: "shared-allow" }, + defaultAdapterId: "acp", + }); + kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "shared-allow" }, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: true, controlGeneration: 0 }, + }); + + await kernel.executeRun({ + ownerId: "owner", + sessionId: resolved.agentSessionId, + surfaceKind: "main_chat", + externalRefKind: "chat", + externalRefId: "shared-allow", + defaultAdapterId: "acp", + adapterId: "acp", + clientId: "shared-allow-client", + prompt: "Render my tasks as cards.", + cwd: "/tmp/chat-first-shared-allow", + requestId: "shared-allow-request-1", + }); + + const runRow = store.getRow( + "SELECT input_json FROM runs WHERE session_id = ? ORDER BY rowid DESC LIMIT 1", + [resolved.agentSessionId], + ); + const runInput = JSON.parse(String(runRow.input_json)); + expect(runInput.surfaceKind).toBe("main_chat"); + expect(runInput.admittedContextSnapshot.capabilities.allowedToolNames).toContain("render_chat_blocks"); + store.close(); + }); + + it("leaves a run capability-off when the shell never sampled one", async () => { + const { store, adapter, kernel } = createKernelHarness(newDatabasePath(), "acp"); + const resolved = kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "unsampled" }, + defaultAdapterId: "acp", + }); + + await kernel.executeRun({ + ownerId: "owner", + sessionId: resolved.agentSessionId, + surfaceKind: "main_chat", + externalRefKind: "chat", + externalRefId: "unsampled", + defaultAdapterId: "acp", + adapterId: "acp", + clientId: "unsampled-client", + prompt: "Anything.", + cwd: "/tmp/chat-first-unsampled", + requestId: "unsampled-request-1", + }); + + expect(adapter.opened[0]?.metadata).toMatchObject({ + surfaceKind: "main_chat", + chatFirstUi: false, + chatFirstControlGeneration: null, + }); + store.close(); + }); + it("preserves the enabled main-Chat generation through run admission for both dynamic tools", async () => { const { store, adapter, kernel } = createKernelHarness(newDatabasePath(), "acp"); const resolved = kernel.resolveSurfaceSession({ diff --git a/desktop/macos/agent/tests/conversation-journal.test.ts b/desktop/macos/agent/tests/conversation-journal.test.ts index cc57dfebeba..1d747a69f60 100644 --- a/desktop/macos/agent/tests/conversation-journal.test.ts +++ b/desktop/macos/agent/tests/conversation-journal.test.ts @@ -400,6 +400,49 @@ describe("kernel conversation journal", () => { fixture.store.close(); }); + it("keeps the cards the agent rendered when the surface terminalizes its own projection", () => { + // The live failure this pins: `render_chat_blocks` appended three task + // cards, the tool answered `ok`, and about three seconds later the turn + // terminalized with the projection Swift built from the adapter stream — + // text and tool calls, and no card, because the append was a journal + // mutation the surface never saw. The replace then deleted all three. + const fixture = newSurface("main_chat", "chat", "chat-first-survives-terminal"); + const { run, attempt } = insertActiveRunAttempt(fixture, "chat-first-survives-terminal"); + recordStreamingAssistantPlaceholder(fixture, "turn-chat-first-survives"); + const cards: ConversationContentBlock[] = [ + { type: "taskCard", id: "cfb-task-1", taskId: "task-1" }, + { type: "goalLink", id: "cfb-goal-1", goalId: "goal-1", summary: "Ship the desktop beta" }, + { type: "memoryLink", id: "cfb-memory-1", memoryId: "memory-1", summary: "Prefers morning reviews" }, + ]; + appendChatFirstBlocksToProducingTurn(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + blocks: cards, + }); + + fixture.store.execute("UPDATE runs SET status = 'succeeded' WHERE run_id = ?", [run.runId]); + fixture.store.execute("UPDATE run_attempts SET status = 'succeeded' WHERE attempt_id = ?", [attempt.attemptId]); + const surfaceProjection: ConversationContentBlock[] = [ + { type: "text", id: "turn-chat-first-survives:terminal", text: "Here are your three most urgent tasks." }, + ]; + const terminalized = terminalizeJournalTurn(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + turnId: "turn-chat-first-survives", + producingRunId: run.runId, + producingAttemptId: attempt.attemptId, + disposition: "accept", + content: "Here are your three most urgent tasks.", + replaceContentBlocks: surfaceProjection, + nowMs: 20, + }); + + expect(terminalized.contentBlocks).toEqual([...surfaceProjection, ...cards]); + fixture.store.close(); + }); + it("attaches only a ready local generated image to the producing Chat-first turn", () => { const fixture = newSurface("main_chat", "chat", "chat-first-evidence"); const { run, attempt } = insertActiveRunAttempt(fixture, "chat-first-evidence"); From 3da69775cfbd906ce82e6097d69366ce199e2bf7 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Thu, 3 Sep 2026 04:17:13 -0400 Subject: [PATCH 22/29] fix(desktop): ticking a task card shows it done, not gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking a box replaced the card with "Task is no longer available" — the one message that means the row is not the user's any more. Nothing had gone away. Ticking moves the task between the store's two arrays and the toggle awaits SQLite first, so `liveTask` is briefly nil. That flips the card's `hydrationKey`, SwiftUI cancels the in-flight hydration, and `TasksStore.isCurrent` folds `!Task.isCancelled` into its lease check — so `resolveCanonicalTask` then returns nil *by construction*, not because the task is missing. The old code published that nil, cleared the retained task and set `hydrationFinished`: exactly the pair that draws the unavailable placeholder. A superseded hydration now speaks for nothing, and a store that cannot vouch for a row is no longer read as the row being retired. Co-Authored-By: Claude Opus 5 --- .../Blocks/ChatFirstContentBlockViews.swift | 64 ++++- .../ChatFirstTaskCardCompletionTests.swift | 238 ++++++++++++++++++ 2 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 desktop/macos/Desktop/Tests/ChatFirstTaskCardCompletionTests.swift diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift index 33a036405be..47d89b5dee6 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift @@ -194,6 +194,14 @@ struct TaskCardView: View { } else if hydrationFinished { ChatFirstUnavailableBlockView(entityName: "Task") .onAppear { + log( + "TaskCardView: \(taskID) unavailable — store=\(tasksStore.tasks.count)" + + " incomplete=\(tasksStore.incompleteTasks.count)" + + " completed=\(tasksStore.completedTasks.count)" + + " deleted=\(tasksStore.deletedTasks.count)" + + " present=\(tasksStore.tasks.contains { $0.id == taskID })" + + " retiredHere=\(isExplicitlyRetired)" + + " retained=\(retainedCompletedTask?.id ?? "none")") AnalyticsManager.shared.chatFirst( .richBlock(kind: .taskCard, outcome: .stalePlaceholder, action: .none) ) @@ -220,11 +228,26 @@ struct TaskCardView: View { hydrationFinished = false } let resolvedTask = await tasksStore.resolveCanonicalTask(id: taskID) - retainCompletedTaskIfNeeded(resolvedTask) - if resolvedTask == nil { - retainedCompletedTask = nil + switch ChatFirstTaskCardHydration.resolution( + isCancelled: Task.isCancelled, hasLiveTask: liveTask != nil) + { + case .abandon: + return + case .settle: + // The toggle won the race and put the task back in the store. That is + // a better answer than this hydration's, so take it. + retainCompletedTaskIfNeeded(liveTask) + hydrationFinished = true + case .adopt: + if resolvedTask == nil { + log("TaskCardView: \(taskID) hydrated to nothing — the store cannot vouch for this task") + } + // A store that cannot vouch for the row is not the same as a row the + // user retired, and only the second is grounds for taking a card away. + // `.onChange(of: isExplicitlyRetired)` is the one clearer. + retainCompletedTaskIfNeeded(resolvedTask) + hydrationFinished = true } - hydrationFinished = true } } @@ -358,6 +381,39 @@ struct TaskCardView: View { } } +/// What a finished hydration is allowed to write back to the card. +/// +/// `.task(id:)` cancels the in-flight hydration when its key changes, but Swift +/// cancellation is cooperative: the body keeps running and its `await` still +/// returns. A hydration that started while the card had no task can therefore +/// land *after* the reader has ticked that task, carrying an answer from before +/// the tick — and `resolveCanonicalTask` answers nil for any row it cannot +/// vouch for, including one whose owner lease turned over mid-flight. Applying +/// that late nil cleared the retained task and marked hydration finished, which +/// is exactly the pair that renders "Task is no longer available" under a task +/// the reader had just completed. +/// +/// Observed directly: a card visibly showing its task logged +/// `hydrated resolved=nil` from a hydration still in flight behind it. +enum ChatFirstTaskCardHydration { + enum Resolution: Equatable { + /// Nothing newer arrived; the answer is the card's state. + case adopt + /// The card already has a live task, so there is nothing to adopt — but + /// this hydration is genuinely over. + case settle + /// A successor hydration owns the card's state. Write nothing at all: + /// even `hydrationFinished` would flash the unavailable placeholder in + /// the gap before the successor answers. + case abandon + } + + static func resolution(isCancelled: Bool, hasLiveTask: Bool) -> Resolution { + if isCancelled { return .abandon } + return hasLiveTask ? .settle : .adopt + } +} + enum ChatFirstTaskCardPresentation { static func displayTask( liveTask: TaskActionItem?, diff --git a/desktop/macos/Desktop/Tests/ChatFirstTaskCardCompletionTests.swift b/desktop/macos/Desktop/Tests/ChatFirstTaskCardCompletionTests.swift new file mode 100644 index 00000000000..3feef7b2e7e --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstTaskCardCompletionTests.swift @@ -0,0 +1,238 @@ +import XCTest + +@testable import Omi_Computer + +/// Ticking a task card in the transcript. +/// +/// The card has no list to fall back on: it names one task by id and draws +/// whatever the store says that task is. Completion is the one gesture that +/// moves a task between the store's two arrays, so it is also the one gesture +/// that can lose it — and a card that loses its task does not show a ticked +/// box, it shows "Task is no longer available", which reads as if the task had +/// been deleted rather than done. +@MainActor +final class ChatFirstTaskCardCompletionTests: XCTestCase { + private var fixture: RewindStorageTestIsolation.Fixture? + private var previousOwnerID: String? + private var previousAuth: RewindStorageTestIsolation.AuthSnapshot? + + override func setUp() async throws { + let fixture = try await RewindStorageTestIsolation.setUp(userIdPrefix: "task-card-completion") + self.fixture = fixture + previousAuth = RewindStorageTestIsolation.captureAuthSnapshot() + previousOwnerID = RuntimeOwnerIdentity.currentOwnerId() + await transitionOwner(to: fixture.testUserId) + RewindStorageTestIsolation.signInForTests(userId: fixture.testUserId) + TasksStore.shared.resetSessionState() + } + + override func tearDown() async throws { + TasksStore.shared.resetSessionState() + if let previousAuth { RewindStorageTestIsolation.restoreAuthSnapshot(previousAuth) } + await transitionOwner(to: previousOwnerID) + await RewindStorageTestIsolation.tearDown(userDir: fixture?.userDir) + fixture = nil + } + + /// The card reads `TasksStore.tasks`. Completing has to leave the task + /// somewhere in there, still not retired, or the card has nothing to draw. + func testACompletedTaskIsStillTheTaskTheCardNames() async throws { + let store = TasksStore.shared + try await ActionItemStorage.shared.syncTaskActionItems( + [ + TaskActionItem( + id: "card-task", + description: "Attend the Claw hackathon", + completed: false, + createdAt: Date(), + dueAt: nil, + source: "manual") + ], + authorization: .unrestricted) + + let hydrated = await store.resolveCanonicalTask(id: "card-task") + let task = try XCTUnwrap(hydrated, "the card hydrates its task before it can draw one") + XCTAssertFalse(task.completed) + + await toggleWithRemoteAccepting(task, store: store) + + let afterToggle = store.tasks.first { $0.id == "card-task" } + let stillThere = try XCTUnwrap( + afterToggle, "completing a task must not take it out of the store the card reads") + XCTAssertTrue(stillThere.completed, "the box is ticked, not emptied") + XCTAssertFalse(stillThere.isRetired, "completing is not retiring") + } + + /// What the card actually renders, through its own presentation rule. + func testTheCardShowsTheTickedTaskRatherThanAnUnavailablePlaceholder() async throws { + let store = TasksStore.shared + try await ActionItemStorage.shared.syncTaskActionItems( + [ + TaskActionItem( + id: "card-task", + description: "Implement one-click summary email", + completed: false, + createdAt: Date(), + dueAt: nil, + source: "manual") + ], + authorization: .unrestricted) + + let resolved = await store.resolveCanonicalTask(id: "card-task") + let task = try XCTUnwrap(resolved) + await toggleWithRemoteAccepting(task, store: store) + + let liveTask = store.tasks.first { $0.id == "card-task" && !$0.isRetired } + let displayed = ChatFirstTaskCardPresentation.displayTask( + liveTask: liveTask, + retainedCompletedTask: nil + ) + let shown = try XCTUnwrap( + displayed, + "a task the reader just ticked is done, not gone — the card must not fall through to " + + "\"Task is no longer available\"") + XCTAssertTrue(shown.completed) + } + + /// The card re-hydrates by id whenever it loses the row — after a relaunch, + /// or when the store's arrays are rebuilt under it. A completed task has to + /// come back from that lookup too. + func testAFreshCardStillResolvesATaskThatWasAlreadyCompleted() async throws { + let store = TasksStore.shared + try await ActionItemStorage.shared.syncTaskActionItems( + [ + TaskActionItem( + id: "card-task", + description: "Get the agent ecosystem working again", + completed: true, + createdAt: Date(), + dueAt: nil, + source: "manual") + ], + authorization: .unrestricted) + store.resetSessionState() + + let resolved = await store.resolveCanonicalTask(id: "card-task") + let task = try XCTUnwrap( + resolved, "a completed task is still a task the card can name and draw") + XCTAssertTrue(task.completed) + XCTAssertFalse(task.isRetired) + } + + /// The toggle with its network legs stubbed to succeed — the production case, + /// where the backend accepts the completion. A failed remote leg is a + /// different behaviour (rollback) with its own coverage. + private func toggleWithRemoteAccepting( + _ task: TaskActionItem, + store: TasksStore + ) async { + await store.toggleTask( + task, + operationOverrides: TasksStore.ToggleOperationOverrides( + updateLocal: { completed, _ in + try await ActionItemStorage.shared.updateCompletionStatus( + backendId: task.id, completed: completed, authorization: .unrestricted) + guard + let stored = try await ActionItemStorage.shared.getLocalActionItem( + byBackendId: task.id) + else { throw CocoaError(.fileNoSuchFile) } + return stored + }, + refreshDashboard: { _ in await store.loadDashboardTasks() }, + updateRemote: { _, _ in + guard + let stored = try await ActionItemStorage.shared.getLocalActionItem( + byBackendId: task.id) + else { throw CocoaError(.fileNoSuchFile) } + return stored + }, + syncRemote: { _, _ in }, + rollbackLocal: {} + )) + } + + /// The card's own state machine, driven through the interleaving that made a + /// ticked task read as a deleted one. + /// + /// Ticking moves the task between the store's two arrays, and the toggle + /// awaits SQLite before it does — so `liveTask` can be nil for a moment. The + /// card's `hydrationKey` flips on exactly that, SwiftUI cancels the in-flight + /// hydration, and `TasksStore.isCurrent` folds `!Task.isCancelled` into its + /// lease check, so `resolveCanonicalTask` answers nil by construction. The + /// old code published that nil. + func testACancelledHydrationDoesNotSpeakForTheCard() { + XCTAssertEqual( + ChatFirstTaskCardHydration.resolution(isCancelled: true, hasLiveTask: false), + .abandon, + "a hydration SwiftUI has already superseded must not write the card's state") + XCTAssertEqual( + ChatFirstTaskCardHydration.resolution(isCancelled: true, hasLiveTask: true), + .abandon) + XCTAssertEqual( + ChatFirstTaskCardHydration.resolution(isCancelled: false, hasLiveTask: true), + .settle, + "the store already has the task — there is nothing left to hydrate") + XCTAssertEqual( + ChatFirstTaskCardHydration.resolution(isCancelled: false, hasLiveTask: false), + .adopt, + "an uncontested hydration is the card's answer") + } + + /// The whole point, stated as the reader sees it: a task ticked and then + /// abandoned by a late nil is still shown, ticked. + func testATickedTaskSurvivesALateNilFromASupersededHydration() async throws { + let store = TasksStore.shared + try await ActionItemStorage.shared.syncTaskActionItems( + [ + TaskActionItem( + id: "card-task", + description: "Attend the Claw hackathon", + completed: false, + createdAt: Date(), + dueAt: nil, + source: "manual") + ], + authorization: .unrestricted) + let resolved = await store.resolveCanonicalTask(id: "card-task") + let task = try XCTUnwrap(resolved) + await toggleWithRemoteAccepting(task, store: store) + + let ticked = try XCTUnwrap(store.tasks.first { $0.id == "card-task" }) + XCTAssertTrue(ticked.completed) + + // The card has retained the ticked row. A hydration that started before the + // tick now returns nil, cancelled. + var retained: TaskActionItem? = ticked + switch ChatFirstTaskCardHydration.resolution(isCancelled: true, hasLiveTask: false) { + case .abandon: + break + case .settle, .adopt: + retained = nil // what the old code did with a nil answer + } + + XCTAssertNotNil( + ChatFirstTaskCardPresentation.displayTask(liveTask: nil, retainedCompletedTask: retained), + "the reader ticked this task — the card owes them a ticked box, not " + + "\"Task is no longer available\"") + } + + private func transitionOwner(to ownerID: String?) async { + do { + _ = try await RuntimeOwnerIdentity.performEffectiveOwnerTransition( + plannedNextOwner: { _, _ in ownerID }, + quiesceVoice: { _, _ in }, + retargetLocalStorage: { _, _ in }, + ownerDidChange: {}, + { defaults in + defaults.removeObject(forKey: .automationOwnerOverride) + if let ownerID { + defaults.set(ownerID, forKey: .authUserId) + } else { + defaults.removeObject(forKey: .authUserId) + } + }) + } catch { + XCTFail("owner transition failed: \(error)") + } + } +} From 99436fa7c0bfb6db6eded4fd4e5d70078debccab Mon Sep 17 00:00:00 2001 From: David Zhang Date: Thu, 3 Sep 2026 04:17:25 -0400 Subject: [PATCH 23/29] fix(chat): components replace the writing instead of doubling it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the same answer was being said twice. The tool's own instructions were the first. They told the model to render components "whenever you retrieve, create, or summarize" those entities and "do not leave them as a Markdown table/list" — so a summary of yesterday stacked three conversation cards above the prose that already said it. That is backwards: reading an entity to answer a question makes it a *source*, and sources belong in citations. A component is for the entity that IS the answer — the one the user asked to see or act on, or the one this turn created or changed — and when components are rendered they are the list, so the prose above them is one lead-in sentence at most. The second was ours. A turn that carried only components still printed the blocks' own degradation text underneath them, so three cards sat above the three lines they were made from. `ChatStructuredFallbackText` mirrors the producer case for case, and a body that is only that projection is recognised as the cards talking to themselves rather than as answer text. Mobile drew six of the nine kinds the desktop transcript draws and silently skipped the rest. Discovery cards and the two agent-run blocks have components now, and a parity test fails if the desktop grows a tenth without one. Co-Authored-By: Claude Opus 5 --- .../content_blocks/agent_run_blocks.dart | 97 +++++++++++++++ .../chat_content_block_list.dart | 26 +++-- .../content_blocks/discovery_card_block.dart | 78 +++++++++++++ .../unit/chat_content_block_parity_test.dart | 110 ++++++++++++++++++ .../Generated/GeneratedToolExecutors.swift | 2 +- .../Components/ChatBubbleSupport.swift | 81 ++++++++++++- .../Tests/ChatDiscoverabilityTests.swift | 5 +- .../Tests/ChatTimelineContinuityTests.swift | 49 ++++++++ .../agent/src/runtime/omi-tool-manifest.ts | 14 ++- .../agent/tests/omi-tool-manifest.test.ts | 12 +- 10 files changed, 453 insertions(+), 21 deletions(-) create mode 100644 app/lib/pages/chat/widgets/content_blocks/agent_run_blocks.dart create mode 100644 app/lib/pages/chat/widgets/content_blocks/discovery_card_block.dart create mode 100644 app/test/unit/chat_content_block_parity_test.dart diff --git a/app/lib/pages/chat/widgets/content_blocks/agent_run_blocks.dart b/app/lib/pages/chat/widgets/content_blocks/agent_run_blocks.dart new file mode 100644 index 00000000000..5720f940449 --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/agent_run_blocks.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import 'package:omi/backend/schema/chat_content_block.dart'; + +import 'chat_block_chrome.dart'; + +/// Mobile counterparts of the desktop `AgentSpawnCard` / `AgentCompletionCard`. +/// +/// A background agent run is started and inspected on the desktop, so these +/// carry no "open" destination the way the goal and memory links do — a phone +/// cannot attach to that session. They are deliberately read-only: the point is +/// that a run the user started still reads as a run in the transcript on their +/// phone, instead of collapsing to the bare line "Agent started - ". +class AgentSpawnBlock extends StatelessWidget { + const AgentSpawnBlock({super.key, required this.block}); + + final AgentSpawnContentBlock block; + + @override + Widget build(BuildContext context) { + return _AgentRunCard( + icon: Icons.smart_toy_outlined, + label: 'Agent started', + title: block.title, + body: block.objective, + ); + } +} + +class AgentCompletionBlock extends StatelessWidget { + const AgentCompletionBlock({super.key, required this.block}); + + final AgentCompletionContentBlock block; + + /// The runtime's status vocabulary is open, so anything that is not a known + /// terminal failure reads as a completed run rather than an invented state. + bool get _failed { + final status = block.status.trim().toLowerCase(); + return status == 'failed' || status == 'error' || status == 'cancelled'; + } + + @override + Widget build(BuildContext context) { + return _AgentRunCard( + icon: _failed ? Icons.error_outline : Icons.check_circle_outline, + label: _failed ? 'Agent stopped' : 'Agent completed', + title: block.title, + body: block.output, + ); + } +} + +class _AgentRunCard extends StatelessWidget { + const _AgentRunCard({ + required this.icon, + required this.label, + required this.title, + required this.body, + }); + + final IconData icon; + final String label; + final String title; + final String body; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final trimmedTitle = title.trim(); + final trimmedBody = body.trim(); + + return ChatBlockCard( + semanticsLabel: trimmedTitle.isEmpty ? label : '$label: $trimmedTitle', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ChatBlockEyebrow(icon: icon, label: label), + if (trimmedTitle.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(trimmedTitle, style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)), + ], + if (trimmedBody.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + trimmedBody, + maxLines: 6, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ], + ), + ); + } +} diff --git a/app/lib/pages/chat/widgets/content_blocks/chat_content_block_list.dart b/app/lib/pages/chat/widgets/content_blocks/chat_content_block_list.dart index 5f0237b8be8..0b16535e163 100644 --- a/app/lib/pages/chat/widgets/content_blocks/chat_content_block_list.dart +++ b/app/lib/pages/chat/widgets/content_blocks/chat_content_block_list.dart @@ -4,7 +4,9 @@ import 'package:omi/backend/schema/chat_content_block.dart'; import 'package:omi/backend/schema/conversation.dart'; import 'package:omi/backend/schema/message.dart'; +import 'agent_run_blocks.dart'; import 'conversation_link_blocks.dart'; +import 'discovery_card_block.dart'; import 'goal_link_block.dart'; import 'memory_link_block.dart'; import 'question_card_block.dart'; @@ -12,11 +14,11 @@ import 'task_card_block.dart'; /// Renders the interactable components for a message's `content_blocks`. /// -/// Only blocks that have their own mobile component are rendered here. text, -/// thinking, toolCall, discoveryCard, citation, agentSpawn, agentCompletion and -/// unknown types are already covered by the message body (or its synthesized -/// fallback text) and deliberately render nothing extra — but they never hide -/// the message. +/// Every block the desktop transcript draws as its own control has a component +/// here, so a turn reads the same on both clients. text, thinking, toolCall, +/// citation and unknown types are covered by the message body (or its +/// synthesized fallback text) and deliberately render nothing extra — but they +/// never hide the message. class ChatContentBlockList extends StatelessWidget { const ChatContentBlockList({ super.key, @@ -40,7 +42,10 @@ class ChatContentBlockList extends StatelessWidget { block is CaptureLinkContentBlock || block is ConversationLinkContentBlock || block is MemoryLinkContentBlock || - block is QuestionCardContentBlock; + block is QuestionCardContentBlock || + block is DiscoveryCardContentBlock || + block is AgentSpawnContentBlock || + block is AgentCompletionContentBlock; } Widget? _build(ChatContentBlock block) { @@ -57,13 +62,16 @@ class ChatContentBlockList extends StatelessWidget { return MemoryLinkBlock(block: block); case QuestionCardContentBlock(): return QuestionCardBlock(block: block, sendMessage: sendMessage); + case DiscoveryCardContentBlock(): + return DiscoveryCardBlock(block: block); + case AgentSpawnContentBlock(): + return AgentSpawnBlock(block: block); + case AgentCompletionContentBlock(): + return AgentCompletionBlock(block: block); case TextContentBlock(): case ThinkingContentBlock(): case ToolCallContentBlock(): - case DiscoveryCardContentBlock(): case CitationContentBlock(): - case AgentSpawnContentBlock(): - case AgentCompletionContentBlock(): case UnknownContentBlock(): return null; } diff --git a/app/lib/pages/chat/widgets/content_blocks/discovery_card_block.dart b/app/lib/pages/chat/widgets/content_blocks/discovery_card_block.dart new file mode 100644 index 00000000000..c5e54dfeb90 --- /dev/null +++ b/app/lib/pages/chat/widgets/content_blocks/discovery_card_block.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import 'package:omi/backend/schema/chat_content_block.dart'; + +import 'chat_block_chrome.dart'; + +/// Mobile counterpart of the desktop `DiscoveryCard`. +/// +/// The block carries a short summary and the full text behind it. Without a +/// component the transcript showed only the synthesized "Discovery - <title> - +/// <summary>" line, which loses the body entirely; this keeps the body one tap +/// away rather than dropping it. +class DiscoveryCardBlock extends StatefulWidget { + const DiscoveryCardBlock({super.key, required this.block}); + + final DiscoveryCardContentBlock block; + + @override + State<DiscoveryCardBlock> createState() => _DiscoveryCardBlockState(); +} + +class _DiscoveryCardBlockState extends State<DiscoveryCardBlock> { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final summary = widget.block.summary.trim(); + final fullText = widget.block.fullText.trim(); + // Expanding is only worth offering when there is more than the summary. + final hasMore = fullText.isNotEmpty && fullText != summary; + final body = _isExpanded && hasMore ? fullText : summary; + + return ChatBlockCard( + onTap: hasMore ? () => setState(() => _isExpanded = !_isExpanded) : null, + semanticsLabel: 'Discovery: ${widget.block.title}', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const ChatBlockEyebrow(icon: Icons.auto_awesome_outlined, label: 'Discovery'), + const SizedBox(height: 6), + if (widget.block.title.trim().isNotEmpty) + Text( + widget.block.title, + style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + if (body.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(body, style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + ], + if (hasMore) ...[ + const SizedBox(height: 8), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _isExpanded ? 'Show less' : 'Show more', + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 2), + Icon( + _isExpanded ? Icons.expand_less : Icons.expand_more, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ], + ], + ), + ); + } +} diff --git a/app/test/unit/chat_content_block_parity_test.dart b/app/test/unit/chat_content_block_parity_test.dart new file mode 100644 index 00000000000..6e48776d7b1 --- /dev/null +++ b/app/test/unit/chat_content_block_parity_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:omi/backend/schema/chat_content_block.dart'; +import 'package:omi/backend/schema/message.dart'; +import 'package:omi/pages/chat/widgets/content_blocks/chat_content_block_list.dart'; + +/// The desktop transcript draws nine block kinds as their own control. A kind +/// the phone cannot draw degrades to one synthesized line — "Discovery - <title>", +/// "Agent started - <title>" — so the same turn reads as a card on one client +/// and a stray label on the other. These pin the parity in both directions. +void main() { + ServerMessage messageWith(List<Map<String, dynamic>> blocks, {String text = ''}) { + return ServerMessage.fromJson({ + 'id': 'message-1', + 'created_at': '2026-09-02T12:00:00Z', + 'text': text, + 'sender': 'ai', + 'type': 'text', + 'content_blocks': blocks, + }); + } + + /// Every kind the desktop renders as a control, with the payload the runtime + /// sends for it. + const desktopRenderedBlocks = <String, Map<String, dynamic>>{ + 'taskCard': {'id': 'b-task', 'type': 'taskCard', 'taskId': 'task-1'}, + 'goalLink': {'id': 'b-goal', 'type': 'goalLink', 'goalId': 'goal-1', 'summary': 'Make Omi Great Again'}, + 'captureLink': {'id': 'b-capture', 'type': 'captureLink', 'conversationId': 'conversation-1', 'summary': 'Standup'}, + 'conversationLink': { + 'id': 'b-conversation', + 'type': 'conversationLink', + 'conversationId': 'conversation-2', + 'summary': 'Founders explore AI memory', + }, + 'memoryLink': {'id': 'b-memory', 'type': 'memoryLink', 'memoryId': 'memory-1', 'summary': 'Prefers dark mode'}, + 'questionCard': { + 'id': 'b-question', + 'type': 'questionCard', + 'questionId': 'question-1', + 'text': 'Which one first?', + 'subject': {'kind': 'task', 'id': 'task-1'}, + 'options': [ + {'optionId': 'option-1', 'label': 'The hackathon'}, + ], + }, + 'discoveryCard': { + 'id': 'b-discovery', + 'type': 'discoveryCard', + 'title': 'You ship on Fridays', + 'summary': 'Nine of your last ten releases landed on a Friday.', + 'fullText': 'Nine of your last ten releases landed on a Friday afternoon.', + }, + 'agentSpawn': { + 'id': 'b-spawn', + 'type': 'agentSpawn', + 'sessionId': 'session-1', + 'runId': 'run-1', + 'title': 'Fix the scroll', + 'objective': 'Keep the transcript pinned while streaming', + }, + 'agentCompletion': { + 'id': 'b-completion', + 'type': 'agentCompletion', + 'sessionId': 'session-1', + 'runId': 'run-1', + 'title': 'Fix the scroll', + 'output': 'Reply no longer collapses when it settles', + 'status': 'completed', + }, + }; + + test('every block the desktop draws as a control has a mobile component', () { + for (final entry in desktopRenderedBlocks.entries) { + expect( + ChatContentBlockList.hasRenderableBlocks(messageWith([entry.value])), + isTrue, + reason: '${entry.key} renders as a card on desktop and must not degrade to a label here', + ); + } + }); + + test('a body that is only the blocks own projection is left to the components', () { + final message = messageWith([ + desktopRenderedBlocks['goalLink']!, + desktopRenderedBlocks['taskCard']!, + desktopRenderedBlocks['taskCard']!, + ]); + + // What the runtime synthesizes for an unaware client, verbatim. + expect(message.text, 'Goal - Make Omi Great Again\nTask\nTask'); + expect(message.textIsStructuredFallback, isTrue); + }); + + test('prose the model actually wrote survives alongside its cards', () { + final message = messageWith( + [desktopRenderedBlocks['taskCard']!], + text: 'Start with the hackathon — the deadline is closest.', + ); + + expect(message.textIsStructuredFallback, isFalse); + }); + + test('blocks with no component still leave the body alone', () { + final message = messageWith([ + {'id': 'b-thinking', 'type': 'thinking', 'text': 'weighing the options'}, + ], text: 'Here is what I would do.'); + + expect(ChatContentBlockList.hasRenderableBlocks(message), isFalse); + expect(message.textIsStructuredFallback, isFalse); + }); +} diff --git a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift index 8b2231c2cb5..ae621a625ca 100644 --- a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift +++ b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift @@ -55,7 +55,7 @@ enum GeneratedSwiftToolExecutor: String { enum GeneratedToolExecutors { static let manifestVersion = 1 static let manifestDigest = "sha256:bb3ddf9efd89ddb68be7de310756422fe0ccb8d9eea6da0b7d8bc44fd456f2c2" - static let chatFirstManifestDigest = "sha256:219fc273c8559a3074c9eee26801cc95a463989bf6adc6cb5e8998eedb4b537a" + static let chatFirstManifestDigest = "sha256:2795f3af713ff85c99af99a47c8457e86a739a57e4aeca608ff6bf63ecf1dc44" static let aliasToCanonical: [String: GeneratedSwiftTool] = [ "search_screen_history": .semanticSearch, diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift index 518a2dab024..371145a63bc 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubbleSupport.swift @@ -49,7 +49,11 @@ enum ChatAssistantAnswerText { } } - let fallbackText = fallback.trimmingCharacters(in: .whitespacesAndNewlines) + // A body that is only the blocks' own degradation has nothing the cards + // above it do not already say, so it is not answer text at all. + let fallbackText = + ChatStructuredFallbackText.bodyIsBlockProjection(text: fallback, contentBlocks: contentBlocks) + ? "" : fallback.trimmingCharacters(in: .whitespacesAndNewlines) guard let lastTool = contentBlocks.lastIndex(where: { block in if case .toolCall = block { return true } @@ -75,6 +79,81 @@ enum ChatAssistantAnswerText { } } +/// The unaware-client projection of a turn's structured blocks. +/// +/// A turn that answers with cards writes no prose, so the runtime synthesizes +/// one line per block — "Goal - Make Omi Great Again", the bare word "Task" +/// once per task card — and puts it on the message's ordinary text field. That +/// is the degradation contract for clients that cannot draw the cards +/// (`agent/src/runtime/content-block-fallback.ts`). A client that *does* draw +/// them must recognize its own projection and not print it back underneath the +/// controls it just rendered. Mobile recognizes it the same way, in +/// `ServerMessage.textIsStructuredFallback`; the two must agree, so this +/// mirrors the producer case for case. +enum ChatStructuredFallbackText { + static func bodyIsBlockProjection(text: String, contentBlocks: [ChatContentBlock]) -> Bool { + guard !contentBlocks.isEmpty else { return false } + let projection = projected(contentBlocks) + guard !projection.isEmpty else { return false } + let body = text.trimmingCharacters(in: .whitespacesAndNewlines) + return body.isEmpty || normalized(body) == normalized(projection) + } + + static func projected(_ contentBlocks: [ChatContentBlock]) -> String { + contentBlocks.map(line(for:)).filter { !$0.isEmpty }.joined(separator: "\n") + } + + private static func normalized(_ value: String) -> String { + value.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") + } + + private static func labelled(_ label: String, _ details: String?...) -> String { + var unique: [String] = [] + for detail in details { + let trimmed = detail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty, !unique.contains(trimmed) else { continue } + unique.append(trimmed) + } + return unique.isEmpty ? label : "\(label) - \(unique.joined(separator: " - "))" + } + + private static func nonEmpty(_ value: String, or fallback: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? fallback : trimmed + } + + private static func line(for block: ChatContentBlock) -> String { + switch block { + case .text(_, let text): + return nonEmpty(text, or: "Message") + case .toolCall(_, let name, _, _, let input, let output): + return labelled("Tool", name, output ?? input?.summary) + case .thinking(_, let text): + return labelled("Thinking", text) + case .discoveryCard(_, let title, let summary, _): + return labelled("Discovery", title, summary) + case .questionCard(_, _, let text, _, _, _, _): + return nonEmpty(text, or: "Question") + case .taskCard: + return "Task" + case .goalLink(_, _, let summary): + return labelled("Goal", summary) + case .captureLink(_, _, _, let summary): + return labelled("Capture", summary) + case .conversationLink(_, _, let summary, _): + return labelled("Meeting notes ready", summary) + case .memoryLink(_, _, let summary): + return labelled("Memory", summary) + case .citation(_, let reference): + return labelled("Source", reference.title, reference.preview) + case .agentSpawn(_, _, _, _, let title, let objective, _): + return labelled("Agent started", title, objective) + case .agentCompletion(_, _, _, _, let title, _, let output, _): + return labelled("Agent completed", title, output) + } + } +} + /// Shared understated date treatment for a transcript row and its prompt-rail /// preview. Keeping this outside the bubble makes the time contextual rather /// than part of the message itself. diff --git a/desktop/macos/Desktop/Tests/ChatDiscoverabilityTests.swift b/desktop/macos/Desktop/Tests/ChatDiscoverabilityTests.swift index 8110561345a..d44f66fbb6e 100644 --- a/desktop/macos/Desktop/Tests/ChatDiscoverabilityTests.swift +++ b/desktop/macos/Desktop/Tests/ChatDiscoverabilityTests.swift @@ -198,7 +198,10 @@ final class ChatDiscoverabilityTests: XCTestCase { declaredTools.insert(name) } } - let localApiOnlyTools: Set<String> = ["get_local_status", "get_screenshot"] + // `look_at_frame` is `get_screenshot`'s alias on the local agent API, and + // inherits its reach: the local API is the only adapter either is + // advertised to, so neither can appear in a chat adapter's declarations. + let localApiOnlyTools: Set<String> = ["get_local_status", "get_screenshot", "look_at_frame"] for toolName in DesktopCapabilityRegistry.desktopToolNames where !localApiOnlyTools.contains(toolName) { XCTAssertTrue(declaredTools.contains(toolName), "Missing agent tool declaration for \(toolName)") diff --git a/desktop/macos/Desktop/Tests/ChatTimelineContinuityTests.swift b/desktop/macos/Desktop/Tests/ChatTimelineContinuityTests.swift index c82b98de551..f65c7c26fd8 100644 --- a/desktop/macos/Desktop/Tests/ChatTimelineContinuityTests.swift +++ b/desktop/macos/Desktop/Tests/ChatTimelineContinuityTests.swift @@ -254,6 +254,55 @@ final class ChatTimelineContinuityTests: XCTestCase { XCTAssertEqual(settled.copyableText, "You filmed the launch video and tested the memory graph.") } + /// A turn that answers with cards writes no prose, so the runtime synthesizes + /// one line per block for clients that cannot draw them and puts it on the + /// message's ordinary text field. The desktop draws the cards, so printing + /// that line too rendered the goal card, three task cards, and then + /// "Goal - Make Omi Great Again / Task / Task / Task" underneath them. + func testTheCardsOwnDegradationIsNotPrintedUnderTheCards() { + let blocks: [ChatContentBlock] = [ + .goalLink(id: "goal_1", goalId: "g-1", summary: "Make Omi Great Again"), + .taskCard(id: "task_1", taskId: "t-1"), + .taskCard(id: "task_2", taskId: "t-2"), + .taskCard(id: "task_3", taskId: "t-3"), + ] + let projection = "Goal - Make Omi Great Again\nTask\nTask\nTask" + XCTAssertEqual( + ChatStructuredFallbackText.projected(blocks), projection, + "the desktop has to recognize the exact text the runtime synthesizes") + XCTAssertEqual( + ChatAssistantAnswerText.visible( + contentBlocks: blocks, fallback: projection, isStreaming: false), + "") + } + + /// The suppression is keyed on the body *being* that projection, not on the + /// turn merely having cards — an answer the model actually wrote still reads. + func testProseWrittenAlongsideCardsSurvives() { + let blocks: [ChatContentBlock] = [ + .taskCard(id: "task_1", taskId: "t-1"), + .text(id: "text_1", text: "Start with the hackathon — the deadline is closest."), + ] + XCTAssertEqual( + ChatAssistantAnswerText.visible( + contentBlocks: blocks, + fallback: "Start with the hackathon — the deadline is closest.", + isStreaming: false), + "Start with the hackathon — the deadline is closest.") + } + + /// Whitespace is normalized on both sides, the way mobile's + /// `textIsStructuredFallback` does it, so a re-wrapped body is still + /// recognized as the projection rather than printed back. + func testAReflowedProjectionIsStillRecognized() { + let blocks: [ChatContentBlock] = [ + .memoryLink(id: "memory_1", memoryId: "m-1", summary: "Prefers dark mode") + ] + XCTAssertTrue( + ChatStructuredFallbackText.bodyIsBlockProjection( + text: " Memory - Prefers dark mode ", contentBlocks: blocks)) + } + func testSettledPreToolTextRemainsWhenItIsTheOnlyAnswer() { let blocks: [ChatContentBlock] = [ .text(id: "text_1", text: "I started a background agent for that."), diff --git a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts index fbf4ff4cca4..983144eb83e 100644 --- a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts +++ b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts @@ -2074,7 +2074,7 @@ export const chatFirstToolManifest: OmiToolManifestEntry[] = [ promptSnippet: "get_canonical_goals - Retrieve canonical goals with IDs for native goal links", promptGuidelines: [ "For goal questions, call this before answering and use only returned canonical goals.", - "Render every returned goal the user should act on as a goalLink in the same response.", + "Render a goalLink only for a goal this turn is actually about — the one the user asked for or just changed. Goals you merely read to answer a question are citations.", "If it returns no goals, state that plainly; do not infer goals from memories or local SQL.", ], latency: "fast network", @@ -2093,13 +2093,15 @@ export const chatFirstToolManifest: OmiToolManifestEntry[] = [ { name: "render_chat_blocks", label: "Render Chat Blocks", - description: "Render native, interactive Omi components on the producing main Chat turn. In Chat-first UI, call this in the same turn whenever you retrieve, create, or summarize tasks, goals, memories, or captured conversations; do not leave those entities as a Markdown table/list or ask whether the user wants cards. For taskCard, taskId MUST be the opaque canonical ID returned by get_action_items or create_action_item; never use a local SQLite/execute_sql numeric row ID. If another lookup found task text, call get_action_items before rendering. Supported shapes include {type:'taskCard', taskId:'...'}, {type:'goalLink', goalId:'...', summary:'...'}, {type:'memoryLink', memoryId:'...', summary:'...'}, and {type:'captureLink', conversationId:'...', summary:'...'}.", - promptSnippet: "render_chat_blocks - Render native interactive Omi components in this main Chat response; use by default for entity results", + description: "Render native, interactive Omi components on the producing main Chat turn. Use it when the entity IS the answer — the user asked to see or act on that task, goal, memory or conversation, or this turn created or changed one — so the next thing they do is click it. Do NOT use it for entities you merely read to answer in prose: those are sources, and sources belong in citations. Most turns need no components at all. Render at most three. The components ARE the list: when you render them, the message text must be at most one short lead-in sentence, and must never be a numbered or bulleted list repeating what the components already show. For taskCard, taskId MUST be the opaque canonical ID returned by get_action_items or create_action_item; never use a local SQLite/execute_sql numeric row ID. If another lookup found task text, call get_action_items before rendering. Supported shapes include {type:'taskCard', taskId:'...'}, {type:'goalLink', goalId:'...', summary:'...'}, {type:'memoryLink', memoryId:'...', summary:'...'}, and {type:'captureLink', conversationId:'...', summary:'...'}.", + promptSnippet: "render_chat_blocks - Render a native interactive Omi component when the entity is what the user asked for or acted on; cite sources in prose otherwise", promptGuidelines: [ - "After reading or mutating tasks, goals, memories, or captured conversations, render the relevant native components before finishing the same response.", - "Do not ask whether the user wants cards and do not substitute Markdown tables or lists for entities that have canonical IDs.", + "Render a component when the entity is the point of the turn: the user asked to see or act on it, or this turn created, completed, or changed it.", + "Rendering replaces the writing. \"Here are your three tasks:\" followed by three task cards is right; the same sentence followed by a numbered list of those same three tasks, with or without cards, is the failure this rule exists to stop.", + "Answering a question from what you read is the common case and needs no components. Cite those entities inline instead — a summary of yesterday cites the conversations it drew on, it does not stack cards above itself.", + "Render at most three components in a turn, and prefer none to a wall of them.", + "Do not ask whether the user wants cards, and do not substitute a Markdown table for entities the user asked to act on.", "For task cards, obtain opaque canonical task IDs from get_action_items or create_action_item; execute_sql numeric row IDs are invalid.", - "Use only for a compact actionable question, task, goal, memory, or Omi-device capture reference.", "Never invent entity identifiers or URLs; the server validates every requested reference.", ], latency: "fast network", diff --git a/desktop/macos/agent/tests/omi-tool-manifest.test.ts b/desktop/macos/agent/tests/omi-tool-manifest.test.ts index d27c9d69d36..d20d57b7761 100644 --- a/desktop/macos/agent/tests/omi-tool-manifest.test.ts +++ b/desktop/macos/agent/tests/omi-tool-manifest.test.ts @@ -275,9 +275,15 @@ describe("omi tool manifest", () => { expect(toolNamesForAdapter("pi-mono", { surfaceKind: "main_chat", chatFirstUi: true, controlGeneration: 7, })).toEqual(expect.arrayContaining(["get_canonical_goals", "render_chat_blocks", "search_chat_history", "show_rewind_evidence"])); - expect(enabled.find((tool) => tool.name === "render_chat_blocks")?.description).toContain( - "call this in the same turn whenever you retrieve, create, or summarize tasks", - ); + // The tool is for entities the user asked for or acted on, not for every + // entity a turn happened to read. The old "render whenever you retrieve" + // wording stacked three conversation cards above a summary that had merely + // cited those conversations. + const renderDescription = enabled.find((tool) => tool.name === "render_chat_blocks")?.description ?? ""; + expect(renderDescription).toContain("when the entity IS the answer"); + expect(renderDescription).toContain("sources belong in citations"); + expect(renderDescription).toContain("Render at most three"); + expect(renderDescription).not.toContain("whenever you retrieve"); expect(enabled.find((tool) => tool.name === "render_chat_blocks")?.description).toContain( "never use a local SQLite/execute_sql numeric row ID", ); From 09662d6251d75bfafc2dcd37fd17ac2ac785d3e4 Mon Sep 17 00:00:00 2001 From: David Zhang <david.d.zhang@gmail.com> Date: Thu, 3 Sep 2026 04:17:40 -0400 Subject: [PATCH 24/29] feat(desktop): selection lives in the words, not in a box beside them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Select Text…" opened a popover that re-printed the message in raw Markdown next to the row the reader was already looking at, and only assistant rows had it — a user turn has no hover strip, so their own words were never selectable by any means. SwiftUI's selection stays barred: PR #10834 put it back on settled rows and reopened FC-selection-overlay-layout-loop in Omi Beta 0.12.146, every sampled main-thread stack in `SelectionOverlay` and `setFont` while memory climbed. But that bar is on `SelectionOverlay`, not on selecting. An `NSTextView` *is* one selection: one view owns it, a rebuild replaces a string, and nothing per-`Text` is mounted for a parent to thrash. `ChatSelectableTextPopover` said so in its own header — it just kept that view outside the transcript. So the words are the surface now. Chat prose renders through one text view per block, parsed from exactly what the SwiftUI renderer parses, and the reader drags across an answer in place with ⌘C copying what they highlighted. Citation markers become link ranges rather than buttons, which is what made the surrounding line selectable at all — a chip in a flow layout forced the prose to be chopped into per-segment views — and they still open their source and still preview it on hover. Two things had to be got right. Height is measured beside the live view rather than inside it, because a container left holding a measurement width wrapped the answer to a width the transcript never granted; and the measurement is synchronous, because publishing a height a frame late broke the transcript's follow-scroll. The gesture harness reads pixels, and `cacheDisplay` stopped seeing prose once it was drawn from a layer, so the probe composites the layer tree and can measure the row again. The boundary check now guards this file too: the AppKit path may never quietly acquire the SwiftUI one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../scripts/check_chat_selection_boundary.py | 25 +- .../MainWindow/Components/ChatBubble.swift | 50 +-- .../Components/ChatSelectableProse.swift | 425 ++++++++++++++++++ .../ChatSelectableTextPopover.swift | 116 ----- .../MainWindow/Components/OmiMarkdown.swift | 37 +- .../OmiMarkdownChatTypography.swift | 2 +- .../Tests/ChatRowErgonomicsTests.swift | 95 +++- .../ChatTranscriptGestureHarnessTests.swift | 13 + 8 files changed, 570 insertions(+), 193 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift delete mode 100644 desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableTextPopover.swift diff --git a/.github/scripts/check_chat_selection_boundary.py b/.github/scripts/check_chat_selection_boundary.py index 0b8bce7df55..25c76d96be7 100644 --- a/.github/scripts/check_chat_selection_boundary.py +++ b/.github/scripts/check_chat_selection_boundary.py @@ -9,8 +9,14 @@ SwiftUI has no type-level API that prevents an ancestor or message renderer from installing SelectionOverlay. This deliberately narrow source tripwire -therefore protects the three authoritative live-transcript files. Behavioral -resize coverage remains in ChatTimelineContinuityTests. +therefore protects the authoritative live-transcript files. Behavioral resize +coverage remains in ChatTimelineContinuityTests. + +The bar is on `SelectionOverlay`, not on selecting. The transcript now hosts +selection through `ChatSelectableProse` — one `NSTextView` per prose block, +which *is* its own selection and installs no per-`Text` overlay for a parent +rebuild to thrash. That file is protected here too, so the AppKit path can +never quietly acquire the SwiftUI one. """ from __future__ import annotations @@ -25,13 +31,15 @@ "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift", "desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift", "desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift", + "desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift", ) MARKDOWN_FILE = LIVE_TRANSCRIPT_FILES[2] +SELECTION_FILE = LIVE_TRANSCRIPT_FILES[3] FORBIDDEN_PATTERNS = { ".textSelection(.enabled)": ( - "live chat must not install SwiftUI SelectionOverlay; use the existing copy actions " - "or a separate non-live reading surface" + "live chat must not install SwiftUI SelectionOverlay; selection belongs to " + "ChatSelectableProse, whose NSTextView owns it without one" ), "textSelectionEnabled": ( "OmiMarkdown must not expose a native-selection escape hatch" @@ -59,6 +67,15 @@ def check_sources(sources: Mapping[str, str]) -> list[str]: f"{MARKDOWN_FILE}: OmiMarkdown must explicitly disable inherited native text selection" ) + # The sanctioned remedy has to stay AppKit. An NSTextView owning its own + # selection is the whole reason selection is allowed back into the + # transcript; a SwiftUI Text here would reopen the failure class. + selection_source = sources.get(SELECTION_FILE) + if selection_source is not None and "NSTextView" not in selection_source: + failures.append( + f"{SELECTION_FILE}: transcript selection must be hosted by an NSTextView" + ) + return failures diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift index 55c354c7897..2a1c44262f5 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift @@ -94,7 +94,6 @@ struct ChatBubble: View { @State private var showCopied = false @State private var showRatingFeedback = false @State private var showInfoPopover = false - @State private var showSelectableText = false /// Automation seam: the bridge's `main_chat_open_response_context` posts this /// with a message id so harnesses can open the Response Context popover for a @@ -278,7 +277,6 @@ struct ChatBubble: View { isExpanded = true } .onHover { updateMetadataHover(.row, hovering: $0) } - .overlay(alignment: .bottomLeading) { selectableTextAnchor } // Copy without hunting for the hover strip — and the only copy affordance a // user turn has ever had. .contextMenu { messageContextMenu } @@ -305,25 +303,12 @@ struct ChatBubble: View { @ViewBuilder private var messageContextMenu: some View { if !copyPayload.isEmpty { + // Selecting is done in the words themselves now; this stays for the + // whole message, which a drag would have to be exact to reproduce. Button("Copy Message") { copyMessageToPasteboard() } - // The live transcript can never be selectable (FC-selection-overlay-layout-loop); - // this opens the same words on a surface that is not the transcript. - Button("Select Text\u{2026}") { showSelectableText = true } } } - /// One anchor for the reading surface, shared by the context menu and the - /// hover strip, so the popover is never mounted twice on the same row. - @ViewBuilder - private var selectableTextAnchor: some View { - Color.clear - .frame(width: 1, height: 1) - .accessibilityHidden(true) - .popover(isPresented: $showSelectableText, arrowEdge: .bottom) { - ChatSelectableTextPopover(text: copyPayload) { showSelectableText = false } - } - } - @ViewBuilder private func messageContentView(_ groupedBlocks: [ContentBlockGroup]) -> some View { if message.isStreaming && message.text.isEmpty && message.contentBlocks.isEmpty { @@ -481,7 +466,8 @@ struct ChatBubble: View { text: text, sender: message.sender, citations: citationReferencesForThisSurface, - onOpenCitation: onOpenInlineCitation + onOpenCitation: onOpenInlineCitation, + appKitProseSelection: true ) .chatMessageBlock(filled: presentation.isFilled) } @@ -531,7 +517,8 @@ struct ChatBubble: View { text: text, sender: .ai, citations: citationReferencesForThisSurface, - onOpenCitation: onOpenInlineCitation + onOpenCitation: onOpenInlineCitation, + appKitProseSelection: true ) .chatMessageBlock(filled: false)) case .commentary(_, let text): @@ -600,7 +587,7 @@ struct ChatBubble: View { let isVisible = metadataRevealOverrideForTesting ?? (metadataHoverState.keepsMetadataVisible || isMetadataControlFocused || showRatingFeedback - || showCopied || showInfoPopover || showSelectableText) + || showCopied || showInfoPopover) // **One cluster under the message.** Controls far left and timestamp far right // of one line is how two halves of a row end up reading as page furniture. HStack(alignment: .center, spacing: OmiSpacing.sm) { @@ -610,9 +597,6 @@ struct ChatBubble: View { if includeCopyButton { copyButton } - if includeCopyButton { - selectTextButton - } if includeCopyButton, message.metadata != nil { infoButton } @@ -731,26 +715,6 @@ struct ChatBubble: View { .help("Copy message") } - /// Opens the message on `ChatSelectableTextPopover`. The transcript itself - /// stays selection-free; this is the "separate non-live reading surface" the - /// selection boundary names as the remedy. - @ViewBuilder - private var selectTextButton: some View { - Button(action: { showSelectableText = true }) { - Image(systemName: "character.cursor.ibeam") - .scaledFont(size: OmiType.caption) - .foregroundColor(showSelectableText ? Ink.primary : Ink.secondary) - .frame( - width: ChatBubbleMetadataControlMetrics.targetSize, - height: ChatBubbleMetadataControlMetrics.targetSize - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .focused($isMetadataControlFocused) - .help("Select text") - } - /// Response Context popover — observed turn evidence (tools, screenshot, /// admitted kernel sources). Only fresh responses carry metadata; it is /// in-memory only and not persisted across restarts. diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift new file mode 100644 index 00000000000..28b94c3a352 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift @@ -0,0 +1,425 @@ +import AppKit +import OmiTheme +import SwiftUI + +/// **Selection where the words already are.** +/// +/// The transcript used to answer "let me copy that date out of your answer" +/// with a popover: `ChatSelectableTextPopover` re-printed the message, in raw +/// Markdown, in a floating box beside the row the reader was already looking +/// at. It was the only remedy available, because SwiftUI's own selection is +/// permanently barred here — PR #10834 put SwiftUI's own native selection back +/// on settled rows and reopened FC-selection-overlay-layout-loop in Omi Beta +/// 0.12.146, with every sampled main-thread stack in `SelectionOverlay`, +/// `setFont` and AttributeGraph while memory climbed without bound. +/// +/// That boundary is about `SelectionOverlay`, not about selection. An +/// `NSTextView` *is* its own selection: one view owns one selection, a parent +/// rebuild replaces a string instead of installing a second overlay, and +/// nothing per-`Text` is mounted at all. `ChatSelectableTextPopover` already +/// said so in its own header — it just kept that view outside the transcript. +/// This brings it inside, so a reader drags across the answer in place, on +/// their own turns as much as Omi's, and `⌘C` copies exactly what they +/// highlighted. +/// +/// `.github/scripts/check_chat_selection_boundary.py` still forbids SwiftUI +/// selection in the live transcript, and now also forbids it here. +enum ChatSelectableProse { + /// The scheme the transcript's own citation markers travel under. It is not + /// openable by the system: the click is handled in-process and the URL never + /// reaches `NSWorkspace`. + static let citationScheme = "omi-citation" + + static func citationOrdinal(from url: URL) -> Int? { + guard url.scheme == citationScheme else { return nil } + return Int(url.host ?? url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) + } + + /// The one place chat prose becomes AppKit text. + /// + /// It parses exactly what `OmiMarkdownContent.styledAttributedString` parses — + /// same preprocessing, same tilde rule, same inline-only syntax — and then + /// maps the parsed *intents* rather than SwiftUI's own attributes, which do + /// not bridge. Anything this cannot represent (a table, a fenced block) never + /// reaches here; those keep their SwiftUI renderers and their copy controls. + static func attributedString( + markdown source: String, + style: OmiMarkdown.Style, + fontSize: CGFloat, + fontScale: CGFloat, + citationOrdinals: Set<Int> = [] + ) -> NSAttributedString? { + let processed = OmiMarkdownContent.preprocessText(source) + let escaped = OmiMarkdownTilde.escapingNonPairDelimiters(processed) + guard + let parsed = try? AttributedString( + markdown: escaped, + options: .init( + allowsExtendedAttributes: true, + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + ) + else { return nil } + + let codeFontSize = round(13 * fontScale) + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = OmiMarkdownContent.chatLineSpacing(fontSize: fontSize) + let result = NSMutableAttributedString() + + for run in parsed.runs { + let text = String(parsed[run.range].characters) + guard !text.isEmpty else { continue } + let intent = run.inlinePresentationIntent ?? [] + let isCode = intent.contains(.code) + var attributes: [NSAttributedString.Key: Any] = [ + .font: font( + size: isCode ? codeFontSize : fontSize, + bold: intent.contains(.stronglyEmphasized), + italic: intent.contains(.emphasized), + code: isCode + ), + .foregroundColor: NSColor.labelColor, + .paragraphStyle: paragraph, + ] + if isCode { + // The same chip wash the SwiftUI renderer paints, flattened: a run + // background cannot round its corners, and a rounded corner is not + // worth an attachment that would drop out of the copied text. + attributes[.backgroundColor] = NSColor.labelColor.withAlphaComponent(0.085) + } + if let link = run.link { + attributes[.link] = link + attributes[.foregroundColor] = NSColor.systemBlue + if style == .user { attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue } + } + result.append(NSAttributedString(string: text, attributes: attributes)) + } + + applyCitationLinks(to: result, ordinals: citationOrdinals) + return result + } + + /// `[7]` is a marker the transcript owns, not Markdown. Markdown leaves it as + /// literal text (there is no link destination after it), so it is still here + /// to find, and turning it into a link keeps it clickable *and* selectable — + /// the chip button it replaces was neither. + static func applyCitationLinks(to text: NSMutableAttributedString, ordinals: Set<Int>) { + guard !ordinals.isEmpty else { return } + guard let pattern = try? NSRegularExpression(pattern: #"\[(\d{1,3})\]"#) else { return } + let full = NSRange(location: 0, length: text.length) + for match in pattern.matches(in: text.string, range: full).reversed() { + guard match.numberOfRanges == 2, + let digits = Range(match.range(at: 1), in: text.string), + let ordinal = Int(text.string[digits]), + ordinals.contains(ordinal), + let url = URL(string: "\(citationScheme)://\(ordinal)") + else { continue } + text.addAttributes( + [.link: url, .foregroundColor: NSColor.systemBlue], range: match.range) + } + } + + private static func font(size: CGFloat, bold: Bool, italic: Bool, code: Bool) -> NSFont { + if code { return .monospacedSystemFont(ofSize: size, weight: bold ? .semibold : .regular) } + let base = bold ? NSFont.boldSystemFont(ofSize: size) : NSFont.systemFont(ofSize: size) + guard italic else { return base } + let italicized = NSFontManager.shared.convert(base, toHaveTrait: .italicFontMask) + return italicized + } +} + +/// One `NSTextView`, laid out by SwiftUI, drawing one run of chat prose. +/// +/// Deliberately *not* `NSTextView.scrollableTextView()`: an inner scroller +/// would swallow the transcript's own trackpad gestures the way a fenced code +/// block does. This view has no scroller, reports the height its text needs at +/// the proposed width, and lets the transcript do the scrolling. +struct ChatSelectableProseText: NSViewRepresentable { + let attributed: NSAttributedString + var onOpenCitation: ((Int) -> Void)? + /// Reports the citation under the pointer and the rectangle its marker + /// occupies, so the transcript can anchor the same source preview the chip + /// used to open. `nil` means the pointer left every marker. + var onHoverCitation: ((CitationHover?) -> Void)? + + struct CitationHover: Equatable { + let ordinal: Int + let rect: CGRect + } + + func makeCoordinator() -> Coordinator { + Coordinator(onOpenCitation: onOpenCitation, onHoverCitation: onHoverCitation) + } + + func makeNSView(context: Context) -> NSTextView { + let textView = ChatProseTextView() + textView.isEditable = false + textView.isSelectable = true + textView.drawsBackground = false + textView.backgroundColor = .clear + textView.isRichText = false + textView.textContainerInset = .zero + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.isVerticallyResizable = false + textView.isHorizontallyResizable = false + textView.linkTextAttributes = [ + .foregroundColor: NSColor.systemBlue, + .cursor: NSCursor.pointingHand, + ] + textView.delegate = context.coordinator + textView.onHoverCitation = { [weak coordinator = context.coordinator] hover in + coordinator?.onHoverCitation?(hover) + } + textView.textStorage?.setAttributedString(attributed) + return textView + } + + func updateNSView(_ textView: NSTextView, context: Context) { + context.coordinator.onOpenCitation = onOpenCitation + context.coordinator.onHoverCitation = onHoverCitation + guard textView.textStorage?.isEqual(to: attributed) != true else { return } + // Replacing the storage of the one view that owns this selection. There is + // no second overlay to install, which is why this is AppKit. + textView.textStorage?.setAttributedString(attributed) + } + + /// Height for the width the transcript proposed, measured **beside** the + /// live view rather than inside it. + /// + /// Measuring in the view's own text container is what broke the column: the + /// container was left holding a measurement width, the frame later arrived at + /// a different one, and the answer wrapped to neither. A throwaway layout + /// manager answers the question without touching what is on screen, and the + /// live container simply tracks the frame it is finally given. + func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSTextView, context: Context) -> CGSize? { + guard let width = proposal.width, width > 0, width < .greatestFiniteMagnitude else { return nil } + return CGSize(width: width, height: Self.height(of: attributed, fittingWidth: width)) + } + + /// Exposed so a test can assert the row's height without mounting a window. + static func height(of attributed: NSAttributedString, fittingWidth width: CGFloat) -> CGFloat { + let storage = NSTextStorage(attributedString: attributed) + let container = NSTextContainer(size: NSSize(width: width, height: .greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + let layoutManager = NSLayoutManager() + layoutManager.addTextContainer(container) + storage.addLayoutManager(layoutManager) + layoutManager.ensureLayout(for: container) + return ceil(layoutManager.usedRect(for: container).height) + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var onOpenCitation: ((Int) -> Void)? + var onHoverCitation: ((CitationHover?) -> Void)? + + init( + onOpenCitation: ((Int) -> Void)?, + onHoverCitation: ((CitationHover?) -> Void)? + ) { + self.onOpenCitation = onOpenCitation + self.onHoverCitation = onHoverCitation + } + + func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { + guard let url = link as? URL ?? (link as? String).flatMap(URL.init(string:)) else { return false } + if let ordinal = ChatSelectableProse.citationOrdinal(from: url) { + onOpenCitation?(ordinal) + return true + } + // Everything else is an ordinary Markdown link and belongs to the browser. + return false + } + } +} + +/// A text view that reads as prose rather than as a control. +/// +/// Two AppKit defaults are wrong for a transcript: the field editor's I-beam +/// tracking rectangle is fine, but the view would otherwise accept first +/// responder from a `Tab` walk and steal the composer's focus ring, and a +/// right-click would open AppKit's editing menu instead of the row's own +/// "Copy Message" menu. +final class ChatProseTextView: NSTextView { + var onHoverCitation: ((ChatSelectableProseText.CitationHover?) -> Void)? + private var hoveredOrdinal: Int? + + override var acceptsFirstResponder: Bool { true } + + /// The column belongs to the transcript. Claiming an intrinsic width here is + /// what let a short line pull the whole row in from the container edge. + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric) + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.filter { $0.owner === self }.forEach(removeTrackingArea) + addTrackingArea( + NSTrackingArea( + rect: bounds, + options: [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self)) + } + + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + publishHover(at: convert(event.locationInWindow, from: nil)) + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + publishHover(at: nil) + } + + /// Point to marker. A miss is as meaningful as a hit — it is what dismisses + /// a preview the reader has moved away from. + private func publishHover(at point: CGPoint?) { + guard let point, let layoutManager, let textContainer else { + publish(nil) + return + } + let glyph = layoutManager.glyphIndex(for: point, in: textContainer) + let bounds = layoutManager.boundingRect( + forGlyphRange: NSRange(location: glyph, length: 1), in: textContainer) + guard bounds.contains(point) else { + publish(nil) + return + } + let index = layoutManager.characterIndexForGlyph(at: glyph) + guard index < (textStorage?.length ?? 0) else { + publish(nil) + return + } + var range = NSRange(location: 0, length: 0) + guard let url = textStorage?.attribute(.link, at: index, effectiveRange: &range) as? URL, + let ordinal = ChatSelectableProse.citationOrdinal(from: url) + else { + publish(nil) + return + } + let rect = layoutManager.boundingRect(forGlyphRange: range, in: textContainer) + guard hoveredOrdinal != ordinal else { return } + hoveredOrdinal = ordinal + onHoverCitation?(.init(ordinal: ordinal, rect: rect)) + } + + private func publish(_ hover: ChatSelectableProseText.CitationHover?) { + guard hoveredOrdinal != nil else { return } + hoveredOrdinal = nil + onHoverCitation?(hover) + } + + /// Focus arrives by clicking into the words, never by tabbing through them. + override func becomeFirstResponder() -> Bool { + guard NSApp.currentEvent?.type != .keyDown else { return false } + return super.becomeFirstResponder() + } + + override func menu(for event: NSEvent) -> NSMenu? { + // Nothing selected — let the row's context menu answer, so "Copy Message" + // stays one right-click away from anywhere in the bubble. + guard selectedRange().length > 0 else { return nil } + return super.menu(for: event) + } +} + +/// One run of chat prose, selectable, with the citation preview the chip used +/// to own. +/// +/// The chip was a `Button` inside a flow layout, which is precisely what made +/// the surrounding words unselectable: a line of prose had to be chopped into +/// per-segment `Text` views to make room for it. Here the marker is a link +/// range inside the one text view, so the same `[7]` is draggable, copyable, +/// clickable *and* still opens its source on hover. +struct ChatSelectableProseBlock: View { + let text: String + let style: OmiMarkdown.Style + let fontScale: CGFloat + let citations: [ChatCitationReference] + let onOpenCitation: ((ChatCitationReference) -> Void)? + + @State private var hover: ChatSelectableProseText.CitationHover? + @State private var isPreviewHovering = false + @State private var hoverGeneration = 0 + + private var referencesByOrdinal: [Int: ChatCitationReference] { + Dictionary(citations.map { ($0.ordinal, $0) }, uniquingKeysWith: { first, _ in first }) + } + + private var hoveredReference: ChatCitationReference? { + hover.flatMap { referencesByOrdinal[$0.ordinal] } + } + + var body: some View { + let fontSize = round(14 * fontScale) + if let attributed = ChatSelectableProse.attributedString( + markdown: text, + style: style, + fontSize: fontSize, + fontScale: fontScale, + citationOrdinals: Set(referencesByOrdinal.keys)) + { + ChatSelectableProseText( + attributed: attributed, + onOpenCitation: { ordinal in + guard let reference = referencesByOrdinal[ordinal], reference.canOpen else { return } + hover = nil + onOpenCitation?(reference) + }, + onHoverCitation: { value in + hoverGeneration += 1 + let generation = hoverGeneration + if value == nil { + schedulePreviewDismiss(generation: generation) + } else { + hover = value + } + } + ) + // Without this the row asks the text for its ideal width and gets the + // text's own, not the column's: assistant prose stopped 200pt short of + // the container edge and re-wrapped inside a gutter nobody reserved. + .frame(maxWidth: .infinity, alignment: .leading) + .popover( + // A real binding, not a constant: SwiftUI writes `false` back when the + // reader dismisses the preview, and a constant would swallow that and + // leave a popover that cannot be closed. + isPresented: Binding( + get: { hoveredReference != nil }, + set: { if !$0 { hover = nil } } + ), + attachmentAnchor: .rect(.rect(hover?.rect ?? .zero)), + arrowEdge: .bottom + ) { + if let reference = hoveredReference { + ChatCitationPreview( + reference: reference, + fontScale: fontScale, + onOpen: { + hover = nil + onOpenCitation?(reference) + } + ) + .onHover { hovering in + isPreviewHovering = hovering + hoverGeneration += 1 + if !hovering { schedulePreviewDismiss(generation: hoverGeneration) } + } + } + } + } else { + // The parse failed, which is not a reason to withhold the words. + OmiMarkdownChatText(text, fontSize: fontSize, style: style) + } + } + + /// The pointer crosses the gap between marker and popover; dismissing on the + /// first miss would make the preview unreachable. + private func schedulePreviewDismiss(generation: Int) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + guard !isPreviewHovering, generation == hoverGeneration else { return } + hover = nil + } + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableTextPopover.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableTextPopover.swift deleted file mode 100644 index a35dbd4c089..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableTextPopover.swift +++ /dev/null @@ -1,116 +0,0 @@ -import AppKit -import OmiTheme -import SwiftUI - -/// **The separate, non-live reading surface for one chat message.** -/// -/// The transcript itself can never host native text selection: PR #10834 put -/// `.textSelection(.enabled)` back on settled rows and reopened -/// FC-selection-overlay-layout-loop in Omi Beta 0.12.146 — `SelectionOverlay` -/// pinned the main thread through `setFont`/intrinsic-size/AttributeGraph while -/// memory grew without bound. `.github/scripts/check_chat_selection_boundary.py` -/// keeps that door shut. -/// -/// So the reader gets the other half of the remedy instead. "Select Text" opens -/// this popover, which is one `NSTextView` over one message, outside the -/// transcript's layout, mounted only when asked for and torn down on close. It -/// cannot participate in transcript loading, scrolling or resize, which is what -/// made selection unsafe in the first place. Nothing here uses SwiftUI's -/// `textSelection` — AppKit's own selection is what an `NSTextView` already is. -struct ChatSelectableTextPopover: View { - let text: String - let onClose: () -> Void - - /// Wide enough for a normal answer line without rewrapping it into a column, - /// capped so a long reply scrolls inside the popover rather than growing one - /// taller than the window. - private static let width: CGFloat = 420 - private static let maxTextHeight: CGFloat = 360 - - var body: some View { - VStack(alignment: .leading, spacing: OmiSpacing.sm) { - HStack(spacing: OmiSpacing.sm) { - Text("Select text") - .scaledFont(size: OmiType.caption, weight: .semibold) - .foregroundColor(Ink.primary) - Spacer(minLength: 0) - Text("⌘A · ⌘C") - .scaledFont(size: OmiType.micro) - .foregroundColor(Ink.secondary) - } - - OmiSelectableTextView(text: text, maxHeight: Self.maxTextHeight) - .frame(width: Self.width) - .frame(maxHeight: Self.maxTextHeight) - } - .padding(OmiSpacing.md) - // Escape. `.popover` is transient, but a click never has to happen for the - // reader to be done reading. - .onExitCommand(perform: onClose) - .accessibilityLabel("Selectable message text") - } -} - -/// Read-only, selectable AppKit text. Deliberately **not** a SwiftUI `Text`: -/// one `NSTextView` owns its own selection, so there is no per-`Text` -/// `SelectionOverlay` to install and nothing for a parent rebuild to thrash. -struct OmiSelectableTextView: NSViewRepresentable { - let text: String - let maxHeight: CGFloat - - func makeNSView(context: Context) -> NSScrollView { Self.makeScrollView(text: text) } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - Self.apply(text: text, to: scrollView) - } - - /// The AppKit configuration, reachable without an `NSViewRepresentableContext` - /// so a test can assert what this surface actually is. - static func makeScrollView(text: String) -> NSScrollView { - let scrollView = NSTextView.scrollableTextView() - scrollView.drawsBackground = false - scrollView.hasVerticalScroller = true - scrollView.autohidesScrollers = true - scrollView.borderType = .noBorder - - guard let textView = scrollView.documentView as? NSTextView else { return scrollView } - textView.isEditable = false - textView.isSelectable = true - textView.isRichText = false - textView.drawsBackground = false - textView.textContainerInset = NSSize(width: 0, height: 0) - textView.font = .systemFont(ofSize: 13) - textView.textColor = .labelColor - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticSpellingCorrectionEnabled = false - textView.string = text - - // The reader asked for this surface in order to select; give them the - // caret without a first click. - DispatchQueue.main.async { - textView.window?.makeFirstResponder(textView) - } - return scrollView - } - - /// A rebuild replaces a string on the one text view; it never installs a - /// second selection overlay. - static func apply(text: String, to scrollView: NSScrollView) { - guard let textView = scrollView.documentView as? NSTextView else { return } - if textView.string != text { textView.string = text } - } - - /// Hug short messages; scroll long ones instead of growing past the cap. - func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSScrollView, context: Context) -> CGSize? { - guard let textView = nsView.documentView as? NSTextView, - let container = textView.textContainer, - let layoutManager = textView.layoutManager - else { return nil } - - let width = proposal.width ?? container.size.width - container.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) - layoutManager.ensureLayout(for: container) - let used = layoutManager.usedRect(for: container).height - return CGSize(width: width, height: min(max(used, 20), maxHeight)) - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift index e6d9f4ab47f..24dad025180 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift @@ -37,19 +37,26 @@ struct OmiMarkdown: View { let style: Style let citations: [ChatCitationReference] let onOpenCitation: ((ChatCitationReference) -> Void)? + /// Draw prose through `ChatSelectableProse` (one `NSTextView`) instead of + /// SwiftUI `Text`, so the reader can drag across it. This is **not** the + /// banned SwiftUI selection: no `SelectionOverlay` is installed anywhere on + /// this path, which is the whole distinction the boundary is drawing. + let appKitProseSelection: Bool @Environment(\.fontScale) private var fontScale init( text: String, sender: ChatSender, citations: [ChatCitationReference] = [], - onOpenCitation: ((ChatCitationReference) -> Void)? = nil + onOpenCitation: ((ChatCitationReference) -> Void)? = nil, + appKitProseSelection: Bool = false ) { let style: Style = sender == .user ? .user : .assistant self.text = Self.renderableText(text, style: style) self.style = style self.citations = citations self.onOpenCitation = onOpenCitation + self.appKitProseSelection = appKitProseSelection } init(text: String, style: Style) { @@ -57,6 +64,7 @@ struct OmiMarkdown: View { self.style = style self.citations = [] self.onOpenCitation = nil + self.appKitProseSelection = false } /// Assistant text may open with an Interject classification token; it is @@ -68,7 +76,7 @@ struct OmiMarkdown: View { var body: some View { Group { - if citations.isEmpty { + if citations.isEmpty && !appKitProseSelection { OmiMarkdownContent(text: text, style: style, fontScale: fontScale) .equatable() } else { @@ -77,7 +85,8 @@ struct OmiMarkdown: View { style: style, fontScale: fontScale, citations: citations, - onOpenCitation: onOpenCitation) + onOpenCitation: onOpenCitation, + appKitProseSelection: appKitProseSelection) } } .textSelection(.disabled) @@ -102,13 +111,15 @@ struct OmiMarkdownContent: View, Equatable { let document: OmiMarkdownDocument let citations: [ChatCitationReference] let onOpenCitation: ((ChatCitationReference) -> Void)? + let appKitProseSelection: Bool init( text: String, style: OmiMarkdown.Style, fontScale: CGFloat, citations: [ChatCitationReference] = [], - onOpenCitation: ((ChatCitationReference) -> Void)? = nil + onOpenCitation: ((ChatCitationReference) -> Void)? = nil, + appKitProseSelection: Bool = false ) { self.text = text self.style = style @@ -116,11 +127,12 @@ struct OmiMarkdownContent: View, Equatable { self.document = OmiMarkdownDocument(markdown: text) self.citations = citations self.onOpenCitation = onOpenCitation + self.appKitProseSelection = appKitProseSelection } nonisolated static func == (lhs: Self, rhs: Self) -> Bool { lhs.text == rhs.text && lhs.style == rhs.style && lhs.fontScale == rhs.fontScale - && lhs.citations == rhs.citations + && lhs.citations == rhs.citations && lhs.appKitProseSelection == rhs.appKitProseSelection } var body: some View { @@ -174,7 +186,16 @@ struct OmiMarkdownContent: View, Equatable { ) Group { - if !citations.isEmpty { + if appKitProseSelection { + // One text view per prose block: selection spans the whole block, and + // the block is the whole message for all but tables and fenced code. + ChatSelectableProseBlock( + text: content, + style: style, + fontScale: fontScale, + citations: citations, + onOpenCitation: onOpenCitation) + } else if !citations.isEmpty { OmiMarkdownCitationContent( text: content, style: style, @@ -326,7 +347,7 @@ struct OmiMarkdownContent: View, Equatable { /// Converts block-level elements (headers, asterisk lists) into inline-compatible /// form for `AttributedString(markdown:)` with `.inlineOnlyPreservingWhitespace`. - static func preprocessText(_ text: String) -> String { + nonisolated static func preprocessText(_ text: String) -> String { text.components(separatedBy: "\n").map { line in var processed = line @@ -1241,7 +1262,7 @@ private struct ChatCitationToken: View { } } -private struct ChatCitationPreview: View { +struct ChatCitationPreview: View { let reference: ChatCitationReference let fontScale: CGFloat let onOpen: () -> Void diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdownChatTypography.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdownChatTypography.swift index cf358509fd8..605cb9bd968 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdownChatTypography.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdownChatTypography.swift @@ -6,7 +6,7 @@ extension OmiMarkdownContent { /// Tracks the chat font-size setting (`fontSize` already includes `fontScale`). /// It does not grow with window size — line leading that followed the panel /// would loosen on a large display and tighten on a small one. - static func chatLineSpacing(fontSize: CGFloat) -> CGFloat { + nonisolated static func chatLineSpacing(fontSize: CGFloat) -> CGFloat { round(5 * fontSize / 14) } } diff --git a/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift b/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift index 5526271a2e8..2101d0fb4ea 100644 --- a/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift +++ b/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift @@ -4,37 +4,90 @@ import XCTest @testable import Omi_Computer -/// The transcript can never host native selection (FC-selection-overlay-layout-loop: -/// PR #10834 reopened it in Omi Beta 0.12.146). The remedy the boundary names is a -/// separate non-live reading surface, and this is it — one AppKit text view over -/// one message, mounted only when the reader asks for it. +/// **Selection lives in the transcript now.** +/// +/// It used to live in a popover beside the row, because SwiftUI's own selection +/// is barred here for good (FC-selection-overlay-layout-loop: PR #10834 +/// reopened it in Omi Beta 0.12.146). The bar is on `SelectionOverlay`, not on +/// selecting — an `NSTextView` *is* one selection, with no per-`Text` overlay to +/// install — so the words themselves are the surface now, on the reader's own +/// turns as much as Omi's. @MainActor -final class ChatSelectableTextSurfaceTests: XCTestCase { - private func textView(for text: String) throws -> NSTextView { - let scrollView = OmiSelectableTextView.makeScrollView(text: text) - return try XCTUnwrap(scrollView.documentView as? NSTextView) +final class ChatSelectableProseTests: XCTestCase { + private func attributed( + _ markdown: String, + style: OmiMarkdown.Style = .assistant, + citations: Set<Int> = [] + ) throws -> NSAttributedString { + try XCTUnwrap( + ChatSelectableProse.attributedString( + markdown: markdown, style: style, fontSize: 14, fontScale: 1, citationOrdinals: citations)) } - func testTheReadingSurfaceIsSelectableButNotEditable() throws { - let view = try textView(for: "They arrive on Saturday.") - XCTAssertTrue(view.isSelectable, "selecting is the entire point of this surface") + private func attribute( + _ key: NSAttributedString.Key, of text: NSAttributedString, at substring: String + ) throws -> Any? { + let range = try XCTUnwrap( + text.string.range(of: substring), "\(substring) is not in \(text.string)") + return text.attribute( + key, at: text.string.distance(from: text.string.startIndex, to: range.lowerBound), effectiveRange: nil) + } + + func testTheProseViewIsSelectableAndNotEditable() { + let view = ChatProseTextView() + view.isEditable = false + view.isSelectable = true + XCTAssertTrue(view.isSelectable, "selecting the answer is the entire point") XCTAssertFalse(view.isEditable, "a transcript row is not a document the reader may rewrite") } - func testTheReadingSurfaceCarriesTheMessageItWasOpenedFor() throws { - XCTAssertEqual(try textView(for: "Booking confirmed.").string, "Booking confirmed.") + /// Both senders. A user turn was never selectable by any means — the popover + /// was reachable from the hover strip, and a user row has no hover strip. + func testBothSendersRenderSelectableProse() throws { + for style in [OmiMarkdown.Style.assistant, .user] { + let text = try attributed("Booking confirmed.", style: style) + XCTAssertEqual(text.string, "Booking confirmed.") + } } - /// It is one AppKit view, so a rebuild replaces a string rather than - /// installing another selection overlay. - func testUpdatingTheSurfaceReplacesTheTextInPlace() throws { - let scrollView = OmiSelectableTextView.makeScrollView(text: "first") - let first = try XCTUnwrap(scrollView.documentView as? NSTextView) + func testEmphasisSurvivesTheCrossingIntoAppKit() throws { + let text = try attributed("Do **YC application** with *Nick* and run `agentctl`.") + XCTAssertEqual(text.string, "Do YC application with Nick and run agentctl.") + + let bold = try XCTUnwrap(try attribute(.font, of: text, at: "YC application") as? NSFont) + XCTAssertTrue( + bold.fontDescriptor.symbolicTraits.contains(.bold), "bold must not flatten into body text") - OmiSelectableTextView.apply(text: "second", to: scrollView) + let italic = try XCTUnwrap(try attribute(.font, of: text, at: "Nick") as? NSFont) + XCTAssertTrue(italic.fontDescriptor.symbolicTraits.contains(.italic)) + + let code = try XCTUnwrap(try attribute(.font, of: text, at: "agentctl") as? NSFont) + XCTAssertTrue( + code.fontDescriptor.symbolicTraits.contains(.monoSpace), + "inline code keeps its monospace face now that it is text rather than a button") + XCTAssertNotNil( + try attribute(.backgroundColor, of: text, at: "agentctl"), "and keeps its chip wash") + } + + /// The marker stays inside the one text view, so it is draggable and + /// copyable — which the chip button never was — and still opens its source. + func testAKnownCitationMarkerBecomesAnOpenableLink() throws { + let text = try attributed("You favoured the clearer concept. [1]", citations: [1]) + let link = try XCTUnwrap(try attribute(.link, of: text, at: "[1]") as? URL) + XCTAssertEqual(ChatSelectableProse.citationOrdinal(from: link), 1) + } + + /// A bracketed number the turn has no source for is prose, not a control. + func testAnUnknownBracketedNumberIsLeftAsWords() throws { + let text = try attributed("Section [4] of the lease.", citations: [1]) + XCTAssertNil(try attribute(.link, of: text, at: "[4]")) + } - XCTAssertIdentical(scrollView.documentView as? NSTextView, first) - XCTAssertEqual(first.string, "second") + func testProseKeepsTheTranscriptsOwnLeading() throws { + let text = try attributed("One line.") + let paragraph = try XCTUnwrap( + try attribute(.paragraphStyle, of: text, at: "One") as? NSParagraphStyle) + XCTAssertEqual(paragraph.lineSpacing, OmiMarkdownContent.chatLineSpacing(fontSize: 14)) } } diff --git a/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift b/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift index b41ad709c7a..9b181124647 100644 --- a/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift +++ b/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift @@ -573,6 +573,19 @@ final class ChatTranscriptGestureHarnessTests: XCTestCase { let representation = clipView.bitmapImageRepForCachingDisplay(in: bounds) else { return nil } clipView.cacheDisplay(in: bounds, to: representation) + // `cacheDisplay` walks `draw(_:)`, which no longer sees everything: once + // the transcript hosts an AppKit text view its prose is drawn from a + // backing layer, and a bitmap taken this way shows the SwiftUI chrome + // without the words. Compositing the layer tree on top puts the text back + // in the picture, so the probe measures the row rather than half of it. + if let layer = clipView.layer, + let context = NSGraphicsContext(bitmapImageRep: representation) + { + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + layer.render(in: context.cgContext) + NSGraphicsContext.restoreGraphicsState() + } guard let image = representation.cgImage else { return nil } let width = image.width From 7f77484f3563f3039bd76cf4e92a5283333e5c3b Mon Sep 17 00:00:00 2001 From: David Zhang <david.d.zhang@gmail.com> Date: Thu, 3 Sep 2026 04:23:16 -0400 Subject: [PATCH 25/29] fix(chat): a rendered card is the citation, and it survives the next update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two leaks left over from making components reachable. The first: terminalization was not the only replace. The streaming projection pushes the surface's own block list several times a turn, and `updateJournalTurn` took the replacement literally — so cards that survived the terminal commit died to the very next update instead, which is why they appeared on one turn and not the next. Both paths now apply a projection over the journal rather than in place of it: an id the projection carries is the projection's to define, which is how a question card's options still get retired, and an id it omits survives only when the kernel wrote it. The second is what the reader saw. When the model renders components and skips inline markers, we append a compact `Sources: [1][2][3]` rail so provenance stays discoverable. That was a sensible garnish when components were rare; now that a component turn is one lead-in line and three cards, the rail is a row of bare markers printed under the very things they point at. Sources the turn already draws are dropped from it, and anything with no component of its own keeps its marker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../Desktop/Sources/Chat/ChatCitation.swift | 34 ++++++++++- .../Desktop/Tests/ChatCitationTests.swift | 39 +++++++++++++ .../agent/src/runtime/conversation-journal.ts | 58 ++++++++++++++----- .../agent/tests/conversation-journal.test.ts | 33 +++++++++++ 4 files changed, 145 insertions(+), 19 deletions(-) diff --git a/desktop/macos/Desktop/Sources/Chat/ChatCitation.swift b/desktop/macos/Desktop/Sources/Chat/ChatCitation.swift index 5e2dd39eeb7..439f79a65ca 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatCitation.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatCitation.swift @@ -443,13 +443,22 @@ enum ChatCitationMarkup { /// Rich blocks are an authoritative selection made by the model. If it omits inline markers /// after rendering those blocks, retain source discoverability as one compact inline fallback. + /// + /// `renderedEntityIDs` are the entities the turn already draws as their own + /// components. A rendered task card is a better citation of that task than + /// `[3]` is — it opens the same thing and says what it is — so a rail that + /// only repeats those ids is noise printed under the cards, and now that + /// components are a turn's whole answer rather than a garnish, it is noise on + /// every such turn. static func appendingSelectedSources( to text: String, selectedReferences: [ChatCitationReference], requestedSources: Bool = false, - retrievedReferences: [ChatCitationReference] = [] + retrievedReferences: [ChatCitationReference] = [], + renderedEntityIDs: Set<String> = [] ) -> String { - let fallback = selectedReferences.isEmpty && requestedSources ? retrievedReferences : selectedReferences + let selection = selectedReferences.isEmpty && requestedSources ? retrievedReferences : selectedReferences + let fallback = selection.filter { !renderedEntityIDs.contains($0.sourceID) } guard !fallback.isEmpty else { return text } let fallbackOrdinals = Set(fallback.map(\.ordinal)) let hasResolvedNumericCitation = ordinals(in: text).contains { fallbackOrdinals.contains($0) } @@ -460,6 +469,23 @@ enum ChatCitationMarkup { return text + "\n\nSources: \(markers)" } + /// The entities this turn already draws as components, by the id a citation + /// would carry for the same thing. + static func renderedEntityIDs(in blocks: [ChatContentBlock]) -> Set<String> { + var identifiers = Set<String>() + for block in blocks { + switch block { + case .taskCard(_, let taskId): identifiers.insert(taskId) + case .goalLink(_, let goalId, _): identifiers.insert(goalId) + case .captureLink(_, let conversationId, _, _): identifiers.insert(conversationId) + case .conversationLink(_, let conversationId, _, _): identifiers.insert(conversationId) + case .memoryLink(_, let memoryId, _): identifiers.insert(memoryId) + default: continue + } + } + return identifiers + } + private static func webReferences(in text: String) -> [ChatCitationReference] { guard let expression = try? NSRegularExpression( @@ -601,12 +627,14 @@ extension ChatMessage { retrievedReferences: [ChatCitationReference], fallbackText: String = "" ) { + let rendered = ChatCitationMarkup.renderedEntityIDs(in: contentBlocks) func apply(_ value: String) -> String { ChatCitationMarkup.appendingSelectedSources( to: value, selectedReferences: selectedReferences, requestedSources: requestedSources, - retrievedReferences: retrievedReferences) + retrievedReferences: retrievedReferences, + renderedEntityIDs: rendered) } if text.isEmpty { text = fallbackText diff --git a/desktop/macos/Desktop/Tests/ChatCitationTests.swift b/desktop/macos/Desktop/Tests/ChatCitationTests.swift index 9f53b44d03c..acb62276652 100644 --- a/desktop/macos/Desktop/Tests/ChatCitationTests.swift +++ b/desktop/macos/Desktop/Tests/ChatCitationTests.swift @@ -849,3 +849,42 @@ final class ChatCitationTests: XCTestCase { XCTAssertEqual(consumed.references.map(\.sourceID), ["conversation-20"]) } } + +/// A rendered component is already the citation of the thing it draws. +final class ChatCitationRenderedEntityTests: XCTestCase { + private let task = ChatCitationReference( + ordinal: 1, kind: .task, sourceID: "task-1", title: "Do YC application") + private let memory = ChatCitationReference( + ordinal: 2, kind: .memory, sourceID: "memory-9", title: "Prefers mornings") + + func testTheSourceRailDropsEntitiesTheTurnAlreadyDraws() { + XCTAssertEqual( + ChatCitationMarkup.appendingSelectedSources( + to: "Here are your tasks.", + selectedReferences: [task], + renderedEntityIDs: ["task-1"]), + "Here are your tasks.", + "a task card opens the same task the marker would, and says what it is") + } + + func testTheSourceRailStillCarriesWhatNothingDraws() { + XCTAssertEqual( + ChatCitationMarkup.appendingSelectedSources( + to: "Here are your tasks.", + selectedReferences: [task, memory], + renderedEntityIDs: ["task-1"]), + "Here are your tasks.\n\nSources: [2]", + "the memory has no component, so it keeps its marker") + } + + func testRenderedEntitiesAreReadFromEveryComponentKind() { + let identifiers = ChatCitationMarkup.renderedEntityIDs(in: [ + .taskCard(id: "b1", taskId: "task-1"), + .goalLink(id: "b2", goalId: "goal-1", summary: "Ship"), + .captureLink(id: "b3", conversationId: "conv-1", momentTimestampMs: nil, summary: "Call"), + .memoryLink(id: "b4", memoryId: "memory-9", summary: "Mornings"), + .text(id: "b5", text: "prose"), + ]) + XCTAssertEqual(identifiers, ["task-1", "goal-1", "conv-1", "memory-9"]) + } +} diff --git a/desktop/macos/agent/src/runtime/conversation-journal.ts b/desktop/macos/agent/src/runtime/conversation-journal.ts index 4bbad5bcc6e..bc4dd3816c8 100644 --- a/desktop/macos/agent/src/runtime/conversation-journal.ts +++ b/desktop/macos/agent/src/runtime/conversation-journal.ts @@ -706,7 +706,13 @@ export function updateJournalTurn(store: AgentStore, input: UpdateJournalTurnInp const contentBlocks = input.replaceContentBlocks === undefined ? mergeById(current.contentBlocks, validateContentBlocks(input.appendContentBlocks ?? [])) - : mergeById([], validateContentBlocks(input.replaceContentBlocks)); + : mergeById( + [], + projectContentBlocksOverKernelAuthored( + current.contentBlocks, + validateContentBlocks(input.replaceContentBlocks), + ), + ); const resources = input.replaceResources === undefined ? mergeById(current.resources, validateResources(input.appendResources ?? [])) : mergeById([], validateResources(input.replaceResources)); @@ -1688,7 +1694,7 @@ export function terminalizeJournalTurn( } const content = input.content ?? current.content; const finalContentBlocks = input.disposition === "accept" && contentBlocks !== undefined - ? monotonicAcceptContentBlocks(current.contentBlocks, contentBlocks) + ? projectContentBlocksOverKernelAuthored(current.contentBlocks, contentBlocks) : contentBlocks ?? current.contentBlocks; const finalResources = input.disposition === "accept" && resources !== undefined ? monotonicAcceptResources(current.resources, resources) @@ -1954,14 +1960,15 @@ function markDiscardedBackendProjection(store: AgentStore, turnId: string, nowMs /** * The block kinds the kernel writes and the visible projection never authors. * - * Terminalization hands us the projection Swift assembled from the adapter - * stream — text, tool calls, thinking, and the cards Swift itself appends. A - * block the *agent* rendered mid-turn through `render_chat_blocks` cannot be in - * it: that append is a journal mutation, not a stream event, so the surface has - * never seen it. Replacing the turn's blocks with that projection therefore - * deleted every task card, goal link and memory link the turn had rendered, - * about three seconds after the tool reported success — which is why chat-first - * components looked like they never rendered while the tool returned `ok`. + * Both the streaming update and the terminal commit hand us the projection + * Swift assembled from the adapter stream — text, tool calls, thinking, and the + * cards Swift itself appends. A block the *agent* rendered mid-turn through + * `render_chat_blocks` cannot be in it: that append is a journal mutation, not + * a stream event, so the surface has never seen it. Replacing the turn's blocks + * with that projection deleted every task card, goal link and memory link the + * turn had rendered, seconds after the tool reported success — which is why + * chat-first components looked like they never rendered while the tool returned + * `ok`. */ const KERNEL_AUTHORED_CONTENT_BLOCK_TYPES: ReadonlySet<ConversationContentBlock["type"]> = new Set([ "agentSpawn", @@ -1974,19 +1981,38 @@ const KERNEL_AUTHORED_CONTENT_BLOCK_TYPES: ReadonlySet<ConversationContentBlock[ "questionCard", ]); -function monotonicAcceptContentBlocks( +/** + * Blocks the surface may re-send but never re-derive, so the journal's copy + * stays canonical even when a projection carries one of its own. + */ +const PINNED_CONTENT_BLOCK_TYPES: ReadonlySet<ConversationContentBlock["type"]> = new Set([ + "agentSpawn", + "agentCompletion", +]); + +/** + * Apply a surface projection over the journal's blocks without losing the ones + * the surface could not have known about. + * + * An id the projection carries is the projection's to define — that is how a + * question card's options get retired — except for the pinned kinds above. + * An id it omits survives only when the kernel wrote it. + */ +function projectContentBlocksOverKernelAuthored( current: readonly ConversationContentBlock[], incoming: readonly ConversationContentBlock[], ): ConversationContentBlock[] { - const protectedCurrent = new Map( + const pinned = new Map( current - .filter((block) => KERNEL_AUTHORED_CONTENT_BLOCK_TYPES.has(block.type)) + .filter((block) => PINNED_CONTENT_BLOCK_TYPES.has(block.type)) .map((block) => [block.id, block] as const), ); - const result = incoming.map((block) => structuredClone(protectedCurrent.get(block.id) ?? block)); + const result = incoming.map((block) => structuredClone(pinned.get(block.id) ?? block)); const resultIds = new Set(result.map((block) => block.id)); - for (const block of protectedCurrent.values()) { - if (!resultIds.has(block.id)) result.push(structuredClone(block)); + for (const block of current) { + if (resultIds.has(block.id)) continue; + if (!KERNEL_AUTHORED_CONTENT_BLOCK_TYPES.has(block.type)) continue; + result.push(structuredClone(block)); } return result; } diff --git a/desktop/macos/agent/tests/conversation-journal.test.ts b/desktop/macos/agent/tests/conversation-journal.test.ts index 1d747a69f60..71acc84f20d 100644 --- a/desktop/macos/agent/tests/conversation-journal.test.ts +++ b/desktop/macos/agent/tests/conversation-journal.test.ts @@ -443,6 +443,39 @@ describe("kernel conversation journal", () => { fixture.store.close(); }); + it("keeps the cards the agent rendered when the surface replaces its blocks mid-turn", () => { + // Terminalization is not the only replace. The streaming projection pushes + // the surface's own block list several times a turn, and each one used to + // take the agent's cards with it — the append survived the commit and died + // to the very next update. + const fixture = newSurface("main_chat", "chat", "chat-first-survives-update"); + const { run, attempt } = insertActiveRunAttempt(fixture, "chat-first-survives-update"); + recordStreamingAssistantPlaceholder(fixture, "turn-chat-first-update"); + appendChatFirstBlocksToProducingTurn(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + blocks: [{ type: "taskCard", id: "cfb-task-1", taskId: "task-1" }], + }); + + const updated = updateJournalTurn(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + turnId: "turn-chat-first-update", + replaceContentBlocks: [ + { type: "text", id: "turn-chat-first-update:terminal", text: "Here they are." }, + ], + nowMs: 30, + }); + + expect(updated.contentBlocks).toEqual([ + { type: "text", id: "turn-chat-first-update:terminal", text: "Here they are." }, + { type: "taskCard", id: "cfb-task-1", taskId: "task-1" }, + ]); + fixture.store.close(); + }); + it("attaches only a ready local generated image to the producing Chat-first turn", () => { const fixture = newSurface("main_chat", "chat", "chat-first-evidence"); const { run, attempt } = insertActiveRunAttempt(fixture, "chat-first-evidence"); From 141b83ad13dfc1b863d9816c6f970ba8ff544ce9 Mon Sep 17 00:00:00 2001 From: David Zhang <david.d.zhang@gmail.com> Date: Thu, 3 Sep 2026 04:30:31 -0400 Subject: [PATCH 26/29] fix(desktop): every citation marker the transcript writes is selectable and live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AppKit prose path matched `[\d{1,3}]` of its own invention. Ordinals run to four digits and the model also writes the kind beside them — `[5004]`, `[memory 5023]` — so real markers rendered as dead text in the middle of an answer. It uses `ChatCitationMarkup.numericMarkerPattern` now, which is the transcript's own definition of a marker rather than a second copy of it. Also pins what the harness proved by hand: the mounted transcript hosts a selectable text view on rows at more than one inset, so it is both senders' words that can be dragged across, not just Omi's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../Components/ChatSelectableProse.swift | 9 +++++- .../Tests/ChatRowErgonomicsTests.swift | 16 ++++++++++ .../ChatTranscriptGestureHarnessTests.swift | 29 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift index 28b94c3a352..f5993532f60 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift @@ -103,9 +103,16 @@ enum ChatSelectableProse { /// literal text (there is no link destination after it), so it is still here /// to find, and turning it into a link keeps it clickable *and* selectable — /// the chip button it replaces was neither. + /// + /// The pattern is the transcript's own, not a second copy of it: ordinals run + /// to four digits and the model also writes kind-prefixed markers like + /// `[memory 5023]`, both of which a hand-rolled `\[\d{1,3}\]` quietly left + /// as dead text. static func applyCitationLinks(to text: NSMutableAttributedString, ordinals: Set<Int>) { guard !ordinals.isEmpty else { return } - guard let pattern = try? NSRegularExpression(pattern: #"\[(\d{1,3})\]"#) else { return } + guard + let pattern = try? NSRegularExpression(pattern: ChatCitationMarkup.numericMarkerPattern) + else { return } let full = NSRange(location: 0, length: text.length) for match in pattern.matches(in: text.string, range: full).reversed() { guard match.numberOfRanges == 2, diff --git a/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift b/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift index 2101d0fb4ea..4ded1ce45fa 100644 --- a/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift +++ b/desktop/macos/Desktop/Tests/ChatRowErgonomicsTests.swift @@ -77,6 +77,22 @@ final class ChatSelectableProseTests: XCTestCase { XCTAssertEqual(ChatSelectableProse.citationOrdinal(from: link), 1) } + /// Ordinals run to four digits, and the model also writes the kind alongside + /// them. Both used to fall outside a narrower pattern and render as dead text. + func testWideAndKindPrefixedMarkersAreLinkedToo() throws { + let wide = try attributed("You preferred the clearer direction. [5004]", citations: [5004]) + XCTAssertEqual( + ChatSelectableProse.citationOrdinal( + from: try XCTUnwrap(try attribute(.link, of: wide, at: "[5004]") as? URL)), + 5004) + + let prefixed = try attributed("You prefer mornings. [memory 5023]", citations: [5023]) + XCTAssertEqual( + ChatSelectableProse.citationOrdinal( + from: try XCTUnwrap(try attribute(.link, of: prefixed, at: "[memory 5023]") as? URL)), + 5023) + } + /// A bracketed number the turn has no source for is prose, not a control. func testAnUnknownBracketedNumberIsLeftAsWords() throws { let text = try attributed("Section [4] of the lease.", citations: [1]) diff --git a/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift b/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift index 9b181124647..38db7d0eee1 100644 --- a/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift +++ b/desktop/macos/Desktop/Tests/ChatTranscriptGestureHarnessTests.swift @@ -217,6 +217,35 @@ final class ChatTranscriptGestureHarnessTests: XCTestCase { "transcript rows stop at x=\(painted) of \(viewportWidth), so a gutter is still reserved") } + /// Selection has to be in the mounted transcript, on both senders' rows. + /// The unit tests prove the attributed string; this proves the transcript + /// actually hosts the text view that owns a selection, which is the part a + /// wiring mistake would silently drop. + func testEveryMountedRowHostsSelectableText() throws { + let harness = try makeHarness(messageCount: 12) + defer { harness.tearDown() } + harness.settleInitialPlacement() + + var leadingEdges = Set<CGFloat>() + var selectable = 0 + func walk(_ view: NSView) { + if let text = view as? ChatProseTextView { + XCTAssertTrue(text.isSelectable, "a mounted row that cannot be selected is the old bug") + XCTAssertFalse(text.isEditable, "a transcript row is not a document") + leadingEdges.insert(text.convert(text.bounds, to: nil).origin.x) + selectable += 1 + } + view.subviews.forEach(walk) + } + walk(harness.scrollView) + + XCTAssertGreaterThan(selectable, 0, "the transcript mounted no selectable prose at all") + XCTAssertGreaterThan( + leadingEdges.count, 1, + "user and assistant rows start at different insets, so one inset means only one sender " + + "is selectable — which is exactly what the popover era looked like") + } + func testRepeatedFastBurstsKeepTheMountedTranscriptResponsive() throws { let harness = try makeHarness(messageCount: 120) defer { harness.tearDown() } From 1412afe3bb92db901aeff3d1a6b8985a347cb223 Mon Sep 17 00:00:00 2001 From: David Zhang <david.d.zhang@gmail.com> Date: Thu, 3 Sep 2026 04:32:41 -0400 Subject: [PATCH 27/29] chore(desktop): the cohesive chat flow covers the selection renderer that replaced the popover Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- desktop/macos/e2e/flows/chat-first-cohesive.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/macos/e2e/flows/chat-first-cohesive.yaml b/desktop/macos/e2e/flows/chat-first-cohesive.yaml index 96d3e8bfec8..953139346ba 100644 --- a/desktop/macos/e2e/flows/chat-first-cohesive.yaml +++ b/desktop/macos/e2e/flows/chat-first-cohesive.yaml @@ -44,7 +44,7 @@ covers: # covers that mount and teardown; the selectable text view's own behaviour # (selectable, not editable, one view across rebuilds) is asserted # hermetically in ChatSelectableTextSurfaceTests, not here. - - desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableTextPopover.swift + - desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift - desktop/macos/Desktop/Sources/MainWindow/Components/StableChatCardHeader.swift - desktop/macos/Desktop/Sources/MainWindow/Components/ChatConversationReferencePill.swift # S8 drives the Tasks-page closure, including its bounded attempt/terminal telemetry. From f8d9e549ead2b096928a15ae5591d66d53cb2054 Mon Sep 17 00:00:00 2001 From: David Zhang <david.d.zhang@gmail.com> Date: Thu, 3 Sep 2026 04:39:47 -0400 Subject: [PATCH 28/29] test(ci): the selection boundary fixtures cover the AppKit surface too The remedy is an NSTextView owning its own selection; a SwiftUI rewrite of that file would put SelectionOverlay back in the transcript under a name no pattern check can see, so the fixtures pin that rule alongside the existing ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../test_check_chat_selection_boundary.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/scripts/test_check_chat_selection_boundary.py b/.github/scripts/test_check_chat_selection_boundary.py index 60b5502a910..1813527884f 100644 --- a/.github/scripts/test_check_chat_selection_boundary.py +++ b/.github/scripts/test_check_chat_selection_boundary.py @@ -28,6 +28,11 @@ def clean_sources() -> dict[str, str]: CHECKER.MARKDOWN_FILE: ( "struct OmiMarkdown { var body: some View { Text(text).textSelection(.disabled) } }\n" ), + CHECKER.SELECTION_FILE: ( + "struct ChatSelectableProseText: NSViewRepresentable {\n" + " func makeNSView(context: Context) -> NSTextView { ChatProseTextView() }\n" + "}\n" + ), } @@ -61,6 +66,19 @@ def test_requires_explicit_disabled_boundary(self) -> None: self.assertTrue(any("explicitly disable" in failure for failure in failures)) + def test_requires_the_selection_surface_to_stay_appkit(self) -> None: + """The remedy is an NSTextView owning its own selection. A SwiftUI + rewrite of this file would put SelectionOverlay back in the transcript + under a name the pattern check cannot see.""" + sources = clean_sources() + sources[CHECKER.SELECTION_FILE] = ( + "struct ChatSelectableProseText: View { var body: some View { Text(text) } }\n" + ) + + failures = CHECKER.check_sources(sources) + + self.assertTrue(any("NSTextView" in failure for failure in failures)) + def test_rejects_missing_protected_source(self) -> None: sources = clean_sources() missing = CHECKER.LIVE_TRANSCRIPT_FILES[1] From eb3603457194549b07fdb5eecee745e77a023407 Mon Sep 17 00:00:00 2001 From: David Zhang <david.d.zhang@gmail.com> Date: Thu, 3 Sep 2026 04:47:07 -0400 Subject: [PATCH 29/29] chore(agent): the component cap is not a licence to write the list out instead Asking to see your tasks and getting seventeen of them numbered in prose is the shape components exist to replace; the cap bounds how many are drawn, not whether any are. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../Desktop/Sources/Generated/GeneratedToolExecutors.swift | 2 +- desktop/macos/agent/src/runtime/omi-tool-manifest.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift index ae621a625ca..5f4d5aebd44 100644 --- a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift +++ b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift @@ -55,7 +55,7 @@ enum GeneratedSwiftToolExecutor: String { enum GeneratedToolExecutors { static let manifestVersion = 1 static let manifestDigest = "sha256:bb3ddf9efd89ddb68be7de310756422fe0ccb8d9eea6da0b7d8bc44fd456f2c2" - static let chatFirstManifestDigest = "sha256:2795f3af713ff85c99af99a47c8457e86a739a57e4aeca608ff6bf63ecf1dc44" + static let chatFirstManifestDigest = "sha256:766e7c69ed51877faf7a844846c5de2ce849c9aed5a0b245f8028f2304bc0838" static let aliasToCanonical: [String: GeneratedSwiftTool] = [ "search_screen_history": .semanticSearch, diff --git a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts index 983144eb83e..b5b139362ba 100644 --- a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts +++ b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts @@ -2100,6 +2100,7 @@ export const chatFirstToolManifest: OmiToolManifestEntry[] = [ "Rendering replaces the writing. \"Here are your three tasks:\" followed by three task cards is right; the same sentence followed by a numbered list of those same three tasks, with or without cards, is the failure this rule exists to stop.", "Answering a question from what you read is the common case and needs no components. Cite those entities inline instead — a summary of yesterday cites the conversations it drew on, it does not stack cards above itself.", "Render at most three components in a turn, and prefer none to a wall of them.", + "The cap is not a reason to fall back to prose. When the user asked to see or work through their tasks, goals or memories, render the three that matter and say how many more there are — a numbered list of entities written out in the message is the exact thing components replace.", "Do not ask whether the user wants cards, and do not substitute a Markdown table for entities the user asked to act on.", "For task cards, obtain opaque canonical task IDs from get_action_items or create_action_item; execute_sql numeric row IDs are invalid.", "Never invent entity identifiers or URLs; the server validates every requested reference.",