From 8b5baf08e4de6e6424a9208f7a3330582f1c3c1e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 05:34:27 -0700 Subject: [PATCH 01/49] Modernize and ship Feed entry points --- .../Keys/BetaFeaturesCatalogSection.swift | 10 - .../CuratedSettingEntry+Default.swift | 1 - .../Sections/BetaFeaturesSection.swift | 22 - .../SettingsRowAnchorResolutionTests.swift | 1 - .../Values/NewWorkspaceInitialSurface.swift | 2 + Resources/Localizable.xcstrings | 102 +- Sources/App/WorkspaceRuntimeSettings.swift | 7 - .../AppDelegate+NewWorkspaceContextMenu.swift | 10 + ...ppDelegate+NewWorkspaceMenuRendering.swift | 13 + Sources/AppDelegate.swift | 13 + .../CommandPaletteSettingsToggle.swift | 11 - ...ntentView+RightSidebarCommandPalette.swift | 11 +- Sources/ContentView.swift | 4 + Sources/FeatureFlags.swift | 20 + Sources/Feed/FeedBlockingWaiterRegistry.swift | 58 + Sources/Feed/FeedCoordinator+Attention.swift | 222 + .../Feed/FeedCoordinator+Notifications.swift | 330 ++ Sources/Feed/FeedCoordinator+Socket.swift | 71 + Sources/Feed/FeedCoordinator.swift | 1051 +---- Sources/Feed/FeedCoordinatorState.swift | 27 + Sources/Feed/FeedExitPlanView.swift | 414 ++ Sources/Feed/FeedItemRow.swift | 436 ++ Sources/Feed/FeedItemSnapshot.swift | 102 + Sources/Feed/FeedJumpResolver.swift | 80 + Sources/Feed/FeedKeyboardFocus.swift | 292 ++ Sources/Feed/FeedListView.swift | 451 ++ .../Feed/FeedNotificationPolicyContext.swift | 70 + Sources/Feed/FeedOpeningCoordinator.swift | 48 + Sources/Feed/FeedPanelView.swift | 3781 +---------------- Sources/Feed/FeedPanelViewModel.swift | 59 +- Sources/Feed/FeedPermissionView.swift | 800 ++++ Sources/Feed/FeedQuestionView.swift | 1032 +++++ Sources/Feed/FeedSocketEncoding.swift | 170 + Sources/Feed/FeedTelemetryView.swift | 287 ++ Sources/FileExplorerState.swift | 1 + Sources/NewWorkspaceMenuModel.swift | 7 +- Sources/RightSidebarMode+Availability.swift | 6 +- Sources/RightSidebarModeShortcutMatcher.swift | 2 +- Sources/RightSidebarPanelView.swift | 14 +- Sources/RightSidebarToolPanel.swift | 4 +- Sources/SettingsNavigation.swift | 2 - Sources/SettingsSearchAliases.swift | 1 - Sources/TabManager.swift | 2 + Sources/Workspace.swift | 16 + cmux.xcodeproj/project.pbxproj | 68 + cmuxTests/FeedCoordinatorTests.swift | 28 + ...ileExplorerStateModePersistenceTests.swift | 137 +- cmuxTests/NewWorkspaceMenuModelTests.swift | 20 + .../PostHogAnalyticsPropertiesTests.swift | 21 + .../RightSidebarCommandPaletteTests.swift | 113 +- 50 files changed, 5385 insertions(+), 5065 deletions(-) create mode 100644 Sources/Feed/FeedBlockingWaiterRegistry.swift create mode 100644 Sources/Feed/FeedCoordinator+Attention.swift create mode 100644 Sources/Feed/FeedCoordinator+Notifications.swift create mode 100644 Sources/Feed/FeedCoordinator+Socket.swift create mode 100644 Sources/Feed/FeedCoordinatorState.swift create mode 100644 Sources/Feed/FeedExitPlanView.swift create mode 100644 Sources/Feed/FeedItemRow.swift create mode 100644 Sources/Feed/FeedItemSnapshot.swift create mode 100644 Sources/Feed/FeedJumpResolver.swift create mode 100644 Sources/Feed/FeedKeyboardFocus.swift create mode 100644 Sources/Feed/FeedListView.swift create mode 100644 Sources/Feed/FeedNotificationPolicyContext.swift create mode 100644 Sources/Feed/FeedOpeningCoordinator.swift create mode 100644 Sources/Feed/FeedPermissionView.swift create mode 100644 Sources/Feed/FeedQuestionView.swift create mode 100644 Sources/Feed/FeedSocketEncoding.swift create mode 100644 Sources/Feed/FeedTelemetryView.swift diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift index a9dac7bbffdc..c3dcae6a2dd6 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift @@ -6,16 +6,6 @@ import Foundation /// pattern `.beta.` so the cmux.json view /// groups them sensibly. public struct BetaFeaturesCatalogSection: SettingCatalogSection { - /// Right-sidebar Feed: an experimental mode that surfaces inline agent - /// decisions (permission prompts, questions) in the right-sidebar mode - /// switcher. Defaults off; while off, the Feed mode is hidden from the - /// switcher so the feature stays opt-in while it is in beta. - public let rightSidebarFeed = DefaultsKey( - id: "rightSidebar.beta.feed.enabled", - defaultValue: false, - userDefaultsKey: "rightSidebar.beta.feed.enabled" - ) - /// Right-sidebar Dock: an experimental terminal-controls dock that /// replaces the per-pane action chrome with a unified right-side /// rail. Defaults off; flagged as unstable in the Settings UI. diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index 22f6b7705d2a..05e608794e5c 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -195,7 +195,6 @@ extension Array where Element == CuratedSettingEntry { .init(section: .customSidebars, id: "renderer", title: String(localized: "settings.customSidebars.renderer", defaultValue: "Renderer"), synonyms: "customSidebars.renderer renderer in-process in app remote worker isolated process hover focus typing input"), // Beta - .init(section: .betaFeatures, id: "feed", title: "Feed", synonyms: "feed right sidebar agent decisions permissions questions approval beta unstable"), .init(section: .betaFeatures, id: "dock", title: "Dock", synonyms: "dock right sidebar terminal controls tui beta unstable"), .init(section: .betaFeatures, id: "customSidebars", title: "Custom Sidebars", synonyms: "custom sidebars swift json interpreted vibe beta unstable"), .init(section: .betaFeatures, id: "remoteTmux", title: "Remote tmux", synonyms: "remote tmux ssh control mode -CC mirror session window pane sidebar workspace beta unstable"), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift index 3d9778a9f0a6..8bfd4da0ed68 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift @@ -7,7 +7,6 @@ import SwiftUI /// off by default. @MainActor public struct BetaFeaturesSection: View { - @State private var feed: DefaultsValueModel @State private var dock: DefaultsValueModel @State private var extensions: DefaultsValueModel @State private var customSidebars: DefaultsValueModel @@ -15,7 +14,6 @@ public struct BetaFeaturesSection: View { @State private var workspaceTodosChecklistStyle: DefaultsValueModel public init(defaultsStore: UserDefaultsSettingsStore, catalog: SettingCatalog) { - _feed = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.rightSidebarFeed)) _dock = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.rightSidebarDock)) _extensions = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.extensions)) _customSidebars = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.customSidebars)) @@ -31,8 +29,6 @@ public struct BetaFeaturesSection: View { String(localized: "settings.betaFeatures.warning", defaultValue: "These features are experimental and may change or break. Enable them only when you are testing them.") ) SettingsCardDivider() - feedRow - SettingsCardDivider() dockRow SettingsCardDivider() extensionsRow @@ -49,7 +45,6 @@ public struct BetaFeaturesSection: View { private func startObservingSettings() { let models: [any SettingObservationStarting] = [ - feed, dock, extensions, customSidebars, @@ -83,23 +78,6 @@ public struct BetaFeaturesSection: View { } } - @ViewBuilder - private var feedRow: some View { - SettingsCardRow( - configurationReview: .settingsOnly, - searchAnchorID: "setting:betaFeatures:feed", - String(localized: "settings.betaFeatures.feed", defaultValue: "Feed"), - subtitle: feed.current - ? String(localized: "settings.betaFeatures.feed.subtitleOn", defaultValue: "Shows Feed in the right sidebar mode switcher for inline agent decisions.") - : String(localized: "settings.betaFeatures.feed.subtitleOff", defaultValue: "Hides Feed from the right sidebar until you enable it here.") - ) { - Toggle("", isOn: Binding(get: { feed.current }, set: { feed.set($0) })) - .labelsHidden() - .controlSize(.small) - .accessibilityIdentifier("SettingsBetaFeedToggle") - } - } - @ViewBuilder private var dockRow: some View { SettingsCardRow( diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index 2fea18222458..352741c9f8e9 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -151,7 +151,6 @@ struct SettingsRowAnchorResolutionTests { "setting:mobile:iOSPairingHost", "setting:mobile:iOSPairingPort", "setting:mobile:iOSPairingDisplayName", - "setting:betaFeatures:feed", "setting:betaFeatures:dock", "setting:betaFeatures:customSidebars", "setting:betaFeatures:remoteTmux", diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Values/NewWorkspaceInitialSurface.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Values/NewWorkspaceInitialSurface.swift index fecc76e6a9b3..50a22fe1288d 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Values/NewWorkspaceInitialSurface.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Values/NewWorkspaceInitialSurface.swift @@ -9,6 +9,8 @@ public enum NewWorkspaceInitialSurface: Sendable { case terminal /// A browser pane in its default new-tab state. case browser + /// The native Feed surface with no terminal process. + case feed /// A transient Cloud VM loading surface. It is swapped for a terminal once attach is ready. case cloudVMLoading } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 87bca74b1e56..a9f8ee891442 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -54425,6 +54425,23 @@ } } }, + "command.openFeedPane.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Feed as Pane" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードをペインで開く" + } + } + } + }, "command.openFolder.subtitle": { "extractionState": "manual", "localizations": { @@ -100157,6 +100174,40 @@ } } }, + "featureFlags.feed.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows Feed in the right sidebar, new-workspace menu, and command palette." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右サイドバー、新規ワークスペースメニュー、コマンドパレットにフィードを表示します。" + } + } + } + }, + "featureFlags.feed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feed UI" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードUI" + } + } + } + }, "featureFlags.column.current": { "extractionState": "manual", "localizations": { @@ -153678,57 +153729,6 @@ } } }, - "settings.betaFeatures.feed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Feed" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Feed" - } - } - } - }, - "settings.betaFeatures.feed.subtitleOff": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Hides Feed from the right sidebar until you enable it here." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ここで有効にするまで、Feed を右サイドバーから隠します。" - } - } - } - }, - "settings.betaFeatures.feed.subtitleOn": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Shows Feed in the right sidebar mode switcher for inline agent decisions." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Feed を右サイドバーのモード切り替えに表示し、エージェントの判断をインラインで扱えるようにします。" - } - } - } - }, "settings.betaFeatures.remoteTmux": { "extractionState": "manual", "localizations": { diff --git a/Sources/App/WorkspaceRuntimeSettings.swift b/Sources/App/WorkspaceRuntimeSettings.swift index 55d937c4ee14..aa6982d3e494 100644 --- a/Sources/App/WorkspaceRuntimeSettings.swift +++ b/Sources/App/WorkspaceRuntimeSettings.swift @@ -480,17 +480,10 @@ enum AgentHibernationTrackingGate { } enum RightSidebarBetaFeatureSettings { - static let feedEnabledKey = "rightSidebar.beta.feed.enabled" static let dockEnabledKey = "rightSidebar.beta.dock.enabled" - static let defaultFeedEnabled = false static let defaultDockEnabled = false - nonisolated static func isFeedEnabled(defaults: UserDefaults = .standard) -> Bool { - guard defaults.object(forKey: feedEnabledKey) != nil else { return defaultFeedEnabled } - return defaults.bool(forKey: feedEnabledKey) - } - nonisolated static func isDockEnabled(defaults: UserDefaults = .standard) -> Bool { guard defaults.object(forKey: dockEnabledKey) != nil else { return defaultDockEnabled } return defaults.bool(forKey: dockEnabledKey) diff --git a/Sources/AppDelegate+NewWorkspaceContextMenu.swift b/Sources/AppDelegate+NewWorkspaceContextMenu.swift index 05583dd61a62..3281765f83b7 100644 --- a/Sources/AppDelegate+NewWorkspaceContextMenu.swift +++ b/Sources/AppDelegate+NewWorkspaceContextMenu.swift @@ -75,6 +75,7 @@ extension AppDelegate { let model = NewWorkspaceMenuModel.build( newWorkspaceContextMenuItems: cmuxConfigStore.newWorkspaceContextMenuItems, agentChatAction: resolvedBuiltInNewAgentChatAction(cmuxConfigStore: cmuxConfigStore), + feedSectionEnabled: CmuxFeatureFlags.shared.isFeedUIEnabled, cloudSectionEnabled: CmuxFeatureFlags.shared.isCloudVMUIEnabled, templateNames: savedLayoutNames(), loadedActions: cmuxConfigStore.loadedActions, @@ -142,4 +143,13 @@ extension AppDelegate { return } } + + @objc func performNewFeedWorkspaceMenuItem(_ sender: NSMenuItem) { + guard let windowID = sender.representedObject as? NSUUID, + let context = mainWindowContexts.values.first(where: { $0.windowId == windowID as UUID }) else { + NSSound.beep() + return + } + _ = feedOpeningCoordinator.openPinnedWorkspace(in: context.tabManager) + } } diff --git a/Sources/AppDelegate+NewWorkspaceMenuRendering.swift b/Sources/AppDelegate+NewWorkspaceMenuRendering.swift index 603b6d583fce..7fc411cf97a4 100644 --- a/Sources/AppDelegate+NewWorkspaceMenuRendering.swift +++ b/Sources/AppDelegate+NewWorkspaceMenuRendering.swift @@ -100,6 +100,19 @@ extension AppDelegate { case .cloud: let cloudMenu = TitlebarCloudVMButton.makeCloudVMMenu() addRenderedSection(cloudMenu.items) + case .feed: + let item = NSMenuItem( + title: String(localized: "rightSidebar.mode.feed", defaultValue: "Feed"), + action: #selector(performNewFeedWorkspaceMenuItem(_:)), + keyEquivalent: "" + ) + item.target = self + item.representedObject = context.windowId as NSUUID + item.image = NSImage( + systemSymbolName: RightSidebarMode.feed.symbolName, + accessibilityDescription: nil + ) + addRenderedSection([item]) case .layouts(let rows): var items: [NSMenuItem] = [ .sectionHeader(title: String( diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 64a07e4ca919..f9ae20a2750b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -508,6 +508,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// Owns the About Titlebar Debug subsystem (CmuxAppKitSupportUI); composition-root /// owned and created lazily so the window-decoration seam can point back at `self`. lazy var debugWindowsCoordinator = DebugWindowsCoordinator(decorator: self) + lazy var feedOpeningCoordinator = FeedOpeningCoordinator( + isEnabled: { CmuxFeatureFlags.shared.isFeedUIEnabled } + ) /// About Titlebar Debug options store, applied by the About/Acknowledgments windows. var aboutTitlebarDebugStore: AboutTitlebarDebugStore { debugWindowsCoordinator.aboutTitlebarStore } /// Coordinates remote tmux (`ssh … tmux -CC`) mirroring; composition-root owned. @@ -7377,6 +7380,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if focusInitialBrowserAddressBarOnCreate { focusInitialBrowserAddressBar(in: workspace) } + case .feed: + if let workspace = feedOpeningCoordinator.openPinnedWorkspace( + in: context.tabManager + ) { + closeInitialWorkspaceIfNeeded( + initialWorkspaceId: initialWorkspace?.id, + in: context + ) + createdWorkspaceHandler?(workspace) + } case .cloudVMLoading: let workspace = context.tabManager.addWorkspace(initialSurface: .cloudVMLoading) closeInitialWorkspaceIfNeeded( diff --git a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift index f3b739adf34d..6a69b996071c 100644 --- a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift +++ b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift @@ -726,17 +726,6 @@ enum CommandPaletteSettingsToggleCommands { defaultsKey: SidebarWorkspaceDetailDefaults.showCustomMetadataKey, isAvailable: sidebarDetailsAvailable ), - CommandPaletteSettingToggleDescriptor( - commandId: commandIdPrefix + "rightSidebarFeed", - settingsKey: "betaFeatures.feed", - title: { - String(localized: "settings.betaFeatures.feed", defaultValue: "Feed") - }, - sectionTitle: beta, - keywords: ["betaFeatures.feed", "feed", "right", "sidebar", "beta", "agent", "decisions", "permissions"], - defaultValue: RightSidebarBetaFeatureSettings.defaultFeedEnabled, - defaultsKey: RightSidebarBetaFeatureSettings.feedEnabledKey - ), CommandPaletteSettingToggleDescriptor( commandId: commandIdPrefix + "rightSidebarDock", settingsKey: "betaFeatures.dock", diff --git a/Sources/ContentView+RightSidebarCommandPalette.swift b/Sources/ContentView+RightSidebarCommandPalette.swift index 7e5ed04a540d..7d18f3e2495b 100644 --- a/Sources/ContentView+RightSidebarCommandPalette.swift +++ b/Sources/ContentView+RightSidebarCommandPalette.swift @@ -153,7 +153,8 @@ extension ContentView { static func commandPaletteRightSidebarToolPaneCommandDescriptors() -> [(mode: RightSidebarMode, commandId: String, title: String)] { RightSidebarMode.paneModes.compactMap { mode in - guard let commandId = commandPaletteRightSidebarToolPaneCommandID(mode), + guard mode.isAvailable(), + let commandId = commandPaletteRightSidebarToolPaneCommandID(mode), let title = commandPaletteRightSidebarToolPaneTitle(mode) else { return nil } @@ -169,7 +170,9 @@ extension ContentView { return "palette.openFindPane" case .sessions: return "palette.openVaultPane" - case .feed, .dock, .customSidebar: + case .feed: + return "palette.openFeedPane" + case .dock, .customSidebar: return nil } } @@ -182,7 +185,9 @@ extension ContentView { return String(localized: "command.openFindPane.title", defaultValue: "Open Find as Pane") case .sessions: return String(localized: "command.openVaultPane.title", defaultValue: "Open Vault as Pane") - case .feed, .dock, .customSidebar: + case .feed: + return String(localized: "command.openFeedPane.title", defaultValue: "Open Feed as Pane") + case .dock, .customSidebar: return nil } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b77b017b63b0..1592abf9b11c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2228,6 +2228,10 @@ struct ContentView: View { } sidebarSelectionState.selection = .tabs + if mode == .feed { + _ = AppDelegate.shared?.feedOpeningCoordinator.openPane(in: workspace) + return + } workspace.clearSplitZoom() _ = workspace.openOrFocusRightSidebarToolSurface(inPane: paneId, mode: mode, focus: true) } diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index a16336dc9a7a..a91740e202b7 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -46,6 +46,7 @@ final class CmuxFeatureFlags { #endif private static let agentChatUIDefault = false private static let sidebarWorkspaceAgentSpinnerDefault = false + private static let feedUIDefault = true private static let overrideKeyPrefix = "cmux.flags.override." @@ -132,6 +133,21 @@ final class CmuxFeatureFlags { ), defaultWhenUnavailable: CmuxFeatureFlags.sidebarWorkspaceAgentSpinnerDefault ), + + // FLAG(key: feed-ui-enabled-release, owner: lawrencecchen, + // reviewBy: 2026-10-01, defaultWhenUnavailable: true) + // Shows Feed in the right sidebar, new-workspace menu, and command + // palette. The default ships Feed to everyone while retaining a + // remote kill switch for operational regressions. + CmuxFeatureFlagDefinition( + key: "feed-ui-enabled-release", + title: String(localized: "featureFlags.feed.title", defaultValue: "Feed UI"), + flagDescription: String( + localized: "featureFlags.feed.description", + defaultValue: "Shows Feed in the right sidebar, new-workspace menu, and command palette." + ), + defaultWhenUnavailable: CmuxFeatureFlags.feedUIDefault + ), ] }() @@ -155,6 +171,10 @@ final class CmuxFeatureFlags { effectiveValue(for: Self.allFlags[4]) } + var isFeedUIEnabled: Bool { + effectiveValue(for: Self.allFlags[5]) + } + @ObservationIgnored private let defaults: UserDefaults @ObservationIgnored diff --git a/Sources/Feed/FeedBlockingWaiterRegistry.swift b/Sources/Feed/FeedBlockingWaiterRegistry.swift new file mode 100644 index 000000000000..21cbdc6e4d7b --- /dev/null +++ b/Sources/Feed/FeedBlockingWaiterRegistry.swift @@ -0,0 +1,58 @@ +import Foundation +import CMUXAgentLaunch +import os + +/// Owns the synchronous request/reply bridge required by the control socket. +/// +/// The socket handler is synchronous and must return the eventual agent +/// decision on the same worker thread. The semaphore parks only that worker; +/// the unfair lock protects short compare-and-update operations and never +/// guards ongoing Feed domain state. +final class FeedBlockingWaiterRegistry: @unchecked Sendable { + struct PendingWaiter { + let semaphore: DispatchSemaphore + var decision: WorkstreamDecision? + var attentionTarget: FeedCoordinator.AttentionTarget? + } + + // Safety: every waiter-table access is serialized by this lock and every + // critical section is bounded to an in-memory dictionary mutation. + private let lock = OSAllocatedUnfairLock(initialState: [String: PendingWaiter]()) + + func register(requestID: String) -> DispatchSemaphore { + let semaphore = DispatchSemaphore(value: 0) + lock.withLock { waiters in + waiters[requestID] = PendingWaiter(semaphore: semaphore) + } + return semaphore + } + + func setAttentionTarget(_ target: FeedCoordinator.AttentionTarget, requestID: String) { + lock.withLock { waiters in + waiters[requestID]?.attentionTarget = target + } + } + + func deliver(_ decision: WorkstreamDecision, requestID: String) -> FeedCoordinator.AttentionTarget? { + let delivery = lock.withLock { waiters -> (DispatchSemaphore, FeedCoordinator.AttentionTarget?)? in + guard var waiter = waiters[requestID] else { return nil } + waiter.decision = decision + waiters[requestID] = waiter + return (waiter.semaphore, waiter.attentionTarget) + } + delivery?.0.signal() + return delivery?.1 + } + + func remove(requestID: String) -> PendingWaiter? { + lock.withLock { waiters in + waiters.removeValue(forKey: requestID) + } + } + + func isAwaitingDecision(requestID: String) -> Bool { + lock.withLock { waiters in + waiters[requestID]?.decision == nil + } + } +} diff --git a/Sources/Feed/FeedCoordinator+Attention.swift b/Sources/Feed/FeedCoordinator+Attention.swift new file mode 100644 index 000000000000..b5bee3b0c77a --- /dev/null +++ b/Sources/Feed/FeedCoordinator+Attention.swift @@ -0,0 +1,222 @@ +import AppKit +import Bonsplit +import CMUXAgentLaunch +import CmuxSettings +import CmuxSidebar +import Foundation + +// MARK: - In-app attention surfacing + +extension FeedCoordinator { + /// The blocking-decision hook events that warrant pulling the user's + /// attention to the owning workspace: a tool permission, a plan + /// approval, or a question. Keeping this as one predicate (rather than + /// branching per event at each call site) is what makes the attention + /// surface uniform across every event type and agent routed through + /// `feed.push` — a new blocking event type only has to be added here. + static func isBlockingDecisionEvent(_ hookEventName: WorkstreamEvent.HookEventName) -> Bool { + switch hookEventName { + case .permissionRequest, .exitPlanMode, .askUserQuestion: + return true + default: + return false + } + } + + /// Maps a feed `source` (agent id) to the agent-lifecycle status key the + /// sidebar reads. Claude reports under `claude_code`; every other agent + /// keys its status by its own source name. Returning the agent's own key + /// is what lets the existing per-agent resume hooks (e.g. Claude's + /// `pre-tool-use`) clear the needs-input badge once the agent continues. + private static let lifecycleStatusKeyOverrides = [ + "claude": "claude_code", + ] + + static func lifecycleStatusKey(forSource source: String) -> String { + lifecycleStatusKeyOverrides[source] ?? source + } + + /// Identifies the sidebar slot an attention overlay lights up. Overlays + /// are refcounted by this key so overlapping blocking decisions on the + /// same agent/panel don't clear each other's needs-input badge. + struct AttentionTarget: Hashable, Sendable { + let workspaceId: UUID + let panelId: UUID? + let statusKey: String + } + + /// The localized "Needs input" sidebar status the overlay sets. Exposed so + /// ``concludeBlockingDecisionAttention(_:)`` can confirm it's still the + /// value we wrote before clearing it (rather than one an agent hook + /// replaced in the meantime). + static var needsInputStatusValue: String { + String(localized: "feed.status.needsInput", defaultValue: "Needs input") + } + + /// Surfaces in-app attention for a blocking feed decision: flips the + /// owning workspace's agent lifecycle to `.needsInput`, sets the + /// "Needs input" sidebar status, elevates the workspace when + /// *Reorder on Notification* is enabled, and rings the bell. + /// + /// This is the convergence point the PreToolUse→PermissionRequest + /// migration left behind: the `feed.push` bridge ingested the card and + /// (when inactive) posted a banner, but never drove the same in-app + /// attention path the `cmux hooks notification` hook uses. Doing + /// it here — once, for every blocking decision — keeps a new event type + /// from silently swallowing. + /// + /// The overlay is cleared by ``concludeBlockingDecisionAttention(_:)`` + /// when the decision resolves or times out. Clearing is refcounted per + /// ``AttentionTarget`` so overlapping decisions on the same panel keep the + /// badge lit until the last one concludes. + /// + /// - Parameter resolved: the target resolved off the main actor before UI + /// mutation, since hook-session lookup may read from disk. + /// - Returns: the target to conclude once the decision ends, or `nil` if + /// nothing was surfaced (no resolvable workspace). + @MainActor + func surfaceBlockingDecisionAttention( + event: WorkstreamEvent, + resolved: (workspaceId: UUID, surfaceId: UUID?)? + ) -> AttentionTarget? { + guard Self.isBlockingDecisionEvent(event.hookEventName) else { return nil } + + #if DEBUG + if let observer = FeedCoordinatorTestHooks.attentionSurfaceObserver { + observer(event) + return nil + } + #endif + + guard let resolved else { + #if DEBUG + cmuxDebugLog( + "feed.attention.skip reason=unresolved-target session=\(event.sessionId) request=\(event.requestId ?? "nil") hook=\(event.hookEventName.rawValue) source=\(event.source) workspace=\(event.workspaceId ?? "nil") receivedAt=\(event.receivedAt.timeIntervalSince1970)" + ) + #endif + return nil + } + + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: resolved.workspaceId), + let tab = tabManager.tabs.first(where: { $0.id == resolved.workspaceId }) + else { + #if DEBUG + cmuxDebugLog( + "feed.attention.skip reason=missing-workspace session=\(event.sessionId) request=\(event.requestId ?? "nil") hook=\(event.hookEventName.rawValue) source=\(event.source) workspace=\(resolved.workspaceId.uuidString) receivedAt=\(event.receivedAt.timeIntervalSince1970)" + ) + #endif + return nil + } + + let panelId = Self.resolvePanelId(surfaceId: resolved.surfaceId, tab: tab) ?? tab.focusedPanelId + let statusKey = Self.lifecycleStatusKey(forSource: event.source) + let target = AttentionTarget( + workspaceId: resolved.workspaceId, + panelId: panelId, + statusKey: statusKey + ) + let attentionState = pendingAttentionStates[target] ?? AttentionOverlayState(workspace: tab) + attentionState.workspace = tab + attentionState.count += 1 + pendingAttentionStates[target] = attentionState + + // Needs-input lifecycle drives the sidebar badge + hibernation state. + tab.setAgentLifecycle(key: statusKey, panelId: panelId, lifecycle: .needsInput) + tab.statusEntries[statusKey] = SidebarStatusEntry( + key: statusKey, + value: Self.needsInputStatusValue, + icon: "bell.fill", + color: "#4C8DFF", + timestamp: Date() + ) + + // Elevate the workspace so it floats to the top of the sidebar, + // honoring the user's Reorder on Notification preference. + if UserDefaultsSettingsClient(defaults: .standard).value(for: SettingCatalog().app.reorderOnNotification) { + tabManager.moveTabToTopForNotification(resolved.workspaceId) + } + + // Ring the bell (dock bounce while the app is in the background). + NSApp.requestUserAttention(.informationalRequest) + + return target + } + + /// Concludes a blocking decision's attention overlay. Decrements the + /// per-target refcount and, when it reaches zero, clears the needs-input + /// overlay — but only the parts the feed still owns: the lifecycle is set + /// to `.running` only if it's still `.needsInput`, and the status entry is + /// removed only if it still holds our "Needs input" value. Anything an + /// agent hook replaced in the meantime is left untouched, so a real + /// running/idle/needs-input update from the agent always wins. + @MainActor + func concludeBlockingDecisionAttention(_ target: AttentionTarget) { + guard let attentionState = pendingAttentionStates[target] else { return } + if attentionState.count > 1 { + attentionState.count -= 1 + return + } + pendingAttentionStates.removeValue(forKey: target) + let tab = attentionState.workspace + + // Lifecycle is per-panel, so clearing this panel's needs-input is + // safe even if another panel still needs input. + if let panelId = target.panelId, + tab.agentLifecycleStatesByPanelId[panelId]?[target.statusKey] == .needsInput { + tab.setAgentLifecycle(key: target.statusKey, panelId: panelId, lifecycle: .running) + } + + // The status entry is workspace-level (keyed only by statusKey), so it + // is shared across panels running the same agent. Only remove it once + // no other panel in this workspace still has a pending decision under + // the same key — otherwise concluding one panel would wipe another + // panel's active "Needs input" badge. + let anotherPanelStillPending = pendingAttentionStates.keys.contains { + $0.workspaceId == target.workspaceId && $0.statusKey == target.statusKey + } + if !anotherPanelStillPending, + tab.statusEntries[target.statusKey]?.value == Self.needsInputStatusValue { + tab.statusEntries.removeValue(forKey: target.statusKey) + } + } + + /// Resolves the `(workspace, surface)` an attention overlay should target. + /// The workspace prefers the event's live `workspace_id` (the running + /// terminal's CMUX_WORKSPACE_ID, a raw UUID) so a stale hook-session map + /// can't redirect attention to the wrong workspace; it falls back to the + /// session store when the event omits a parseable id. The surface comes + /// from the session store only when its workspace matches the resolved + /// workspace, so a stale entry can't point the panel elsewhere. + static func resolveAttentionTarget( + event: WorkstreamEvent + ) -> (workspaceId: UUID, surfaceId: UUID?)? { + let sessionMatch: (workspaceId: UUID, surfaceId: UUID?)? = { + guard let parsed = FeedJumpResolver.parse(event.sessionId), + let resolved = FeedJumpResolver.lookup(agent: parsed.agent, sessionId: parsed.sessionId), + let workspaceId = UUID(uuidString: resolved.workspaceId) + else { return nil } + return (workspaceId, UUID(uuidString: resolved.surfaceId)) + }() + + let eventWorkspaceId = event.workspaceId.flatMap { + UUID(uuidString: $0.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + guard let workspaceId = eventWorkspaceId ?? sessionMatch?.workspaceId else { + return nil + } + // Only trust the session store's surface if it belongs to the + // workspace we're actually targeting. + let surfaceId = (sessionMatch?.workspaceId == workspaceId) ? sessionMatch?.surfaceId : nil + return (workspaceId, surfaceId) + } + + /// Maps a surface id from the hook-session store to its owning panel id, + /// tolerating stores that already record the panel id directly. + @MainActor + private static func resolvePanelId(surfaceId: UUID?, tab: Workspace) -> UUID? { + guard let surfaceId else { return nil } + if tab.panels[surfaceId] != nil { return surfaceId } + return tab.panelIdFromSurfaceId(TabID(uuid: surfaceId)) + } +} diff --git a/Sources/Feed/FeedCoordinator+Notifications.swift b/Sources/Feed/FeedCoordinator+Notifications.swift new file mode 100644 index 000000000000..ae7cd8a5a8ca --- /dev/null +++ b/Sources/Feed/FeedCoordinator+Notifications.swift @@ -0,0 +1,330 @@ +import AppKit +import CMUXAgentLaunch +import Foundation +@preconcurrency import UserNotifications + +// MARK: - Native notification banner + +extension FeedCoordinator { + /// Posts a UNUserNotificationCenter banner with inline action buttons + /// for the given Feed event after optional notification policy hooks run. + /// Notification eligibility is derived only from the waiter table so + /// resolved/timed-out requests cannot enqueue stale banners while the main + /// queue, policy hooks, or notification center catches up. + func postNotificationIfStillAwaiting(event: WorkstreamEvent, requestId: String) { + Task { @MainActor [weak self] in + guard let self, self.isAwaitingDecision(requestId: requestId) else { + return + } + + #if DEBUG + let isAppActive = FeedCoordinatorTestHooks.isAppActiveOverride?() ?? NSApp.isActive + #else + let isAppActive = NSApp.isActive + #endif + + // Don't pester users while the app is already up front. + if isAppActive { + return + } + + #if DEBUG + if let observer = FeedCoordinatorTestHooks.notificationPostObserver { + observer(event, requestId) + return + } + #endif + + let categoryId: String + let title: String + let body: String + switch event.hookEventName { + case .permissionRequest: + categoryId = Self.permissionNotificationCategoryId(for: event) + title = String( + localized: "feed.notification.permission.title", + defaultValue: "\(event.source.capitalized) permission" + ) + body = event.toolName.map { + String( + localized: "feed.notification.permission.body", + defaultValue: "\($0) needs approval" + ) + } ?? String( + localized: "feed.notification.decisionNeeded", + defaultValue: "Decision needed" + ) + case .exitPlanMode: + categoryId = "CMUXFeedExitPlan" + title = String( + localized: "feed.notification.exitPlan.title", + defaultValue: "\(event.source.capitalized) plan ready" + ) + body = String( + localized: "feed.notification.exitPlan.body", + defaultValue: "Review and approve the plan" + ) + case .askUserQuestion: + categoryId = "CMUXFeedQuestion" + title = String( + localized: "feed.notification.question.title", + defaultValue: "\(event.source.capitalized) question" + ) + body = String( + localized: "feed.notification.question.body", + defaultValue: "Agent is asking a question" + ) + default: + return + } + + let policyContext = FeedNotificationPolicyContext.make( + event: event, + title: title, + body: body + ) + let deliverDefault = { [weak self] in + self?.deliverFeedNotificationIfStillAwaiting( + requestId: requestId, + event: event, + categoryId: categoryId, + title: title, + subtitle: "", + body: body, + effects: policyContext.envelope.effects + ) + } + + guard !policyContext.hooks.isEmpty else { + deliverDefault() + return + } + + let authorizedHooks = await NotificationPolicyHookAuthorizer.authorize( + policyContext.hooks, + globalConfigPath: policyContext.globalConfigPath + ) + guard self.isAwaitingDecision(requestId: requestId) else { return } + guard !authorizedHooks.isEmpty else { + deliverDefault() + return + } + + let result = await TerminalNotificationPolicyEngine.evaluate( + envelope: policyContext.envelope, + hooks: authorizedHooks + ) + guard self.isAwaitingDecision(requestId: requestId) else { return } + switch result { + case .success(let envelope): + let payload = envelope.notification + self.deliverFeedNotificationIfStillAwaiting( + requestId: requestId, + event: event, + categoryId: categoryId, + title: payload.title, + subtitle: payload.subtitle, + body: payload.body, + effects: envelope.effects + ) + case .failure(let failure): + deliverDefault() + TerminalNotificationStore.shared.reportNotificationHookFailure(failure) + } + } + } + + private static func permissionNotificationCategoryId(for event: WorkstreamEvent) -> String { + let source = WorkstreamSource(wireName: event.source) ?? .claude + let supportsOnce = FeedPermissionActionPolicy.supportsOncePermissionMode( + source: source, + toolInputJSON: event.toolInputJSON + ) + let supportsAlways = FeedPermissionActionPolicy.supportsAlwaysPermissionMode( + source: source, + toolInputJSON: event.toolInputJSON + ) + let supportsAll = FeedPermissionActionPolicy.supportsAllPermissionMode( + source: source, + toolInputJSON: event.toolInputJSON + ) + var suffix = "" + if supportsOnce { suffix += "Once" } + if supportsAlways { suffix += "Always" } + if supportsAll { suffix += "All" } + return suffix.isEmpty ? "CMUXFeedPermissionDeny" : "CMUXFeedPermission\(suffix)" + } + + @MainActor + func deliverFeedNotificationIfStillAwaiting( + requestId: String, + event: WorkstreamEvent, + categoryId: String, + title: String, + subtitle: String, + body: String, + effects: TerminalNotificationPolicyEffects + ) { + guard isAwaitingDecision(requestId: requestId), + effects.desktop || effects.sound || effects.command + else { return } + + if !effects.desktop { + runFallbackEffectsIfStillAwaiting( + requestId: requestId, + title: title, + subtitle: subtitle, + body: body, + effects: effects, + runCommand: true + ) + return + } + + let content = UNMutableNotificationContent() + content.title = title + content.subtitle = subtitle + content.body = body + content.sound = effects.sound ? NotificationSoundSettings.sound() : nil + content.categoryIdentifier = categoryId + content.userInfo = [ + "requestId": requestId, + "workstreamId": event.sessionId, + ] + + let request = UNNotificationRequest( + identifier: "feed.\(requestId)", + content: content, + trigger: nil + ) + + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + Task { @MainActor [weak self] in + guard let self, self.isAwaitingDecision(requestId: requestId) else { return } + switch settings.authorizationStatus { + case .authorized, .provisional: + self.addNotificationIfStillAwaiting( + center: center, + request: request, + requestId: requestId, + effects: effects + ) + case .notDetermined: + var granted = false + var requestFailed = false + do { + granted = try await center.requestAuthorization(options: [.alert, .sound]) + } catch { + requestFailed = true + } + guard self.isAwaitingDecision(requestId: requestId) else { return } + if granted { + self.addNotificationIfStillAwaiting( + center: center, + request: request, + requestId: requestId, + effects: effects + ) + } else { + // A non-grant without an error is the user declining + // the prompt just now: honor the fresh denial on this + // very notification. A request error is not a user + // decision, so the fallback stays audible (fail-open). + self.runFallbackEffectsIfStillAwaiting( + requestId: requestId, + title: title, + subtitle: subtitle, + body: body, + effects: TerminalNotificationStore.fallbackEffects( + effects, + authorizationState: requestFailed ? .unknown : .denied + ), + runCommand: false + ) + } + default: + self.runFallbackEffectsIfStillAwaiting( + requestId: requestId, + title: title, + subtitle: subtitle, + body: body, + effects: TerminalNotificationStore.fallbackEffects( + effects, + authorizationState: TerminalNotificationStore.authorizationState( + from: settings.authorizationStatus + ) + ), + runCommand: false + ) + } + } + } + } + + @MainActor + func addNotificationIfStillAwaiting( + center: UNUserNotificationCenter, + request: UNNotificationRequest, + requestId: String, + effects: TerminalNotificationPolicyEffects + ) { + guard isAwaitingDecision(requestId: requestId) else { return } + let title = request.content.title + let subtitle = request.content.subtitle + let body = request.content.body + center.add(request) { error in + let didFail = error != nil + Task { @MainActor [weak self] in + guard let self else { return } + if !self.isAwaitingDecision(requestId: requestId) { + self.cancelNotification(requestId: requestId) + return + } + if didFail { + self.runFallbackEffectsIfStillAwaiting( + requestId: requestId, + title: title, + subtitle: subtitle, + body: body, + effects: effects, + runCommand: false + ) + return + } + if effects.command { + NotificationSoundSettings.runCustomCommand( + title: title, + subtitle: subtitle, + body: body + ) + } + } + } + } + + @MainActor + func runFallbackEffectsIfStillAwaiting( + requestId: String, + title: String, + subtitle: String, + body: String, + effects: TerminalNotificationPolicyEffects, + runCommand: Bool + ) { + guard isAwaitingDecision(requestId: requestId) else { return } + NativeNotificationDeliveryHooks.runLocalFeedback( + title: title, + subtitle: subtitle, + body: body, + effects: effects, runCommand: runCommand + ) + } + + func cancelNotification(requestId: String) { + let identifier = "feed.\(requestId)" + let center = UNUserNotificationCenter.current() + center.removePendingNotificationRequestsOffMain(withIdentifiers: [identifier]) + center.removeDeliveredNotificationsOffMain(withIdentifiers: [identifier]) + } +} diff --git a/Sources/Feed/FeedCoordinator+Socket.swift b/Sources/Feed/FeedCoordinator+Socket.swift new file mode 100644 index 000000000000..672005b71b0d --- /dev/null +++ b/Sources/Feed/FeedCoordinator+Socket.swift @@ -0,0 +1,71 @@ +import CMUXAgentLaunch +import Foundation + +// MARK: - Socket-layer helpers + +extension FeedCoordinator { + /// Thread-safe snapshot of the store's items; hops to main to read + /// the observable state (only if called off-main). + func snapshot(pendingOnly: Bool) -> [WorkstreamItem] { + let body: @Sendable () -> [WorkstreamItem] = { + MainActor.assumeIsolated { + guard let store = FeedCoordinator.shared.store else { return [] } + return pendingOnly ? store.pending : store.items + } + } + if Thread.isMainThread { + return body() + } + return DispatchQueue.main.sync(execute: body) + } + + /// Parses `workstreamId` in the form `-` and + /// looks up the matching hook-session entry in + /// `~/.cmuxterm/-hook-sessions.json` (written by + /// `cmux -hook session-start`). Returns `true` if a match + /// was found so the UI can gate the jump gesture. + /// + /// Actual focus (workspace.select + surface.focus) is scheduled via + /// `FeedJumpResolver.focusIfPossible` on the main actor. + func resolvePossibleSurface(for workstreamId: String) -> Bool { + guard let parsed = FeedJumpResolver.parse(workstreamId) else { + return false + } + return FeedJumpResolver.lookup(agent: parsed.agent, sessionId: parsed.sessionId) != nil + } + + /// Fires a best-effort focus for the given `workstreamId`. Returns + /// `true` if a target was found and the focus commands were + /// dispatched. Runs on the main actor because the focus commands + /// touch AppKit state. + @MainActor + func focusIfPossible(workstreamId: String) -> Bool { + guard let parsed = FeedJumpResolver.parse(workstreamId), + let target = FeedJumpResolver.lookup( + agent: parsed.agent, sessionId: parsed.sessionId + ) + else { return false } + FeedJumpResolver.focus(workspaceId: target.workspaceId, surfaceId: target.surfaceId) + return true + } + + /// Resolves `workstreamId` to a `(workspace, surface)` pair and + /// types the user's `text` into that surface, followed by Return. + /// Used by Stop-kind cards so the user can reply to Claude from + /// the Feed without switching focus to the terminal. + @MainActor + @discardableResult + func sendTextToWorkstream(workstreamId: String, text: String) -> Bool { + guard let parsed = FeedJumpResolver.parse(workstreamId), + let target = FeedJumpResolver.lookup( + agent: parsed.agent, sessionId: parsed.sessionId + ) + else { return false } + FeedJumpResolver.sendText( + workspaceId: target.workspaceId, + surfaceId: target.surfaceId, + text: text + ) + return true + } +} diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index ecdc1988f6d2..5ada7d456f8c 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -15,6 +15,8 @@ import CmuxSidebar /// on the store and parks the socket worker on a `DispatchSemaphore` until /// the user resolves the item via `feed.*.reply` (or the timeout elapses). /// Hooks then receive the decision inline in the `feed.push` response. +// Safety: mutable UI state is MainActor-isolated, while the only worker-thread +// state lives in `FeedBlockingWaiterRegistry` behind its documented lock. final class FeedCoordinator: @unchecked Sendable { static let shared = FeedCoordinator() static let storeInstalledNotification = Notification.Name("cmux.feed.storeInstalled") @@ -23,11 +25,7 @@ final class FeedCoordinator: @unchecked Sendable { // so it hops to main explicitly when touching the store. @MainActor private(set) var store: WorkstreamStore! - /// Pending blocking-hook waiters keyed by request id. The waiter owns - /// a semaphore plus a slot for the resolved decision; the reply - /// handler signals the semaphore after filling the slot. - private let waiterLock = NSLock() - private var waiters: [String: PendingWaiter] = [:] + private let waiterRegistry = FeedBlockingWaiterRegistry() /// One kqueue-backed DispatchSource per distinct agent PID we've /// ever seen. The kernel fires `.exit` the instant the process @@ -46,7 +44,7 @@ final class FeedCoordinator: @unchecked Sendable { /// resolving a live window route after the decision has already ended. /// Main-actor isolated: read/written only from the `@MainActor` attention /// methods. - @MainActor private var pendingAttentionStates: [AttentionTarget: AttentionOverlayState] = [:] + @MainActor var pendingAttentionStates: [AttentionTarget: AttentionOverlayState] = [:] private init() {} @@ -97,38 +95,30 @@ final class FeedCoordinator: @unchecked Sendable { waitTimeout: TimeInterval ) -> IngestBlockingResult { guard let requestId = event.requestId, waitTimeout > 0 else { - DispatchQueue.main.async { - MainActor.assumeIsolated { - FeedCoordinator.shared.store.ingest(event) - if let ppid = event.ppid, ppid > 0 { - FeedCoordinator.shared.armPidWatcher(ppid: ppid) - } + Task { @MainActor in + FeedCoordinator.shared.store.ingest(event) + if let ppid = event.ppid, ppid > 0 { + FeedCoordinator.shared.armPidWatcher(ppid: ppid) } } return .acknowledged(itemId: nil) } - let semaphore = DispatchSemaphore(value: 0) - let waiter = PendingWaiter(semaphore: semaphore) - // Register the waiter before the store sees the event so a very // fast reply can't slip through. - waiterLock.lock() - waiters[requestId] = waiter - waiterLock.unlock() + let semaphore = waiterRegistry.register(requestID: requestId) // Hop to main to actually insert the item + install the // kqueue watcher for the agent's PID. The watcher handler // caps the pending lifetime to the agent process lifetime // — no polling, no leaked cards when the agent is killed. - let itemIdSlot = UnsafeItemIdSlot() let resolvedAttentionTarget = Self.isBlockingDecisionEvent(event.hookEventName) ? Self.resolveAttentionTarget(event: event) : nil - DispatchQueue.main.sync { + let itemID: UUID? = DispatchQueue.main.sync { MainActor.assumeIsolated { FeedCoordinator.shared.store.ingest(event) - itemIdSlot.value = FeedCoordinator.shared.store.items.last?.id + let itemID = FeedCoordinator.shared.store.items.last?.id if let ppid = event.ppid, ppid > 0 { FeedCoordinator.shared.armPidWatcher(ppid: ppid) } @@ -148,13 +138,15 @@ final class FeedCoordinator: @unchecked Sendable { event: event, resolved: resolvedAttentionTarget ) { - FeedCoordinator.shared.waiterLock.lock() - FeedCoordinator.shared.waiters[requestId]?.attentionTarget = target - FeedCoordinator.shared.waiterLock.unlock() + FeedCoordinator.shared.waiterRegistry.setAttentionTarget( + target, + requestID: requestId + ) } #if DEBUG FeedCoordinatorTestHooks.afterBlockingEventIngested?(event, requestId) #endif + return itemID } } @@ -166,25 +158,23 @@ final class FeedCoordinator: @unchecked Sendable { let deadline: DispatchTime = .now() + waitTimeout let waitResult = semaphore.wait(timeout: deadline) - waiterLock.lock() - let w = waiters.removeValue(forKey: requestId) - waiterLock.unlock() + let waiter = waiterRegistry.remove(requestID: requestId) switch waitResult { case .success: - if let decision = w?.decision { + if let decision = waiter?.decision { // `deliverReply` concludes the attention overlay on resolve. - return .resolved(itemId: itemIdSlot.value, decision: decision) + return .resolved(itemId: itemID, decision: decision) } cancelNotification(requestId: requestId) - concludeAttentionOnMain(w?.attentionTarget) - expireTimedOutItem(itemIdSlot.value) - return .timedOut(itemId: itemIdSlot.value) + concludeAttentionOnMain(waiter?.attentionTarget) + expireTimedOutItem(itemID) + return .timedOut(itemId: itemID) case .timedOut: cancelNotification(requestId: requestId) - concludeAttentionOnMain(w?.attentionTarget) - expireTimedOutItem(itemIdSlot.value) - return .timedOut(itemId: itemIdSlot.value) + concludeAttentionOnMain(waiter?.attentionTarget) + expireTimedOutItem(itemID) + return .timedOut(itemId: itemID) } } @@ -200,20 +190,14 @@ final class FeedCoordinator: @unchecked Sendable { if Thread.isMainThread { conclude() } else { - DispatchQueue.main.async(execute: conclude) + Task { @MainActor in conclude() } } } /// Called by the `feed.*.reply` handlers. Marks the corresponding /// item resolved on the main-actor store and wakes any waiter. func deliverReply(requestId: String, decision: WorkstreamDecision) { - waiterLock.lock() - let attentionTarget = waiters[requestId]?.attentionTarget - if let waiter = waiters[requestId] { - waiter.decision = decision - waiter.semaphore.signal() - } - waiterLock.unlock() + let attentionTarget = waiterRegistry.deliver(decision, requestID: requestId) // The user decided: conclude the needs-input overlay so the agent's // running/idle state shows through (refcounted so an overlapping @@ -232,17 +216,14 @@ final class FeedCoordinator: @unchecked Sendable { if Thread.isMainThread { resolve() } else { - DispatchQueue.main.async(execute: resolve) + Task { @MainActor in resolve() } } cancelNotification(requestId: requestId) } - fileprivate func isAwaitingDecision(requestId: String) -> Bool { - waiterLock.lock() - defer { waiterLock.unlock() } - guard let waiter = waiters[requestId] else { return false } - return waiter.decision == nil + func isAwaitingDecision(requestId: String) -> Bool { + waiterRegistry.isAwaitingDecision(requestID: requestId) } private static func findItemId( @@ -284,975 +265,3 @@ final class FeedCoordinator: @unchecked Sendable { case timedOut(itemId: UUID?) } } - -// MARK: - In-app attention surfacing - -extension FeedCoordinator { - /// The blocking-decision hook events that warrant pulling the user's - /// attention to the owning workspace: a tool permission, a plan - /// approval, or a question. Keeping this as one predicate (rather than - /// branching per event at each call site) is what makes the attention - /// surface uniform across every event type and agent routed through - /// `feed.push` — a new blocking event type only has to be added here. - static func isBlockingDecisionEvent(_ hookEventName: WorkstreamEvent.HookEventName) -> Bool { - switch hookEventName { - case .permissionRequest, .exitPlanMode, .askUserQuestion: - return true - default: - return false - } - } - - /// Maps a feed `source` (agent id) to the agent-lifecycle status key the - /// sidebar reads. Claude reports under `claude_code`; every other agent - /// keys its status by its own source name. Returning the agent's own key - /// is what lets the existing per-agent resume hooks (e.g. Claude's - /// `pre-tool-use`) clear the needs-input badge once the agent continues. - private static let lifecycleStatusKeyOverrides = [ - "claude": "claude_code", - ] - - static func lifecycleStatusKey(forSource source: String) -> String { - lifecycleStatusKeyOverrides[source] ?? source - } - - /// Identifies the sidebar slot an attention overlay lights up. Overlays - /// are refcounted by this key so overlapping blocking decisions on the - /// same agent/panel don't clear each other's needs-input badge. - struct AttentionTarget: Hashable, Sendable { - let workspaceId: UUID - let panelId: UUID? - let statusKey: String - } - - /// The localized "Needs input" sidebar status the overlay sets. Exposed so - /// ``concludeBlockingDecisionAttention(_:)`` can confirm it's still the - /// value we wrote before clearing it (rather than one an agent hook - /// replaced in the meantime). - static var needsInputStatusValue: String { - String(localized: "feed.status.needsInput", defaultValue: "Needs input") - } - - /// Surfaces in-app attention for a blocking feed decision: flips the - /// owning workspace's agent lifecycle to `.needsInput`, sets the - /// "Needs input" sidebar status, elevates the workspace when - /// *Reorder on Notification* is enabled, and rings the bell. - /// - /// This is the convergence point the PreToolUse→PermissionRequest - /// migration left behind: the `feed.push` bridge ingested the card and - /// (when inactive) posted a banner, but never drove the same in-app - /// attention path the `cmux hooks notification` hook uses. Doing - /// it here — once, for every blocking decision — keeps a new event type - /// from silently swallowing. - /// - /// The overlay is cleared by ``concludeBlockingDecisionAttention(_:)`` - /// when the decision resolves or times out. Clearing is refcounted per - /// ``AttentionTarget`` so overlapping decisions on the same panel keep the - /// badge lit until the last one concludes. - /// - /// - Parameter resolved: the target resolved off the main actor before UI - /// mutation, since hook-session lookup may read from disk. - /// - Returns: the target to conclude once the decision ends, or `nil` if - /// nothing was surfaced (no resolvable workspace). - @MainActor - func surfaceBlockingDecisionAttention( - event: WorkstreamEvent, - resolved: (workspaceId: UUID, surfaceId: UUID?)? - ) -> AttentionTarget? { - guard Self.isBlockingDecisionEvent(event.hookEventName) else { return nil } - - #if DEBUG - if let observer = FeedCoordinatorTestHooks.attentionSurfaceObserver { - observer(event) - return nil - } - #endif - - guard let resolved else { - #if DEBUG - cmuxDebugLog( - "feed.attention.skip reason=unresolved-target session=\(event.sessionId) request=\(event.requestId ?? "nil") hook=\(event.hookEventName.rawValue) source=\(event.source) workspace=\(event.workspaceId ?? "nil") receivedAt=\(event.receivedAt.timeIntervalSince1970)" - ) - #endif - return nil - } - - guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: resolved.workspaceId), - let tab = tabManager.tabs.first(where: { $0.id == resolved.workspaceId }) - else { - #if DEBUG - cmuxDebugLog( - "feed.attention.skip reason=missing-workspace session=\(event.sessionId) request=\(event.requestId ?? "nil") hook=\(event.hookEventName.rawValue) source=\(event.source) workspace=\(resolved.workspaceId.uuidString) receivedAt=\(event.receivedAt.timeIntervalSince1970)" - ) - #endif - return nil - } - - let panelId = Self.resolvePanelId(surfaceId: resolved.surfaceId, tab: tab) ?? tab.focusedPanelId - let statusKey = Self.lifecycleStatusKey(forSource: event.source) - let target = AttentionTarget( - workspaceId: resolved.workspaceId, - panelId: panelId, - statusKey: statusKey - ) - let attentionState = pendingAttentionStates[target] ?? AttentionOverlayState(workspace: tab) - attentionState.workspace = tab - attentionState.count += 1 - pendingAttentionStates[target] = attentionState - - // Needs-input lifecycle drives the sidebar badge + hibernation state. - tab.setAgentLifecycle(key: statusKey, panelId: panelId, lifecycle: .needsInput) - tab.statusEntries[statusKey] = SidebarStatusEntry( - key: statusKey, - value: Self.needsInputStatusValue, - icon: "bell.fill", - color: "#4C8DFF", - timestamp: Date() - ) - - // Elevate the workspace so it floats to the top of the sidebar, - // honoring the user's Reorder on Notification preference. - if UserDefaultsSettingsClient(defaults: .standard).value(for: SettingCatalog().app.reorderOnNotification) { - tabManager.moveTabToTopForNotification(resolved.workspaceId) - } - - // Ring the bell (dock bounce while the app is in the background). - NSApp.requestUserAttention(.informationalRequest) - - return target - } - - /// Concludes a blocking decision's attention overlay. Decrements the - /// per-target refcount and, when it reaches zero, clears the needs-input - /// overlay — but only the parts the feed still owns: the lifecycle is set - /// to `.running` only if it's still `.needsInput`, and the status entry is - /// removed only if it still holds our "Needs input" value. Anything an - /// agent hook replaced in the meantime is left untouched, so a real - /// running/idle/needs-input update from the agent always wins. - @MainActor - func concludeBlockingDecisionAttention(_ target: AttentionTarget) { - guard let attentionState = pendingAttentionStates[target] else { return } - if attentionState.count > 1 { - attentionState.count -= 1 - return - } - pendingAttentionStates.removeValue(forKey: target) - let tab = attentionState.workspace - - // Lifecycle is per-panel, so clearing this panel's needs-input is - // safe even if another panel still needs input. - if let panelId = target.panelId, - tab.agentLifecycleStatesByPanelId[panelId]?[target.statusKey] == .needsInput { - tab.setAgentLifecycle(key: target.statusKey, panelId: panelId, lifecycle: .running) - } - - // The status entry is workspace-level (keyed only by statusKey), so it - // is shared across panels running the same agent. Only remove it once - // no other panel in this workspace still has a pending decision under - // the same key — otherwise concluding one panel would wipe another - // panel's active "Needs input" badge. - let anotherPanelStillPending = pendingAttentionStates.keys.contains { - $0.workspaceId == target.workspaceId && $0.statusKey == target.statusKey - } - if !anotherPanelStillPending, - tab.statusEntries[target.statusKey]?.value == Self.needsInputStatusValue { - tab.statusEntries.removeValue(forKey: target.statusKey) - } - } - - /// Resolves the `(workspace, surface)` an attention overlay should target. - /// The workspace prefers the event's live `workspace_id` (the running - /// terminal's CMUX_WORKSPACE_ID, a raw UUID) so a stale hook-session map - /// can't redirect attention to the wrong workspace; it falls back to the - /// session store when the event omits a parseable id. The surface comes - /// from the session store only when its workspace matches the resolved - /// workspace, so a stale entry can't point the panel elsewhere. - private static func resolveAttentionTarget( - event: WorkstreamEvent - ) -> (workspaceId: UUID, surfaceId: UUID?)? { - let sessionMatch: (workspaceId: UUID, surfaceId: UUID?)? = { - guard let parsed = FeedJumpResolver.parse(event.sessionId), - let resolved = FeedJumpResolver.lookup(agent: parsed.agent, sessionId: parsed.sessionId), - let workspaceId = UUID(uuidString: resolved.workspaceId) - else { return nil } - return (workspaceId, UUID(uuidString: resolved.surfaceId)) - }() - - let eventWorkspaceId = event.workspaceId.flatMap { - UUID(uuidString: $0.trimmingCharacters(in: .whitespacesAndNewlines)) - } - - guard let workspaceId = eventWorkspaceId ?? sessionMatch?.workspaceId else { - return nil - } - // Only trust the session store's surface if it belongs to the - // workspace we're actually targeting. - let surfaceId = (sessionMatch?.workspaceId == workspaceId) ? sessionMatch?.surfaceId : nil - return (workspaceId, surfaceId) - } - - /// Maps a surface id from the hook-session store to its owning panel id, - /// tolerating stores that already record the panel id directly. - @MainActor - private static func resolvePanelId(surfaceId: UUID?, tab: Workspace) -> UUID? { - guard let surfaceId else { return nil } - if tab.panels[surfaceId] != nil { return surfaceId } - return tab.panelIdFromSurfaceId(TabID(uuid: surfaceId)) - } -} - -@MainActor -private final class AttentionOverlayState { - var count: Int - var workspace: Workspace - - init(workspace: Workspace) { - self.count = 0 - self.workspace = workspace - } -} - -private final class PendingWaiter: @unchecked Sendable { - let semaphore: DispatchSemaphore - var decision: WorkstreamDecision? - /// The attention overlay target for this decision, if one was surfaced. - /// Set inside the ingest `main.sync` (before the card can render and a - /// reply can fire) and read when the decision concludes, so the - /// needs-input overlay is cleared exactly once. Guarded by - /// `FeedCoordinator.waiterLock`. - var attentionTarget: FeedCoordinator.AttentionTarget? - - init(semaphore: DispatchSemaphore) { - self.semaphore = semaphore - } -} - -/// Tiny box so the `DispatchQueue.main.sync` closure can mutate an -/// `UUID?` without a capture warning. -private final class UnsafeItemIdSlot: @unchecked Sendable { - var value: UUID? -} - -private final class SnapshotSlot: @unchecked Sendable { - var value: [WorkstreamItem] = [] -} - -#if DEBUG -@MainActor -enum FeedCoordinatorTestHooks { - static var afterBlockingEventIngested: (@Sendable (WorkstreamEvent, String) -> Void)? - static var isAppActiveOverride: (@Sendable () -> Bool)? - static var notificationPostObserver: (@Sendable (WorkstreamEvent, String) -> Void)? - /// Fires when a blocking decision event requests in-app attention - /// surfacing (needs-input status + bell + elevation). When set, the - /// production surfacing is short-circuited so tests can assert the - /// request without a live `TabManager`. - static var attentionSurfaceObserver: (@Sendable (WorkstreamEvent) -> Void)? -} -#endif - -// MARK: - Socket-layer helpers - -extension FeedCoordinator { - /// Thread-safe snapshot of the store's items; hops to main to read - /// the observable state (only if called off-main). - func snapshot(pendingOnly: Bool) -> [WorkstreamItem] { - let slot = SnapshotSlot() - let body: @Sendable () -> Void = { [slot] in - MainActor.assumeIsolated { - guard let store = FeedCoordinator.shared.store else { return } - slot.value = pendingOnly ? store.pending : store.items - } - } - if Thread.isMainThread { - body() - } else { - DispatchQueue.main.sync(execute: body) - } - return slot.value - } - - /// Parses `workstreamId` in the form `-` and - /// looks up the matching hook-session entry in - /// `~/.cmuxterm/-hook-sessions.json` (written by - /// `cmux -hook session-start`). Returns `true` if a match - /// was found so the UI can gate the jump gesture. - /// - /// Actual focus (workspace.select + surface.focus) is scheduled via - /// `FeedJumpResolver.focusIfPossible` on the main actor. - func resolvePossibleSurface(for workstreamId: String) -> Bool { - guard let parsed = FeedJumpResolver.parse(workstreamId) else { - return false - } - return FeedJumpResolver.lookup(agent: parsed.agent, sessionId: parsed.sessionId) != nil - } - - /// Fires a best-effort focus for the given `workstreamId`. Returns - /// `true` if a target was found and the focus commands were - /// dispatched. Runs on the main actor because the focus commands - /// touch AppKit state. - @MainActor - func focusIfPossible(workstreamId: String) -> Bool { - guard let parsed = FeedJumpResolver.parse(workstreamId), - let target = FeedJumpResolver.lookup( - agent: parsed.agent, sessionId: parsed.sessionId - ) - else { return false } - FeedJumpResolver.focus(workspaceId: target.workspaceId, surfaceId: target.surfaceId) - return true - } - - /// Resolves `workstreamId` to a `(workspace, surface)` pair and - /// types the user's `text` into that surface, followed by Return. - /// Used by Stop-kind cards so the user can reply to Claude from - /// the Feed without switching focus to the terminal. - @MainActor - @discardableResult - func sendTextToWorkstream(workstreamId: String, text: String) -> Bool { - guard let parsed = FeedJumpResolver.parse(workstreamId), - let target = FeedJumpResolver.lookup( - agent: parsed.agent, sessionId: parsed.sessionId - ) - else { return false } - FeedJumpResolver.sendText( - workspaceId: target.workspaceId, - surfaceId: target.surfaceId, - text: text - ) - return true - } -} - -/// Reads the per-agent hook session stores (`~/.cmuxterm/-hook-sessions.json`) -/// to map a feed `workstream_id` back to a cmux `(workspaceId, surfaceId)` pair. -/// The schema is the same one written by `cmux -hook session-start`. -enum FeedJumpResolver { - struct Target: Equatable { - let workspaceId: String - let surfaceId: String - } - - static func parse(_ workstreamId: String) -> (agent: String, sessionId: String)? { - guard let dash = workstreamId.firstIndex(of: "-") else { return nil } - let agent = String(workstreamId[.. Target? { - let home = FileManager.default.homeDirectoryForCurrentUser - let file = home - .appendingPathComponent(".cmuxterm", isDirectory: true) - .appendingPathComponent("\(agent)-hook-sessions.json", isDirectory: false) - guard let data = try? Data(contentsOf: file), - let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { return nil } - // Stores have a consistent shape: top-level `sessions` dict keyed - // by sessionId. Tolerate older flat layouts too. - let sessions: [String: Any] - if let nested = root["sessions"] as? [String: Any] { - sessions = nested - } else { - sessions = root - } - guard let entry = sessions[sessionId] as? [String: Any], - let workspaceId = entry["workspaceId"] as? String, - let surfaceId = entry["surfaceId"] as? String, - !workspaceId.isEmpty, !surfaceId.isEmpty - else { return nil } - return Target(workspaceId: workspaceId, surfaceId: surfaceId) - } - - /// Dispatches a workspace-select + surface-focus intent. Posts - /// through the existing cmux notification pathway so we don't need - /// to bind directly to the TerminalController V2 handlers from the - /// Feed layer. - @MainActor - static func focus(workspaceId: String, surfaceId: String) { - NotificationCenter.default.post( - name: .feedRequestFocus, - object: nil, - userInfo: [ - "workspaceId": workspaceId, - "surfaceId": surfaceId, - ] - ) - } - - /// Dispatches a surface.send_text intent for the agent's terminal. - /// The observer in AppDelegate translates it into the V2 socket - /// call so the Feed stays decoupled from TerminalController. - @MainActor - static func sendText(workspaceId: String, surfaceId: String, text: String) { - NotificationCenter.default.post( - name: .feedRequestSendText, - object: nil, - userInfo: [ - "workspaceId": workspaceId, - "surfaceId": surfaceId, - "text": text, - ] - ) - } -} - -extension Notification.Name { - static let feedRequestFocus = Notification.Name("cmux.feedRequestFocus") - static let feedRequestSendText = Notification.Name("cmux.feedRequestSendText") -} - -// MARK: - Native notification banner - -private extension FeedCoordinator { - /// Posts a UNUserNotificationCenter banner with inline action buttons - /// for the given Feed event after optional notification policy hooks run. - /// Notification eligibility is derived only from the waiter table so - /// resolved/timed-out requests cannot enqueue stale banners while the main - /// queue, policy hooks, or notification center catches up. - func postNotificationIfStillAwaiting(event: WorkstreamEvent, requestId: String) { - Task { @MainActor [weak self] in - guard let self, self.isAwaitingDecision(requestId: requestId) else { - return - } - - #if DEBUG - let isAppActive = FeedCoordinatorTestHooks.isAppActiveOverride?() ?? NSApp.isActive - #else - let isAppActive = NSApp.isActive - #endif - - // Don't pester users while the app is already up front. - if isAppActive { - return - } - - #if DEBUG - if let observer = FeedCoordinatorTestHooks.notificationPostObserver { - observer(event, requestId) - return - } - #endif - - let categoryId: String - let title: String - let body: String - switch event.hookEventName { - case .permissionRequest: - categoryId = Self.permissionNotificationCategoryId(for: event) - title = String( - localized: "feed.notification.permission.title", - defaultValue: "\(event.source.capitalized) permission" - ) - body = event.toolName.map { - String( - localized: "feed.notification.permission.body", - defaultValue: "\($0) needs approval" - ) - } ?? String( - localized: "feed.notification.decisionNeeded", - defaultValue: "Decision needed" - ) - case .exitPlanMode: - categoryId = "CMUXFeedExitPlan" - title = String( - localized: "feed.notification.exitPlan.title", - defaultValue: "\(event.source.capitalized) plan ready" - ) - body = String( - localized: "feed.notification.exitPlan.body", - defaultValue: "Review and approve the plan" - ) - case .askUserQuestion: - categoryId = "CMUXFeedQuestion" - title = String( - localized: "feed.notification.question.title", - defaultValue: "\(event.source.capitalized) question" - ) - body = String( - localized: "feed.notification.question.body", - defaultValue: "Agent is asking a question" - ) - default: - return - } - - let policyContext = makeFeedNotificationPolicyContext( - event: event, - title: title, - body: body - ) - let deliverDefault = { [weak self] in - self?.deliverFeedNotificationIfStillAwaiting( - requestId: requestId, - event: event, - categoryId: categoryId, - title: title, - subtitle: "", - body: body, - effects: policyContext.envelope.effects - ) - } - - guard !policyContext.hooks.isEmpty else { - deliverDefault() - return - } - - let authorizedHooks = await NotificationPolicyHookAuthorizer.authorize( - policyContext.hooks, - globalConfigPath: policyContext.globalConfigPath - ) - guard self.isAwaitingDecision(requestId: requestId) else { return } - guard !authorizedHooks.isEmpty else { - deliverDefault() - return - } - - let result = await TerminalNotificationPolicyEngine.evaluate( - envelope: policyContext.envelope, - hooks: authorizedHooks - ) - guard self.isAwaitingDecision(requestId: requestId) else { return } - switch result { - case .success(let envelope): - let payload = envelope.notification - self.deliverFeedNotificationIfStillAwaiting( - requestId: requestId, - event: event, - categoryId: categoryId, - title: payload.title, - subtitle: payload.subtitle, - body: payload.body, - effects: envelope.effects - ) - case .failure(let failure): - deliverDefault() - TerminalNotificationStore.shared.reportNotificationHookFailure(failure) - } - } - } - - private static func permissionNotificationCategoryId(for event: WorkstreamEvent) -> String { - let source = WorkstreamSource(wireName: event.source) ?? .claude - let supportsOnce = FeedPermissionActionPolicy.supportsOncePermissionMode( - source: source, - toolInputJSON: event.toolInputJSON - ) - let supportsAlways = FeedPermissionActionPolicy.supportsAlwaysPermissionMode( - source: source, - toolInputJSON: event.toolInputJSON - ) - let supportsAll = FeedPermissionActionPolicy.supportsAllPermissionMode( - source: source, - toolInputJSON: event.toolInputJSON - ) - var suffix = "" - if supportsOnce { suffix += "Once" } - if supportsAlways { suffix += "Always" } - if supportsAll { suffix += "All" } - return suffix.isEmpty ? "CMUXFeedPermissionDeny" : "CMUXFeedPermission\(suffix)" - } - - @MainActor - func deliverFeedNotificationIfStillAwaiting( - requestId: String, - event: WorkstreamEvent, - categoryId: String, - title: String, - subtitle: String, - body: String, - effects: TerminalNotificationPolicyEffects - ) { - guard isAwaitingDecision(requestId: requestId), - effects.desktop || effects.sound || effects.command - else { return } - - if !effects.desktop { - runFallbackEffectsIfStillAwaiting( - requestId: requestId, - title: title, - subtitle: subtitle, - body: body, - effects: effects, - runCommand: true - ) - return - } - - let content = UNMutableNotificationContent() - content.title = title - content.subtitle = subtitle - content.body = body - content.sound = effects.sound ? NotificationSoundSettings.sound() : nil - content.categoryIdentifier = categoryId - content.userInfo = [ - "requestId": requestId, - "workstreamId": event.sessionId, - ] - - let request = UNNotificationRequest( - identifier: "feed.\(requestId)", - content: content, - trigger: nil - ) - - let center = UNUserNotificationCenter.current() - center.getNotificationSettings { settings in - Task { @MainActor [weak self] in - guard let self, self.isAwaitingDecision(requestId: requestId) else { return } - switch settings.authorizationStatus { - case .authorized, .provisional: - self.addNotificationIfStillAwaiting( - center: center, - request: request, - requestId: requestId, - effects: effects - ) - case .notDetermined: - var granted = false - var requestFailed = false - do { - granted = try await center.requestAuthorization(options: [.alert, .sound]) - } catch { - requestFailed = true - } - guard self.isAwaitingDecision(requestId: requestId) else { return } - if granted { - self.addNotificationIfStillAwaiting( - center: center, - request: request, - requestId: requestId, - effects: effects - ) - } else { - // A non-grant without an error is the user declining - // the prompt just now: honor the fresh denial on this - // very notification. A request error is not a user - // decision, so the fallback stays audible (fail-open). - self.runFallbackEffectsIfStillAwaiting( - requestId: requestId, - title: title, - subtitle: subtitle, - body: body, - effects: TerminalNotificationStore.fallbackEffects( - effects, - authorizationState: requestFailed ? .unknown : .denied - ), - runCommand: false - ) - } - default: - self.runFallbackEffectsIfStillAwaiting( - requestId: requestId, - title: title, - subtitle: subtitle, - body: body, - effects: TerminalNotificationStore.fallbackEffects( - effects, - authorizationState: TerminalNotificationStore.authorizationState( - from: settings.authorizationStatus - ) - ), - runCommand: false - ) - } - } - } - } - - @MainActor - func addNotificationIfStillAwaiting( - center: UNUserNotificationCenter, - request: UNNotificationRequest, - requestId: String, - effects: TerminalNotificationPolicyEffects - ) { - guard isAwaitingDecision(requestId: requestId) else { return } - let title = request.content.title - let subtitle = request.content.subtitle - let body = request.content.body - center.add(request) { error in - let didFail = error != nil - Task { @MainActor [weak self] in - guard let self else { return } - if !self.isAwaitingDecision(requestId: requestId) { - self.cancelNotification(requestId: requestId) - return - } - if didFail { - self.runFallbackEffectsIfStillAwaiting( - requestId: requestId, - title: title, - subtitle: subtitle, - body: body, - effects: effects, - runCommand: false - ) - return - } - if effects.command { - NotificationSoundSettings.runCustomCommand( - title: title, - subtitle: subtitle, - body: body - ) - } - } - } - } - - @MainActor - func runFallbackEffectsIfStillAwaiting( - requestId: String, - title: String, - subtitle: String, - body: String, - effects: TerminalNotificationPolicyEffects, - runCommand: Bool - ) { - guard isAwaitingDecision(requestId: requestId) else { return } - NativeNotificationDeliveryHooks.runLocalFeedback( - title: title, - subtitle: subtitle, - body: body, - effects: effects, runCommand: runCommand - ) - } - - func cancelNotification(requestId: String) { - let identifier = "feed.\(requestId)" - let center = UNUserNotificationCenter.current() - center.removePendingNotificationRequestsOffMain(withIdentifiers: [identifier]) - center.removeDeliveredNotificationsOffMain(withIdentifiers: [identifier]) - } -} - -private struct FeedNotificationPolicyContext { - let envelope: TerminalNotificationPolicyEnvelope - let hooks: [CmuxResolvedNotificationHook] - let globalConfigPath: String? -} - -@MainActor -private func makeFeedNotificationPolicyContext( - event: WorkstreamEvent, - title: String, - body: String -) -> FeedNotificationPolicyContext { - let appDelegate = AppDelegate.shared - let workspaceID = event.workspaceId.flatMap(UUID.init(uuidString:)) - let context = workspaceID.flatMap { appDelegate?.contextContainingTabId($0) } - ?? appDelegate?.mainWindowContexts.values.first(where: { $0.cmuxConfigStore != nil }) - let workspace = workspaceID.flatMap { id in - context?.tabManager.tabs.first(where: { $0.id == id }) - } - let cwd = normalizedFeedNotificationCWD(event.cwd) - ?? workspace?.surfaceTabBarDirectory - ?? workspace?.currentDirectory - ?? FileManager.default.homeDirectoryForCurrentUser.path - var effects = TerminalNotificationPolicyEffects() - effects.desktop = true - effects.record = false - effects.markUnread = false - effects.reorderWorkspace = false - effects.sound = false - effects.command = false - effects.paneFlash = false - - return FeedNotificationPolicyContext( - envelope: TerminalNotificationPolicyEnvelope( - notification: TerminalNotificationPolicyPayload( - workspaceId: event.workspaceId ?? event.sessionId, - surfaceId: nil, - title: title, - subtitle: "", - body: body - ), - context: TerminalNotificationPolicyContext( - cwd: cwd, - configPath: nil, - hookId: nil, - appFocused: AppFocusState.isAppFocused(), - focusedPanel: false - ), - effects: effects - ), - hooks: context?.cmuxConfigStore?.notificationHooks(startingFrom: workspace?.isRemoteWorkspace == true ? nil : (normalizedFeedNotificationCWD(event.cwd) ?? workspace?.surfaceTabBarDirectory)) ?? [], - globalConfigPath: context?.cmuxConfigStore?.globalConfigPath - ) -} - -private func normalizedFeedNotificationCWD(_ cwd: String?) -> String? { - guard let cwd else { return nil } - let trimmed = cwd.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed -} - -/// JSON-shape helpers used by the V2 `feed.*` socket handlers. -enum FeedSocketEncoding { - private static let primaryTextLimit = 8_000 - private static let secondaryTextLimit = 2_000 - - static func payload(for result: FeedCoordinator.IngestBlockingResult) -> [String: Any] { - switch result { - case .acknowledged(let itemId): - var dict: [String: Any] = ["status": "acknowledged"] - if let itemId { dict["item_id"] = itemId.uuidString } - return dict - case .resolved(let itemId, let decision): - var dict: [String: Any] = [ - "status": "resolved", - "decision": decisionDict(decision) - ] - if let itemId { dict["item_id"] = itemId.uuidString } - return dict - case .timedOut(let itemId): - var dict: [String: Any] = ["status": "timed_out"] - if let itemId { dict["item_id"] = itemId.uuidString } - return dict - } - } - - static func decisionDict(_ decision: WorkstreamDecision) -> [String: Any] { - switch decision { - case .permission(let mode): - return ["kind": "permission", "mode": mode.rawValue] - case .exitPlan(let mode, let feedback): - var dict: [String: Any] = ["kind": "exit_plan", "mode": mode.rawValue] - if let feedback, !feedback.isEmpty { - dict["feedback"] = feedback - } - return dict - case .question(let selections): - return ["kind": "question", "selections": selections] - } - } - - private static func limitedText(_ value: String, limit: Int) -> (text: String, truncated: Bool) { - guard value.count > limit else { return (value, false) } - let end = value.index(value.startIndex, offsetBy: max(limit - 3, 0)) - return (String(value[.. [String: Any] { - var dict: [String: Any] = [ - "id": question.id, - "multi_select": question.multiSelect, - ] - if let header = question.header { - assignLimitedText(header, key: "header", to: &dict, limit: secondaryTextLimit) - } - assignLimitedText(question.prompt, key: "prompt", to: &dict, limit: primaryTextLimit) - dict["options"] = question.options.map { option in - var optionDict: [String: Any] = [ - "id": option.id, - "label": limitedText(option.label, limit: secondaryTextLimit).text, - ] - if let description = option.description { - assignLimitedText(description, key: "description", to: &optionDict, limit: secondaryTextLimit) - } - return optionDict - } - return dict - } - - static func itemDict(_ item: WorkstreamItem) -> [String: Any] { - let isoFormatter = ISO8601DateFormatter() - var dict: [String: Any] = [ - "id": item.id.uuidString, - "workstream_id": item.workstreamId, - "source": item.source.rawValue, - "kind": item.kind.rawValue, - "created_at": isoFormatter.string(from: item.createdAt), - "updated_at": isoFormatter.string(from: item.updatedAt), - ] - if let cwd = item.cwd { dict["cwd"] = cwd } - if let title = item.title { dict["title"] = title } - switch item.status { - case .pending: - dict["status"] = "pending" - case .resolved(let decision, let at): - dict["status"] = "resolved" - dict["decision"] = decisionDict(decision) - dict["resolved_at"] = isoFormatter.string(from: at) - case .expired(let at): - dict["status"] = "expired" - dict["resolved_at"] = isoFormatter.string(from: at) - case .telemetry: - dict["status"] = "telemetry" - } - switch item.payload { - case .permissionRequest(let requestId, let toolName, let toolInputJSON, let pattern): - dict["request_id"] = requestId - dict["tool_name"] = toolName - if let capabilityJSON = FeedPermissionActionPolicy.codexCapabilityToolInputJSON( - source: item.source, - toolInputJSON: toolInputJSON - ) { - dict["tool_input_capabilities"] = capabilityJSON - } - assignLimitedText(toolInputJSON, key: "tool_input", to: &dict) - if let pattern { dict["pattern"] = pattern } - case .exitPlan(let requestId, let plan, let defaultMode): - dict["request_id"] = requestId - assignLimitedText(plan, key: "plan", to: &dict) - dict["plan_summary"] = plan.components(separatedBy: .newlines) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .first { !$0.isEmpty } - dict["default_mode"] = defaultMode.rawValue - case .question(let requestId, let questions): - dict["request_id"] = requestId - dict["questions"] = questions.map(questionDict) - if let firstQuestion = questions.first { - assignLimitedText(firstQuestion.prompt, key: "question_prompt", to: &dict) - dict["question_multi_select"] = firstQuestion.multiSelect - dict["question_options"] = firstQuestion.options.map { option in - var optionDict: [String: Any] = [ - "id": option.id, - "label": limitedText(option.label, limit: secondaryTextLimit).text, - ] - if let description = option.description { - assignLimitedText(description, key: "description", to: &optionDict, limit: secondaryTextLimit) - } - return optionDict - } - } - case .toolUse(let toolName, let toolInputJSON): - dict["tool_name"] = toolName - assignLimitedText(toolInputJSON, key: "tool_input", to: &dict) - case .toolResult(let toolName, let resultJSON, let isError): - dict["tool_name"] = toolName - assignLimitedText(resultJSON, key: "tool_result", to: &dict) - dict["tool_result_is_error"] = isError - case .userPrompt(let text), .assistantMessage(let text): - assignLimitedText(text, key: "text", to: &dict) - case .sessionStart, .sessionEnd: - break - case .stop(let reason): - if let reason { assignLimitedText(reason, key: "reason", to: &dict, limit: secondaryTextLimit) } - case .todos(let todos): - dict["todos"] = todos.map { todo in - [ - "id": todo.id, - "content": limitedText(todo.content, limit: secondaryTextLimit).text, - "state": todo.state.rawValue, - ] - } - } - return dict - } -} diff --git a/Sources/Feed/FeedCoordinatorState.swift b/Sources/Feed/FeedCoordinatorState.swift new file mode 100644 index 000000000000..56cc92e1e5b3 --- /dev/null +++ b/Sources/Feed/FeedCoordinatorState.swift @@ -0,0 +1,27 @@ +import CMUXAgentLaunch +import Foundation + +@MainActor +final class AttentionOverlayState { + var count: Int + var workspace: Workspace + + init(workspace: Workspace) { + self.count = 0 + self.workspace = workspace + } +} + +#if DEBUG +@MainActor +enum FeedCoordinatorTestHooks { + static var afterBlockingEventIngested: (@Sendable (WorkstreamEvent, String) -> Void)? + static var isAppActiveOverride: (@Sendable () -> Bool)? + static var notificationPostObserver: (@Sendable (WorkstreamEvent, String) -> Void)? + /// Fires when a blocking decision event requests in-app attention + /// surfacing (needs-input status + bell + elevation). When set, the + /// production surfacing is short-circuited so tests can assert the + /// request without a live `TabManager`. + static var attentionSurfaceObserver: (@Sendable (WorkstreamEvent) -> Void)? +} +#endif diff --git a/Sources/Feed/FeedExitPlanView.swift b/Sources/Feed/FeedExitPlanView.swift new file mode 100644 index 000000000000..97ffeaae424c --- /dev/null +++ b/Sources/Feed/FeedExitPlanView.swift @@ -0,0 +1,414 @@ +import AppKit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +struct ExitPlanActionArea: View { + let plan: String + let source: WorkstreamSource + let status: WorkstreamStatus + let isRowSelected: Bool + let onFocusRow: () -> Void + let onActionRow: () -> Void + let onBlurRow: () -> Void + let onApprove: (WorkstreamExitPlanMode, String?) -> Void + + @State private var feedback: String = "" + @FocusState private var feedbackFocused: Bool + + private var trimmedFeedback: String { + feedback.trimmingCharacters(in: .whitespacesAndNewlines) + } + private var hasFeedback: Bool { !trimmedFeedback.isEmpty } + private var preview: WorkstreamExitPlanPreview { + WorkstreamExitPlanPreview(rawPlan: plan) + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + PlanBodyView( + plan: preview.planText, + rendersMarkdown: source == .claude + ) + if !preview.allowedPrompts.isEmpty { + ExitPlanAllowedPromptsView(prompts: preview.allowedPrompts) + } + if let path = preview.planFilePath { + ExitPlanPlanFileView(path: path) + } + if status.isPending { + TextField( + String( + localized: "feed.exitplan.feedback.placeholder", + defaultValue: "Tell Claude what to change…" + ), + text: $feedback, + axis: .vertical + ) + .textFieldStyle(.plain) + .cmuxFont(size: 12) + .tint(Color.primary.opacity(0.75)) + .focused($feedbackFocused) + .lineLimit(2...5) + .padding(10) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(feedbackFocused ? 0.075 : 0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke( + Color.primary.opacity(feedbackFocused ? 0.20 : (hasFeedback ? 0.25 : 0.10)), + lineWidth: 1 + ) + ) + .onChange(of: feedbackFocused) { _, focused in + if focused { + onFocusRow() + } else { + onBlurRow() + } + } + HStack(spacing: 6) { + FeedButton( + label: hasFeedback + ? String(localized: "feed.exitplan.refine", + defaultValue: "Send feedback") + : String(localized: "feed.exitplan.ultraplan", + defaultValue: "Ultraplan"), + kind: hasFeedback ? .primary : .soft, + size: .medium, fullWidth: true + ) { + feedbackFocused = false + onActionRow() + // Feedback always wins over mode; hook translates + // non-empty feedback into block+reason. + onApprove(hasFeedback ? .manual : .ultraplan, hasFeedback ? trimmedFeedback : nil) + } + FeedButton( + label: String(localized: "feed.exitplan.manual", + defaultValue: "Manual"), + kind: .soft, + size: .medium, fullWidth: true, + dimmed: hasFeedback + ) { + feedbackFocused = false + onActionRow() + onApprove(.manual, hasFeedback ? trimmedFeedback : nil) + } + FeedButton( + label: String(localized: "feed.exitplan.auto", + defaultValue: "Auto"), + kind: .success, + size: .medium, fullWidth: true, + dimmed: hasFeedback + ) { + feedbackFocused = false + onActionRow() + onApprove(.autoAccept, hasFeedback ? trimmedFeedback : nil) + } + } + } else if let badge = submittedBadge { + FeedButton( + label: badge, + leadingIcon: "checkmark", + kind: .success, + size: .medium, + fullWidth: true, + dimmed: true + ) {} + } + } + .onChange(of: isRowSelected) { _, selected in + if !selected { + feedbackFocused = false + } + } + } + + private var submittedBadge: String? { + guard case .resolved(let decision, _) = status else { return nil } + let submitted = String(localized: "feed.badge.submitted", defaultValue: "Submitted") + switch decision { + case .exitPlan(let mode, let feedback): + if let feedback, !feedback.isEmpty { + return "\(submitted) · " + String( + localized: "feed.badge.refined", defaultValue: "refined" + ) + } + return "\(submitted) · \(mode.displayLabel)" + default: + return submitted + } + } +} + +private struct ExitPlanAllowedPromptsView: View { + let prompts: [WorkstreamAllowedPrompt] + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 5) { + Image(systemName: "checklist") + .cmuxFont(size: 10, weight: .medium) + .foregroundColor(Color.purple.opacity(0.85)) + Text(String(localized: "feed.exitplan.allowedPrompts", defaultValue: "Allowed prompts")) + .cmuxFont(size: 11, weight: .semibold) + .foregroundColor(Color.purple.opacity(0.9)) + } + VStack(alignment: .leading, spacing: 5) { + ForEach(Array(prompts.enumerated()), id: \.offset) { _, prompt in + HStack(alignment: .firstTextBaseline, spacing: 6) { + if !prompt.tool.isEmpty { + Text(prompt.tool) + .cmuxFont(size: 10, weight: .semibold) + .foregroundColor(.purple) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.purple.opacity(0.14)) + ) + } + Text(prompt.prompt) + .cmuxFont(size: 11) + .foregroundColor(.primary.opacity(0.86)) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.purple.opacity(0.06)) + ) + } + } +} + +private struct ExitPlanPlanFileView: View { + let path: String + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: "doc.text") + .cmuxFont(size: 10, weight: .medium) + .foregroundColor(.secondary) + Text(String(localized: "feed.exitplan.planFile", defaultValue: "Plan file")) + .cmuxFont(size: 10, weight: .semibold) + .foregroundColor(.secondary) + Text((path as NSString).lastPathComponent) + .cmuxFont(size: 10, design: .monospaced) + .foregroundColor(.secondary.opacity(0.85)) + .lineLimit(1) + .truncationMode(.middle) + .help(path) + } + } +} + +struct FeedMarkdownInlineText: View { + let text: String + let fontSize: CGFloat + let weight: Font.Weight? + let foregroundColor: Color + + init( + text: String, + fontSize: CGFloat, + weight: Font.Weight? = nil, + foregroundColor: Color + ) { + self.text = text + self.fontSize = fontSize + self.weight = weight + self.foregroundColor = foregroundColor + } + + var body: some View { + let parsed = (try? AttributedString( + markdown: text, + options: AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + )) ?? AttributedString(text) + Text(parsed) + .cmuxFont(size: fontSize, weight: weight ?? .regular) + .foregroundColor(foregroundColor) + .fixedSize(horizontal: false, vertical: true) + } +} + +/// Renders plan text as a stack of small structured sections. Block +/// headings, lists, and paragraphs keep the Feed's compact rhythm, while +/// Claude markdown inside each line gets parsed tastefully. Heading text +/// intentionally stays at body scale. +private struct PlanBodyView: View { + let plan: String + let rendersMarkdown: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + switch block { + case .heading(let text): + markdownText( + text, + weight: .semibold, + color: .primary.opacity(0.95) + ) + .padding(.top, 2) + case .paragraph(let text): + markdownText(text, color: .primary.opacity(0.85)) + case .numbered(let items): + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + HStack(alignment: .top, spacing: 5) { + Text("\(item.index).") + .cmuxFont(size: 11, weight: .medium, monospacedDigit: true) + .foregroundColor(.secondary) + markdownText(item.text, color: .primary.opacity(0.85)) + } + } + } + case .bulleted(let items): + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(Color.blue.opacity(0.85)) + .frame(width: 3.5, height: 3.5) + .padding(.top, 5.5) + .frame(width: 10, alignment: .center) + markdownText(item, color: .primary.opacity(0.85)) + } + } + } + } + } + } + } + + @ViewBuilder + private func markdownText( + _ text: String, + weight: Font.Weight? = nil, + color: Color + ) -> some View { + if rendersMarkdown { + FeedMarkdownInlineText( + text: text, + fontSize: 11, + weight: weight, + foregroundColor: color + ) + } else { + Text(text) + .cmuxFont(size: 11, weight: weight ?? .regular) + .foregroundColor(color) + .fixedSize(horizontal: false, vertical: true) + } + } + + private enum Block { + case heading(String) + case paragraph(String) + case numbered([NumberedItem]) + case bulleted([String]) + } + + private struct NumberedItem { + let index: Int + let text: String + } + + private var blocks: [Block] { + var out: [Block] = [] + var buffer: [String] = [] + func flushParagraph() { + guard !buffer.isEmpty else { return } + let joined = buffer.joined(separator: " ") + out.append(.paragraph(joined)) + buffer = [] + } + var numbered: [NumberedItem] = [] + func flushNumbered() { + if !numbered.isEmpty { + out.append(.numbered(numbered)) + numbered = [] + } + } + var bulleted: [String] = [] + func flushBulleted() { + if !bulleted.isEmpty { + out.append(.bulleted(bulleted)) + bulleted = [] + } + } + + for rawLine in plan.split(separator: "\n", omittingEmptySubsequences: false) { + let line = String(rawLine).trimmingCharacters(in: .whitespaces) + if line.isEmpty { + flushParagraph(); flushNumbered(); flushBulleted() + continue + } + // **Bold heading** or ## heading or "Word:" on its own line + if line.hasPrefix("**") && line.hasSuffix("**") && line.count > 4 { + flushParagraph(); flushNumbered(); flushBulleted() + out.append(.heading(String(line.dropFirst(2).dropLast(2)))) + continue + } + if let heading = markdownHeadingText(line) { + flushParagraph(); flushNumbered(); flushBulleted() + out.append(.heading(heading)) + continue + } + if line.hasSuffix(":") && line.count <= 40 + && !line.contains(" ") == false && line.split(separator: " ").count <= 4 + { + flushParagraph(); flushNumbered(); flushBulleted() + out.append(.heading(line)) + continue + } + // Numbered list + if let match = line.range( + of: #"^(\d+)\.\s+(.+)$"#, + options: .regularExpression + ) { + flushParagraph(); flushBulleted() + let text = String(line[match]) + if let dotIdx = text.firstIndex(of: ".") { + let numStr = String(text[text.startIndex.. String? { + guard line.hasPrefix("#") else { return nil } + let hashCount = line.prefix(while: { $0 == "#" }).count + guard (1...6).contains(hashCount), + line.count > hashCount, + line[line.index(line.startIndex, offsetBy: hashCount)] == " " + else { return nil } + return String(line.dropFirst(hashCount + 1)) + } +} diff --git a/Sources/Feed/FeedItemRow.swift b/Sources/Feed/FeedItemRow.swift new file mode 100644 index 000000000000..aafa9984a5ad --- /dev/null +++ b/Sources/Feed/FeedItemRow.swift @@ -0,0 +1,436 @@ +import AppKit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +struct FeedItemRow: View, Equatable { + let snapshot: FeedItemSnapshot + let actions: FeedRowActions + let isSelected: Bool + let onPressSelect: () -> Void + let onControlFocus: () -> Void + let onControlAction: () -> Void + let onControlBlur: () -> Void + let onActivate: () -> Void + @Binding var stopDraft: FeedStopDraft + let stopDraftValue: FeedStopDraft + @Binding var stopFocusRequest: Int + let stopFocusRequestValue: Int + + @State private var didHandlePressSelection = false + + static func == (lhs: FeedItemRow, rhs: FeedItemRow) -> Bool { + lhs.snapshot == rhs.snapshot + && lhs.isSelected == rhs.isSelected + && lhs.stopDraftValue == rhs.stopDraftValue + && lhs.stopFocusRequestValue == rhs.stopFocusRequestValue + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + chipHeader + if let context = displayContext { + FeedContextBlock(context: context, source: snapshot.source) + } else if let echo = promptEcho, !echo.isEmpty { + Text(echo) + .cmuxFont(size: 11) + .foregroundColor(.secondary) + .lineLimit(2) + .truncationMode(.tail) + } + actionArea + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(isResolvedOrExpired ? 0.6 : 1.0) + .contentShape(Rectangle()) + .help(helpText) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if !didHandlePressSelection { + didHandlePressSelection = true + onPressSelect() + } + } + .onEnded { _ in + didHandlePressSelection = false + } + ) + .onTapGesture(count: 2, perform: onActivate) + } + + private var promptEcho: String? { + // Prefer the real user prompt attached by the list view (walks + // the same workstream for the most recent .userPrompt + // telemetry). Synthetic permission labels are intentionally + // avoided here because the Feed should show real context only. + if let echo = snapshot.userPromptEcho, + !echo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return String(localized: "feed.promptEcho", defaultValue: "You: \(echo)") + } + return nil + } + + private var displayContext: WorkstreamContext? { + let fallback = WorkstreamContext(lastUserMessage: snapshot.userPromptEcho) + let context = snapshot.context?.mergingMissing(from: fallback) ?? fallback + return context.isEmpty ? nil : context + } + + private var isResolvedOrExpired: Bool { + switch snapshot.status { + case .pending: return false + case .telemetry: return false + case .resolved, .expired: return true + } + } + + /// Compact header: kind icon + project/path title on the left, + /// agent and age on the right. + private var chipHeader: some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: snapshot.kind.symbolName) + .cmuxFont(size: 12, weight: .medium) + .foregroundColor(kindTint) + .frame(width: 14, height: 14) + Text(headerTitle) + .cmuxFont(size: 12, weight: .medium) + .foregroundColor(.primary.opacity(0.92)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + HStack(spacing: 4) { + chip( + text: snapshot.source.rawValue.capitalized, + fg: sourceChipForeground, + bg: sourceChipBackground + ) + chip( + text: relativeTimeChip(snapshot.createdAt), + fg: .secondary, + bg: Color.primary.opacity(0.10), + mono: true + ) + } + } + } + + private var headerTitle: String { + // Prefer the user prompt as the card title, but keep question + // headers before it so short labels like "Demo style" survive + // middle truncation. + let promptLine = (displayContext?.lastUserMessage ?? snapshot.userPromptEcho)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let questionHeader = questionHeaderForTitle + if !promptLine.isEmpty { + let detail = [questionHeader, promptLine].compactMap { $0 }.joined(separator: " · ") + if let cwd = snapshot.cwd, !cwd.isEmpty { + return "\(cwdBasename(cwd)) · \(detail)" + } + return detail + } + if let questionHeader { + if let cwd = snapshot.cwd, !cwd.isEmpty { + return "\(cwdBasename(cwd)) · \(questionHeader)" + } + return questionHeader + } + if let title = snapshot.title, !title.isEmpty { + if let cwd = snapshot.cwd, !cwd.isEmpty { + return "\(cwdBasename(cwd)) · \(title)" + } + return title + } + if let cwd = snapshot.cwd, !cwd.isEmpty { + return "\(cwdBasename(cwd)) · \(kindLabel.capitalized)" + } + return kindLabel.capitalized + } + + private var questionHeaderForTitle: String? { + guard case .question(_, let questions) = snapshot.payload else { return nil } + return questions + .compactMap { $0.header?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } + } + + /// Last path component only — `fun` instead of `~/fun` or the full + /// absolute path. Matches the Vibe-Island mockup's compact header. + private func cwdBasename(_ path: String) -> String { + let trimmed = path.hasSuffix("/") ? String(path.dropLast()) : path + let name = (trimmed as NSString).lastPathComponent + return name.isEmpty ? path : name + } + + private func chip(text: String, fg: Color, bg: Color, mono: Bool = false) -> some View { + Text(text) + .cmuxFont(size: 10, weight: .medium, monospacedDigit: mono) + .foregroundColor(fg) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(bg) + ) + } + + private var sourceChipForeground: Color { + switch snapshot.source { + case .claude: return Color(red: 0.92, green: 0.54, blue: 0.29) + case .codex: return .green + case .opencode: return .blue + case .hermesAgent: return .teal + case .cursor: return .purple + default: return .secondary + } + } + private var sourceChipBackground: Color { + return sourceChipForeground.opacity(0.18) + } + + private func relativeTimeChip(_ date: Date) -> String { + let interval = Date().timeIntervalSince(date) + if interval < 60 { return "<1m" } + if interval < 3600 { return "\(Int(interval / 60))m" } + if interval < 86_400 { return "\(Int(interval / 3600))h" } + return "\(Int(interval / 86_400))d" + } + + private var kindLabel: String { + switch snapshot.kind { + case .permissionRequest: + return String(localized: "feed.kind.permission", defaultValue: "PERMISSION") + case .exitPlan: + return String(localized: "feed.kind.plan", defaultValue: "PLAN") + case .question: + return String(localized: "feed.kind.question.upper", defaultValue: "QUESTION") + case .toolUse: + return String(localized: "feed.kind.toolUse", defaultValue: "TOOL USE") + case .toolResult: + return String(localized: "feed.kind.toolResult", defaultValue: "TOOL RESULT") + case .userPrompt: + return String(localized: "feed.kind.prompt", defaultValue: "PROMPT") + case .assistantMessage: + return String(localized: "feed.kind.message", defaultValue: "MESSAGE") + case .sessionStart: + return String(localized: "feed.kind.sessionStart.upper", defaultValue: "SESSION START") + case .sessionEnd: + return String(localized: "feed.kind.sessionEnd.upper", defaultValue: "SESSION END") + case .stop: + return String(localized: "feed.kind.stop", defaultValue: "STOP") + case .todos: + return String(localized: "feed.kind.todos", defaultValue: "TODOS") + } + } + + private var kindTint: Color { + switch snapshot.kind { + case .permissionRequest: return .orange + case .exitPlan: return .purple + case .question: return .blue + default: return snapshot.status.isPending ? .orange : .secondary.opacity(0.8) + } + } + + @ViewBuilder + private var actionArea: some View { + switch snapshot.payload { + case .permissionRequest(_, let toolName, let toolInputJSON, _): + PermissionActionArea( + toolName: toolName, + toolInputJSON: toolInputJSON, + source: snapshot.source, + status: snapshot.status, + onActionRow: onControlAction, + onApprove: { mode in + actions.approvePermission(snapshot.id, mode) + } + ) + case .exitPlan(_, let plan, _): + ExitPlanActionArea( + plan: plan, + source: snapshot.source, + status: snapshot.status, + isRowSelected: isSelected, + onFocusRow: onControlFocus, + onActionRow: onControlAction, + onBlurRow: onControlBlur, + onApprove: { mode, feedback in + actions.approveExitPlan(snapshot.id, mode, feedback) + } + ) + case .question(_, let questions): + QuestionActionArea( + questions: questions, + source: snapshot.source, + status: snapshot.status, + isRowSelected: isSelected, + onFocusRow: onControlFocus, + onActionRow: onControlAction, + onBlurRow: onControlBlur, + context: displayContext, + onReply: { selections in + actions.replyQuestion(snapshot.id, selections) + } + ) + case .stop: + StopActionArea( + draft: $stopDraft, + focusRequest: $stopFocusRequest, + onFocusRow: onControlFocus, + onActionRow: onControlAction, + onBlurRow: onControlBlur, + onSend: { text in actions.sendText(snapshot.workstreamId, text) } + ) + default: + TelemetryActionArea(snapshot: snapshot) + } + } + + private var primaryTitle: String { + switch snapshot.payload { + case .permissionRequest(_, let toolName, _, _): + return "\(snapshot.source.rawValue.capitalized) · \(toolName)" + case .exitPlan: + return "\(snapshot.source.rawValue.capitalized) · \(String(localized: "feed.kind.exitPlan", defaultValue: "Exit plan"))" + case .question: + return "\(snapshot.source.rawValue.capitalized) · \(String(localized: "feed.kind.question", defaultValue: "Question"))" + default: + if let title = snapshot.title, !title.isEmpty { + return "\(snapshot.source.rawValue.capitalized) · \(title)" + } + return snapshot.source.rawValue.capitalized + } + } + + private var helpText: String { + var lines: [String] = [primaryTitle] + if let cwd = snapshot.cwd { lines.append(cwd) } + lines.append(absoluteTime(snapshot.createdAt)) + return lines.joined(separator: "\n") + } + + private func resolvedBadgeLabel(_ decision: WorkstreamDecision) -> String { + let submitted = String(localized: "feed.badge.submitted", defaultValue: "Submitted") + switch decision { + case .permission(let m): + return "\(submitted) · \(m.displayLabel)" + case .exitPlan(let m, let feedback): + if let feedback, !feedback.isEmpty { + return "\(submitted) · " + String(localized: "feed.badge.refined", defaultValue: "refined") + } + return "\(submitted) · \(m.displayLabel)" + case .question: + return submitted + } + } + + private func statusTag(_ text: String, color: Color) -> some View { + Text(text) + .cmuxFont(size: 10, weight: .medium) + .foregroundColor(color) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(color.opacity(0.12)) + ) + } + + private func relativeTime(_ date: Date) -> String { + Self.relativeFormatter.localizedString(for: date, relativeTo: Date()) + } + + private func absoluteTime(_ date: Date) -> String { + Self.absoluteFormatter.string(from: date) + } + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return f + }() + + private static let absoluteFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .short + f.timeStyle = .short + return f + }() +} + +// MARK: - Per-kind action areas + +private struct FeedContextBlock: View { + let context: WorkstreamContext + let source: WorkstreamSource + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if let user = context.lastUserMessage { + FeedLabeledTextRow( + label: String(localized: "feed.context.you", defaultValue: "You:"), + text: user, + labelColor: .secondary, + textColor: .secondary + ) + } + if let preamble = context.assistantPreamble { + FeedLabeledTextRow( + label: agentLabel, + text: preamble, + labelColor: .secondary, + textColor: .secondary, + rendersMarkdown: source == .claude + ) + } + if let plan = context.planSummary { + FeedLabeledTextRow( + label: String(localized: "feed.context.plan", defaultValue: "Plan:"), + text: plan, + labelColor: Color.purple.opacity(0.85), + textColor: .secondary, + rendersMarkdown: source == .claude + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var agentLabel: String { + "\(source.rawValue.capitalized):" + } +} + +struct FeedLabeledTextRow: View { + let label: String + let text: String + let labelColor: Color + let textColor: Color + var rendersMarkdown: Bool = false + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(label) + .cmuxFont(size: 10, weight: .semibold) + .foregroundColor(labelColor) + .frame(width: 48, alignment: .leading) + if rendersMarkdown { + FeedMarkdownInlineText( + text: text, + fontSize: 11, + foregroundColor: textColor + ) + } else { + Text(text) + .cmuxFont(size: 11) + .foregroundColor(textColor) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} diff --git a/Sources/Feed/FeedItemSnapshot.swift b/Sources/Feed/FeedItemSnapshot.swift new file mode 100644 index 000000000000..0fc9a174df3a --- /dev/null +++ b/Sources/Feed/FeedItemSnapshot.swift @@ -0,0 +1,102 @@ +import CMUXAgentLaunch +import Foundation + +struct FeedItemSnapshot: Equatable { + let id: UUID + let workstreamId: String + let source: WorkstreamSource + let kind: WorkstreamKind + let title: String? + let cwd: String? + let createdAt: Date + let status: WorkstreamStatus + let payload: WorkstreamPayload + let context: WorkstreamContext? + /// Most recent user-prompt text in the same workstream, attached + /// by the list view so every card can show a "You: …" echo for + /// context, even when the agent payload doesn't carry it directly. + let userPromptEcho: String? + + init(item: WorkstreamItem, userPromptEcho: String? = nil) { + self.id = item.id + self.workstreamId = item.workstreamId + self.source = item.source + self.kind = item.kind + self.title = item.title + self.cwd = item.cwd + self.createdAt = item.createdAt + self.status = item.status + self.payload = item.payload + self.context = item.context + self.userPromptEcho = userPromptEcho + } +} + +/// Closure bundle; binds to `FeedCoordinator` by default. +struct FeedRowActions { + let approvePermission: (UUID, WorkstreamPermissionMode) -> Void + let replyQuestion: (UUID, [String]) -> Void + let approveExitPlan: (UUID, WorkstreamExitPlanMode, String?) -> Void + let jump: (String) -> Void + /// Types the user's reply into the agent's terminal surface and + /// presses Return. Used by Stop-kind cards so the user can nudge + /// Claude without switching focus to the terminal. + let sendText: (String, String) -> Void + + static func bound() -> FeedRowActions { + FeedRowActions( + approvePermission: { itemId, mode in + Task { @MainActor in + FeedCoordinator.shared.deliverReply( + requestId: Self.requestId(for: itemId) ?? itemId.uuidString, + decision: .permission(mode) + ) + } + }, + replyQuestion: { itemId, selections in + Task { @MainActor in + FeedCoordinator.shared.deliverReply( + requestId: Self.requestId(for: itemId) ?? itemId.uuidString, + decision: .question(selections: selections) + ) + } + }, + approveExitPlan: { itemId, mode, feedback in + Task { @MainActor in + FeedCoordinator.shared.deliverReply( + requestId: Self.requestId(for: itemId) ?? itemId.uuidString, + decision: .exitPlan(mode, feedback: feedback) + ) + } + }, + jump: { workstreamId in + Task { @MainActor in + _ = FeedCoordinator.shared.focusIfPossible(workstreamId: workstreamId) + } + }, + sendText: { workstreamId, text in + Task { @MainActor in + FeedCoordinator.shared.sendTextToWorkstream( + workstreamId: workstreamId, + text: text + ) + } + } + ) + } + + @MainActor + private static func requestId(for itemId: UUID) -> String? { + guard let store = FeedCoordinator.shared.store else { return nil } + return store.items.first(where: { $0.id == itemId }).flatMap { item in + switch item.payload { + case .permissionRequest(let rid, _, _, _): return rid + case .exitPlan(let rid, _, _): return rid + case .question(let rid, _): return rid + default: return nil + } + } + } +} + +// MARK: - Row (matches SessionIndexView row aesthetic) diff --git a/Sources/Feed/FeedJumpResolver.swift b/Sources/Feed/FeedJumpResolver.swift new file mode 100644 index 000000000000..a9e697e1a597 --- /dev/null +++ b/Sources/Feed/FeedJumpResolver.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Reads the per-agent hook session stores (`~/.cmuxterm/-hook-sessions.json`) +/// to map a feed `workstream_id` back to a cmux `(workspaceId, surfaceId)` pair. +/// The schema is the same one written by `cmux -hook session-start`. +enum FeedJumpResolver { + struct Target: Equatable { + let workspaceId: String + let surfaceId: String + } + + static func parse(_ workstreamId: String) -> (agent: String, sessionId: String)? { + guard let dash = workstreamId.firstIndex(of: "-") else { return nil } + let agent = String(workstreamId[.. Target? { + let home = FileManager.default.homeDirectoryForCurrentUser + let file = home + .appendingPathComponent(".cmuxterm", isDirectory: true) + .appendingPathComponent("\(agent)-hook-sessions.json", isDirectory: false) + guard let data = try? Data(contentsOf: file), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + // Stores have a consistent shape: top-level `sessions` dict keyed + // by sessionId. Tolerate older flat layouts too. + let sessions: [String: Any] + if let nested = root["sessions"] as? [String: Any] { + sessions = nested + } else { + sessions = root + } + guard let entry = sessions[sessionId] as? [String: Any], + let workspaceId = entry["workspaceId"] as? String, + let surfaceId = entry["surfaceId"] as? String, + !workspaceId.isEmpty, !surfaceId.isEmpty + else { return nil } + return Target(workspaceId: workspaceId, surfaceId: surfaceId) + } + + /// Dispatches a workspace-select + surface-focus intent. Posts + /// through the existing cmux notification pathway so we don't need + /// to bind directly to the TerminalController V2 handlers from the + /// Feed layer. + @MainActor + static func focus(workspaceId: String, surfaceId: String) { + NotificationCenter.default.post( + name: .feedRequestFocus, + object: nil, + userInfo: [ + "workspaceId": workspaceId, + "surfaceId": surfaceId, + ] + ) + } + + /// Dispatches a surface.send_text intent for the agent's terminal. + /// The observer in AppDelegate translates it into the V2 socket + /// call so the Feed stays decoupled from TerminalController. + @MainActor + static func sendText(workspaceId: String, surfaceId: String, text: String) { + NotificationCenter.default.post( + name: .feedRequestSendText, + object: nil, + userInfo: [ + "workspaceId": workspaceId, + "surfaceId": surfaceId, + "text": text, + ] + ) + } +} + +extension Notification.Name { + static let feedRequestFocus = Notification.Name("cmux.feedRequestFocus") + static let feedRequestSendText = Notification.Name("cmux.feedRequestSendText") +} diff --git a/Sources/Feed/FeedKeyboardFocus.swift b/Sources/Feed/FeedKeyboardFocus.swift new file mode 100644 index 000000000000..5dedc75d6a12 --- /dev/null +++ b/Sources/Feed/FeedKeyboardFocus.swift @@ -0,0 +1,292 @@ +import AppKit +import Bonsplit +import CMUXAgentLaunch +import Foundation +import SwiftUI + +struct FeedStopDraft: Equatable { + var reply = "" + + var isPristine: Bool { + reply.isEmpty + } +} + +struct FeedRowSurface: View { + let snapshot: FeedItemSnapshot + let actions: FeedRowActions + let isSelected: Bool + let isFocusActive: Bool + let showsDivider: Bool + @Binding var stopDraft: FeedStopDraft + let onPressSelect: () -> Void + let onControlFocus: () -> Void + let onControlAction: () -> Void + let onControlBlur: () -> Void + let onActivate: () -> Void + + @State private var isHovered = false + @State private var stopReplyFocusRequest = 0 + + var body: some View { + VStack(spacing: 0) { + FeedItemRow( + snapshot: snapshot, + actions: actions, + isSelected: isFocusActive, + onPressSelect: onPressSelect, + onControlFocus: onControlFocus, + onControlAction: onControlAction, + onControlBlur: onControlBlur, + onActivate: onActivate, + stopDraft: $stopDraft, + stopDraftValue: stopDraft, + stopFocusRequest: $stopReplyFocusRequest, + stopFocusRequestValue: stopReplyFocusRequest + ) + .equatable() + if showsDivider { + Rectangle() + .fill(Color.primary.opacity(0.08)) + .frame(maxWidth: .infinity) + .frame(height: 1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackgroundFill) + .animation(.easeOut(duration: 0.14), value: isHovered) + .animation(.easeOut(duration: 0.14), value: isSelected) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.14)) { + isHovered = hovering + } + } + } + + private var rowBackgroundFill: Color { + if isSelected { + guard isFocusActive else { + return Color.primary.opacity(0.07) + } + if snapshot.status.isPending { + return tint.opacity(0.14) + } + return Color.primary.opacity(0.075) + } + if isHovered { + if snapshot.status.isPending { + return tint.opacity(0.10) + } + return Color.primary.opacity(0.055) + } + return .clear + } + + private var tint: Color { + switch snapshot.kind { + case .permissionRequest: return .orange + case .exitPlan: return .purple + case .question: return .blue + default: return snapshot.status.isPending ? .orange : .secondary.opacity(0.8) + } + } +} + +extension View { + @ViewBuilder + func feedZeroScrollContentMargins() -> some View { + if #available(macOS 14.0, *) { + contentMargins(.all, 0, for: .scrollContent) + } else { + self + } + } +} + +struct FeedKeyboardFocusBridge: NSViewRepresentable { + let onEscape: () -> Void + let onMoveSelection: (Int) -> Void + let onActivateSelection: () -> Void + let onFocusFirstItemRequested: () -> Void + let onFocusChanged: (Bool) -> Void + let onFocusSnapshotChanged: (FeedFocusSnapshot) -> Void + + func makeNSView(context: Context) -> FeedKeyboardFocusView { + let view = FeedKeyboardFocusView(frame: NSRect(x: 0, y: 0, width: 1, height: 1)) + view.onEscape = onEscape + view.onMoveSelection = onMoveSelection + view.onActivateSelection = onActivateSelection + view.onFocusFirstItemRequested = onFocusFirstItemRequested + view.onFocusChanged = onFocusChanged + view.onFocusSnapshotChanged = onFocusSnapshotChanged + return view + } + + func updateNSView(_ nsView: FeedKeyboardFocusView, context: Context) { + nsView.onEscape = onEscape + nsView.onMoveSelection = onMoveSelection + nsView.onActivateSelection = onActivateSelection + nsView.onFocusFirstItemRequested = onFocusFirstItemRequested + nsView.onFocusChanged = onFocusChanged + nsView.onFocusSnapshotChanged = onFocusSnapshotChanged + nsView.registerWithKeyboardFocusCoordinatorIfNeeded() + } +} + +final class FeedKeyboardFocusView: NSView { + var onEscape: (() -> Void)? + var onMoveSelection: ((Int) -> Void)? + var onActivateSelection: (() -> Void)? + var onFocusFirstItemRequested: (() -> Void)? + var onFocusChanged: ((Bool) -> Void)? + var onFocusSnapshotChanged: ((FeedFocusSnapshot) -> Void)? + + override var acceptsFirstResponder: Bool { true } + override var canBecomeKeyView: Bool { true } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let window else { return } + AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.registerFeedHost(self) +#if DEBUG + dlog("feed.focus.host attach window=\(ObjectIdentifier(window))") +#endif + } + + func registerWithKeyboardFocusCoordinatorIfNeeded() { + guard let window else { return } + AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.registerFeedHost(self) + } + + override func layout() { + super.layout() + registerWithKeyboardFocusCoordinatorIfNeeded() + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if event.type == .keyDown, event.keyCode == 53 { +#if DEBUG + dlog( + "feed.focus.host escape window=\(window.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + + "fr=\(feedDebugResponderSummary(window?.firstResponder))" + ) +#endif + onEscape?() + return true + } + if let delta = RightSidebarKeyboardNavigation.moveDelta(for: event) { + onMoveSelection?(delta) + return true + } + return super.performKeyEquivalent(with: event) + } + + override func keyDown(with event: NSEvent) { +#if DEBUG + let chars = event.charactersIgnoringModifiers ?? "" + dlog( + "feed.focus.host keyDown key=\(event.keyCode) chars=\(chars) " + + "fr=\(feedDebugResponderSummary(window?.firstResponder))" + ) +#endif + if let mode = AppDelegate.shared?.rightSidebarModeShortcut(for: event) { + _ = AppDelegate.shared?.focusRightSidebarInActiveMainWindow( + mode: mode, + focusFirstItem: true, + preferredWindow: window + ) + return + } + + if let delta = RightSidebarKeyboardNavigation.moveDelta(for: event) { + onMoveSelection?(delta) + return + } + + let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let hasShortcutModifier = !normalizedFlags.intersection([.command, .control, .option]).isEmpty + guard !hasShortcutModifier else { + super.keyDown(with: event) + return + } + + switch event.keyCode { + case 36, 76: + onActivateSelection?() + return + case 53: + onEscape?() + return + default: + break + } + + if let characters = event.charactersIgnoringModifiers, !characters.isEmpty { + return + } + super.keyDown(with: event) + } + + override func becomeFirstResponder() -> Bool { + let result = super.becomeFirstResponder() + if result { + onFocusChanged?(true) + } +#if DEBUG + dlog( + "feed.focus.host become result=\(result ? 1 : 0) " + + "window=\(window.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + + "fr=\(feedDebugResponderSummary(window?.firstResponder))" + ) +#endif + return result + } + + override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() + if result { + onFocusChanged?(false) + } +#if DEBUG + dlog( + "feed.focus.host resign result=\(result ? 1 : 0) " + + "window=\(window.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + + "fr=\(feedDebugResponderSummary(window?.firstResponder))" + ) +#endif + return result + } + + func focusFirstItemFromCoordinator() { + onFocusFirstItemRequested?() + } + + func focusHostFromCoordinator() -> Bool { + guard let window else { return false } +#if DEBUG + let before = feedDebugResponderSummary(window.firstResponder) +#endif + let result = window.makeFirstResponder(self) +#if DEBUG + dlog( + "feed.focus.host request result=\(result ? 1 : 0) " + + "window=\(ObjectIdentifier(window)) before=\(before) " + + "after=\(feedDebugResponderSummary(window.firstResponder))" + ) +#endif + return result + } + + func applyFocusSnapshotFromController(_ snapshot: FeedFocusSnapshot) { + onFocusSnapshotChanged?(snapshot) + } + + func ownsKeyboardFocus(_ responder: NSResponder) -> Bool { + responder === self || responder is FeedKeyboardFocusResponder + } +} + +// MARK: - Row snapshot + actions (respects snapshot-boundary rule) + +/// Immutable snapshot of a `WorkstreamItem` handed to row views so rows +/// never hold a reference to the store. diff --git a/Sources/Feed/FeedListView.swift b/Sources/Feed/FeedListView.swift new file mode 100644 index 000000000000..7fb141589ccc --- /dev/null +++ b/Sources/Feed/FeedListView.swift @@ -0,0 +1,451 @@ +import AppKit +import Bonsplit +import CMUXAgentLaunch +import Foundation +import SwiftUI + +struct FeedListView: View { + let filter: FeedPanelView.Filter + let items: [WorkstreamItem] + let hasMorePersistedItems: Bool + let isLoadingOlderItems: Bool + let onLoadOlderItems: () -> Void + + @State private var focusSnapshot = FeedFocusSnapshot() + @State private var scrollRequest: FeedScrollRequest? + @State private var scrollRequestSequence = 0 + @State private var stopDrafts: [UUID: FeedStopDraft] = [:] + + var body: some View { + let snapshots = visibleSnapshots(items) + let activityGroups = filter == .activity ? activitySnapshotGroups(snapshots) : nil + let focusSnapshots = activityGroups?.ordered ?? snapshots + let rowActions = FeedRowActions.bound() + ScrollViewReader { proxy in + Group { + if snapshots.isEmpty && !shouldShowActivityHistoryLoader { + emptyState + } else { + contentBody( + snapshots: snapshots, + activityGroups: activityGroups, + actions: rowActions + ) + } + } + .onChange(of: scrollRequest) { request in + guard let request else { return } + proxy.scrollTo(request.id, anchor: .top) + } + .background( + FeedKeyboardFocusBridge( + onEscape: { + let window = activeFeedWindow() + if AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.focusTerminal() != true { + window?.makeFirstResponder(nil) + } + syncFeedFocusSnapshot(window: window) + }, + onMoveSelection: { delta in + moveSelection(in: focusSnapshots, delta: delta) + }, + onActivateSelection: { + activateSelection(in: focusSnapshots, actions: rowActions) + }, + onFocusFirstItemRequested: { + focusFirstVisibleItem(in: focusSnapshots, focusHost: false) + }, + onFocusChanged: { focused in + let window = activeFeedWindow() + if !focused { + AppDelegate.shared?.syncKeyboardFocusAfterFirstResponderChange(in: window) + } + syncFeedFocusSnapshot(window: window) + }, + onFocusSnapshotChanged: { snapshot in + focusSnapshot = snapshot + } + ) + .frame(width: 1, height: 1) + ) + } + } + + @ViewBuilder + private func contentBody( + snapshots: [FeedItemSnapshot], + activityGroups: ActivitySnapshotGroups?, + actions: FeedRowActions + ) -> some View { + switch filter { + case .actionable: + stableScrollSurface( + snapshots: snapshots, + actions: actions + ) + case .activity: + activityScrollSurface( + groups: activityGroups ?? activitySnapshotGroups(snapshots), + actions: actions, + showsLoadMore: hasMorePersistedItems + ) + } + } + + private func stableScrollSurface( + snapshots: [FeedItemSnapshot], + actions: FeedRowActions + ) -> some View { + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(Array(snapshots.enumerated()), id: \.element.id) { idx, snapshot in + rowSurface( + snapshot: snapshot, + actions: actions, + showsDivider: idx < snapshots.count - 1 + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .feedZeroScrollContentMargins() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + private func activityScrollSurface( + groups: ActivitySnapshotGroups, + actions: FeedRowActions, + showsLoadMore: Bool + ) -> some View { + List { + ForEach(Array(groups.stable.enumerated()), id: \.element.id) { idx, snapshot in + rowSurface( + snapshot: snapshot, + actions: actions, + showsDivider: idx < groups.stable.count - 1 + ) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + if !groups.stable.isEmpty && (!groups.history.isEmpty || showsLoadMore) { + rowSeparator + .id("feed.activity.separator") + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + ForEach(Array(groups.history.enumerated()), id: \.element.id) { idx, snapshot in + rowSurface( + snapshot: snapshot, + actions: actions, + showsDivider: idx < groups.history.count - 1 + ) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + if showsLoadMore { + FeedHistoryLoadMoreRow( + isLoading: isLoadingOlderItems, + action: onLoadOlderItems + ) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .feedZeroScrollContentMargins() + .environment(\.defaultMinListRowHeight, 0) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private struct ActivitySnapshotGroups { + let stable: [FeedItemSnapshot] + let history: [FeedItemSnapshot] + let ordered: [FeedItemSnapshot] + } + + private func activitySnapshotGroups(_ snapshots: [FeedItemSnapshot]) -> ActivitySnapshotGroups { + var stable: [FeedItemSnapshot] = [] + var history: [FeedItemSnapshot] = [] + stable.reserveCapacity(snapshots.count) + history.reserveCapacity(snapshots.count) + for snapshot in snapshots { + if prefersStableSurface(snapshot) { + stable.append(snapshot) + } else { + history.append(snapshot) + } + } + return ActivitySnapshotGroups(stable: stable, history: history, ordered: stable + history) + } + + private func rowSurface( + snapshot: FeedItemSnapshot, + actions: FeedRowActions, + showsDivider: Bool + ) -> some View { + FeedRowSurface( + snapshot: snapshot, + actions: actions, + isSelected: focusSnapshot.selectedItemId == snapshot.id, + isFocusActive: focusSnapshot.isKeyboardActive && focusSnapshot.selectedItemId == snapshot.id, + showsDivider: showsDivider, + stopDraft: stopDraftBinding(for: snapshot.id), + onPressSelect: { + selectRow(snapshot.id, focusFeed: false) + }, + onControlFocus: { + selectRow(snapshot.id, focusFeed: false) + }, + onControlAction: { + selectRow(snapshot.id, focusFeed: true) + }, + onControlBlur: { + syncFeedFocusSnapshot() + }, + onActivate: { + selectRow(snapshot.id, focusFeed: true) + actions.jump(snapshot.workstreamId) + } + ) + .id(snapshot.id) + } + + private func stopDraftBinding(for id: UUID) -> Binding { + Binding( + get: { stopDrafts[id] ?? FeedStopDraft() }, + set: { draft in + if draft.isPristine { + stopDrafts.removeValue(forKey: id) + } else { + stopDrafts[id] = draft + } + } + ) + } + + /// Walks the full items list (not just the filtered visible set), + /// ordered by createdAt, and records the most recent user-prompt + /// text per workstreamId. Rows consult this dict to show a + /// "You: …" echo line at the top of their card. + private static func lastPromptByWorkstream(_ items: [WorkstreamItem]) -> [String: String] { + var out: [String: String] = [:] + for item in items { + if case .userPrompt(let text) = item.payload, !text.isEmpty { + out[item.workstreamId] = text + } + } + return out + } + + private func filtered(_ items: [WorkstreamItem]) -> [WorkstreamItem] { + let base: [WorkstreamItem] + switch filter { + case .actionable: + base = items.filter { $0.kind.isActionable } + case .activity: + // Actionable kinds + todos + stop. Tool use, user prompts, + // assistant messages, session markers, and raw + // notifications are intentionally excluded — they're too + // noisy for a sidebar and already visible in the agent's + // terminal or the cmux notification system. Stop events + // render a "reply to Claude" textbox so the user can + // nudge Claude without switching focus to the terminal. + base = items.filter { item in + item.kind.isActionable + || item.kind == .todos + || item.kind == .stop + } + } + // Newest first. Status isn't a sort key — resolved items stay + // in the chronological slot where they arrived so the user's + // mental map of "this was the second request I got" doesn't + // get shuffled when they answer it. + return Array(base.reversed()) + } + + private func visibleSnapshots(_ items: [WorkstreamItem]) -> [FeedItemSnapshot] { + let lastPromptByWorkstream = Self.lastPromptByWorkstream(items) + return filtered(items).map { item in + FeedItemSnapshot( + item: item, + userPromptEcho: lastPromptByWorkstream[item.workstreamId] + ) + } + } + + private func prefersStableSurface(_ snapshot: FeedItemSnapshot) -> Bool { + snapshot.status.isPending || snapshot.kind == .stop + } + + private var shouldShowActivityHistoryLoader: Bool { + filter == .activity && hasMorePersistedItems + } + + private func selectRow(_ id: UUID, focusFeed: Bool) { + let selectionChanged = focusSnapshot.selectedItemId != id + let window = activeFeedWindow() +#if DEBUG + dlog( + "feed.focus.select begin id=\(id.uuidString.prefix(5)) " + + "focusFeed=\(focusFeed ? 1 : 0) selectionChanged=\(selectionChanged ? 1 : 0) " + + "frBefore=\(feedDebugResponderSummary(window?.firstResponder))" + ) +#endif + if focusFeed { + FeedInlineNativeTextView.blurActiveEditor() + } + let optimisticSnapshot = FeedFocusSnapshot(selectedItemId: id, isKeyboardActive: true) + focusSnapshot = optimisticSnapshot + if let controller = AppDelegate.shared?.keyboardFocusCoordinator(for: window) { + _ = controller.selectFeedItem(id, focusFeed: focusFeed) + focusSnapshot = controller.feedFocusSnapshot() + } else { + focusSnapshot = optimisticSnapshot + } +#if DEBUG + let afterWindow = activeFeedWindow() + dlog( + "feed.focus.select end id=\(id.uuidString.prefix(5)) " + + "focusFeed=\(focusFeed ? 1 : 0) selected=\(focusSnapshot.selectedItemId == id ? 1 : 0) " + + "active=\(focusSnapshot.isKeyboardActive ? 1 : 0) " + + "frAfter=\(feedDebugResponderSummary(afterWindow?.firstResponder))" + ) +#endif + } + + private func focusFirstVisibleItem(in snapshots: [FeedItemSnapshot], focusHost: Bool = true) { + guard let targetId = preferredFocusItemId(in: snapshots) else { + let window = activeFeedWindow() + if focusHost { + _ = AppDelegate.shared?.focusRightSidebarInActiveMainWindow( + mode: .feed, + focusFirstItem: false, + preferredWindow: window + ) + } else { + AppDelegate.shared?.noteRightSidebarKeyboardFocusIntent( + mode: .feed, + in: window + ) + } + syncFeedFocusSnapshot(window: window) + return + } + selectRow(targetId, focusFeed: focusHost) + scrollRequestSequence &+= 1 + scrollRequest = FeedScrollRequest(id: targetId, sequence: scrollRequestSequence) + } + + private func preferredFocusItemId(in snapshots: [FeedItemSnapshot]) -> UUID? { + let ids = snapshots.map(\.id) + let window = activeFeedWindow() + if let controllerSelectedId = AppDelegate.shared? + .keyboardFocusCoordinator(for: window)? + .feedFocusSnapshot() + .selectedItemId, + ids.contains(controllerSelectedId) + { + return controllerSelectedId + } + if let selectedItemId = focusSnapshot.selectedItemId, + ids.contains(selectedItemId) { + return selectedItemId + } + return ids.first + } + + private func moveSelection(in snapshots: [FeedItemSnapshot], delta: Int) { + guard !snapshots.isEmpty else { return } + let ids = snapshots.map(\.id) + let targetIndex: Int + if let selectedItemId = focusSnapshot.selectedItemId, + let currentIndex = ids.firstIndex(of: selectedItemId) { + targetIndex = min(max(currentIndex + delta, 0), ids.count - 1) + } else { + targetIndex = delta >= 0 ? 0 : ids.count - 1 + } + let targetId = ids[targetIndex] + let window = activeFeedWindow() + if let controller = AppDelegate.shared?.keyboardFocusCoordinator(for: window) { + _ = controller.selectFeedItem(targetId, focusFeed: false) + focusSnapshot = controller.feedFocusSnapshot() + } else { + focusSnapshot = FeedFocusSnapshot(selectedItemId: targetId, isKeyboardActive: true) + } + scrollRequestSequence &+= 1 + scrollRequest = FeedScrollRequest(id: targetId, sequence: scrollRequestSequence) +#if DEBUG + dlog( + "feed.focus.move delta=\(delta) " + + "target=\(targetId.uuidString.prefix(5)) count=\(ids.count)" + ) +#endif + } + + private func activateSelection( + in snapshots: [FeedItemSnapshot], + actions: FeedRowActions + ) { + guard !snapshots.isEmpty else { return } + let snapshot: FeedItemSnapshot + if let selectedItemId = focusSnapshot.selectedItemId, + let matched = snapshots.first(where: { $0.id == selectedItemId }) { + snapshot = matched + } else { + snapshot = snapshots[0] + } + selectRow(snapshot.id, focusFeed: true) + actions.jump(snapshot.workstreamId) + } + + private func activeFeedWindow() -> NSWindow? { + NSApp.keyWindow ?? NSApp.mainWindow + } + + private func syncFeedFocusSnapshot(window: NSWindow? = nil) { + let targetWindow = window ?? activeFeedWindow() + guard let controller = AppDelegate.shared?.keyboardFocusCoordinator(for: targetWindow) else { + return + } + focusSnapshot = controller.feedFocusSnapshot() + } + + private var rowSeparator: some View { + Rectangle() + .fill(Color.primary.opacity(0.08)) + .frame(maxWidth: .infinity) + .frame(height: 1) + } + + private var emptyState: some View { + VStack(spacing: 4) { + Text(filter == .actionable + ? String(localized: "feed.empty.actionable.title", + defaultValue: "No pending decisions") + : String(localized: "feed.empty.activity.title", + defaultValue: "No activity yet")) + .cmuxFont(size: 12) + .foregroundColor(.secondary) + Text(filter == .actionable + ? String(localized: "feed.empty.actionable.subtitle", + defaultValue: "Permission, plan, and question requests from AI agents will appear here.") + : String(localized: "feed.empty.activity.subtitle", + defaultValue: "Agent decisions and todo-list updates will appear here.")) + .cmuxFont(size: 11) + .foregroundColor(.secondary.opacity(0.7)) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct FeedScrollRequest: Equatable { + let id: UUID + let sequence: Int +} diff --git a/Sources/Feed/FeedNotificationPolicyContext.swift b/Sources/Feed/FeedNotificationPolicyContext.swift new file mode 100644 index 000000000000..498232711949 --- /dev/null +++ b/Sources/Feed/FeedNotificationPolicyContext.swift @@ -0,0 +1,70 @@ +import CMUXAgentLaunch +import CmuxSettings +import Foundation + +struct FeedNotificationPolicyContext { + let envelope: TerminalNotificationPolicyEnvelope + let hooks: [CmuxResolvedNotificationHook] + let globalConfigPath: String? +} + +extension FeedNotificationPolicyContext { + @MainActor + static func make( + event: WorkstreamEvent, + title: String, + body: String + ) -> FeedNotificationPolicyContext { + let appDelegate = AppDelegate.shared + let workspaceID = event.workspaceId.flatMap(UUID.init(uuidString:)) + let context = workspaceID.flatMap { appDelegate?.contextContainingTabId($0) } + ?? appDelegate?.mainWindowContexts.values.first(where: { $0.cmuxConfigStore != nil }) + let workspace = workspaceID.flatMap { id in + context?.tabManager.tabs.first(where: { $0.id == id }) + } + let cwd = normalizedCWD(event.cwd) + ?? workspace?.surfaceTabBarDirectory + ?? workspace?.currentDirectory + ?? FileManager.default.homeDirectoryForCurrentUser.path + var effects = TerminalNotificationPolicyEffects() + effects.desktop = true + effects.record = false + effects.markUnread = false + effects.reorderWorkspace = false + effects.sound = false + effects.command = false + effects.paneFlash = false + + return FeedNotificationPolicyContext( + envelope: TerminalNotificationPolicyEnvelope( + notification: TerminalNotificationPolicyPayload( + workspaceId: event.workspaceId ?? event.sessionId, + surfaceId: nil, + title: title, + subtitle: "", + body: body + ), + context: TerminalNotificationPolicyContext( + cwd: cwd, + configPath: nil, + hookId: nil, + appFocused: AppFocusState.isAppFocused(), + focusedPanel: false + ), + effects: effects + ), + hooks: context?.cmuxConfigStore?.notificationHooks( + startingFrom: workspace?.isRemoteWorkspace == true + ? nil + : (normalizedCWD(event.cwd) ?? workspace?.surfaceTabBarDirectory) + ) ?? [], + globalConfigPath: context?.cmuxConfigStore?.globalConfigPath + ) + } + + private static func normalizedCWD(_ cwd: String?) -> String? { + guard let cwd else { return nil } + let trimmed = cwd.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/Feed/FeedOpeningCoordinator.swift b/Sources/Feed/FeedOpeningCoordinator.swift new file mode 100644 index 000000000000..b8a991707df9 --- /dev/null +++ b/Sources/Feed/FeedOpeningCoordinator.swift @@ -0,0 +1,48 @@ +import AppKit +import Foundation + +/// Opens Feed through the same feature gate for workspace and pane entrypoints. +@MainActor +final class FeedOpeningCoordinator { + private let isEnabled: () -> Bool + + init(isEnabled: @escaping () -> Bool) { + self.isEnabled = isEnabled + } + + @discardableResult + func openPinnedWorkspace(in tabManager: TabManager) -> Workspace? { + guard isEnabled() else { + NSSound.beep() + return nil + } + + let workspace = tabManager.addWorkspace( + title: String(localized: "rightSidebar.mode.feed", defaultValue: "Feed"), + initialSurface: .feed, + inheritWorkingDirectory: false, + select: true, + autoWelcomeIfNeeded: false, + allowTextBoxFocusDefault: false + ) + tabManager.setPinned(workspace, pinned: true) + return workspace + } + + @discardableResult + func openPane(in workspace: Workspace) -> RightSidebarToolPanel? { + guard isEnabled(), + let paneID = workspace.bonsplitController.focusedPaneId + ?? workspace.bonsplitController.allPaneIds.first else { + NSSound.beep() + return nil + } + + workspace.clearSplitZoom() + return workspace.openOrFocusRightSidebarToolSurface( + inPane: paneID, + mode: .feed, + focus: true + ) + } +} diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 08e839cd9102..75d56681afcc 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -4,13 +4,13 @@ import Bonsplit import CMUXAgentLaunch import SwiftUI #if DEBUG -private func feedDebugResponderSummary(_ responder: NSResponder?) -> String { +func feedDebugResponderSummary(_ responder: NSResponder?) -> String { guard let responder else { return "nil" } return String(describing: type(of: responder)) } #endif -private extension WorkstreamPermissionMode { +extension WorkstreamPermissionMode { var displayLabel: String { switch self { case .once: @@ -27,7 +27,7 @@ private extension WorkstreamPermissionMode { } } -private extension WorkstreamExitPlanMode { +extension WorkstreamExitPlanMode { var displayLabel: String { switch self { case .ultraplan: @@ -73,7 +73,7 @@ struct FeedPanelView: View { } @State private var filter: Filter = .actionable - @StateObject private var viewModel = FeedPanelViewModel() + @State private var viewModel = FeedPanelViewModel() var body: some View { VStack(spacing: 0) { @@ -159,3776 +159,3 @@ private struct FeedSecondaryFilterButton: View { /// changes don't invalidate rows unnecessarily. Receives items as a /// plain value so its body never touches the live store, the parent /// owns the observation. -private struct FeedListView: View { - let filter: FeedPanelView.Filter - let items: [WorkstreamItem] - let hasMorePersistedItems: Bool - let isLoadingOlderItems: Bool - let onLoadOlderItems: () -> Void - - @State private var focusSnapshot = FeedFocusSnapshot() - @State private var scrollRequest: FeedScrollRequest? - @State private var scrollRequestSequence = 0 - @State private var stopDrafts: [UUID: FeedStopDraft] = [:] - - var body: some View { - let snapshots = visibleSnapshots(items) - let activityGroups = filter == .activity ? activitySnapshotGroups(snapshots) : nil - let focusSnapshots = activityGroups?.ordered ?? snapshots - let rowActions = FeedRowActions.bound() - ScrollViewReader { proxy in - Group { - if snapshots.isEmpty && !shouldShowActivityHistoryLoader { - emptyState - } else { - contentBody( - snapshots: snapshots, - activityGroups: activityGroups, - actions: rowActions - ) - } - } - .onChange(of: scrollRequest) { request in - guard let request else { return } - proxy.scrollTo(request.id, anchor: .top) - } - .background( - FeedKeyboardFocusBridge( - onEscape: { - let window = activeFeedWindow() - if AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.focusTerminal() != true { - window?.makeFirstResponder(nil) - } - syncFeedFocusSnapshot(window: window) - }, - onMoveSelection: { delta in - moveSelection(in: focusSnapshots, delta: delta) - }, - onActivateSelection: { - activateSelection(in: focusSnapshots, actions: rowActions) - }, - onFocusFirstItemRequested: { - focusFirstVisibleItem(in: focusSnapshots, focusHost: false) - }, - onFocusChanged: { focused in - let window = activeFeedWindow() - if !focused { - AppDelegate.shared?.syncKeyboardFocusAfterFirstResponderChange(in: window) - } - syncFeedFocusSnapshot(window: window) - }, - onFocusSnapshotChanged: { snapshot in - focusSnapshot = snapshot - } - ) - .frame(width: 1, height: 1) - ) - } - } - - @ViewBuilder - private func contentBody( - snapshots: [FeedItemSnapshot], - activityGroups: ActivitySnapshotGroups?, - actions: FeedRowActions - ) -> some View { - switch filter { - case .actionable: - stableScrollSurface( - snapshots: snapshots, - actions: actions - ) - case .activity: - activityScrollSurface( - groups: activityGroups ?? activitySnapshotGroups(snapshots), - actions: actions, - showsLoadMore: hasMorePersistedItems - ) - } - } - - private func stableScrollSurface( - snapshots: [FeedItemSnapshot], - actions: FeedRowActions - ) -> some View { - ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach(Array(snapshots.enumerated()), id: \.element.id) { idx, snapshot in - rowSurface( - snapshot: snapshot, - actions: actions, - showsDivider: idx < snapshots.count - 1 - ) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .feedZeroScrollContentMargins() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - - private func activityScrollSurface( - groups: ActivitySnapshotGroups, - actions: FeedRowActions, - showsLoadMore: Bool - ) -> some View { - List { - ForEach(Array(groups.stable.enumerated()), id: \.element.id) { idx, snapshot in - rowSurface( - snapshot: snapshot, - actions: actions, - showsDivider: idx < groups.stable.count - 1 - ) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - if !groups.stable.isEmpty && (!groups.history.isEmpty || showsLoadMore) { - rowSeparator - .id("feed.activity.separator") - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - ForEach(Array(groups.history.enumerated()), id: \.element.id) { idx, snapshot in - rowSurface( - snapshot: snapshot, - actions: actions, - showsDivider: idx < groups.history.count - 1 - ) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - if showsLoadMore { - FeedHistoryLoadMoreRow( - isLoading: isLoadingOlderItems, - action: onLoadOlderItems - ) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .feedZeroScrollContentMargins() - .environment(\.defaultMinListRowHeight, 0) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private struct ActivitySnapshotGroups { - let stable: [FeedItemSnapshot] - let history: [FeedItemSnapshot] - let ordered: [FeedItemSnapshot] - } - - private func activitySnapshotGroups(_ snapshots: [FeedItemSnapshot]) -> ActivitySnapshotGroups { - var stable: [FeedItemSnapshot] = [] - var history: [FeedItemSnapshot] = [] - stable.reserveCapacity(snapshots.count) - history.reserveCapacity(snapshots.count) - for snapshot in snapshots { - if prefersStableSurface(snapshot) { - stable.append(snapshot) - } else { - history.append(snapshot) - } - } - return ActivitySnapshotGroups(stable: stable, history: history, ordered: stable + history) - } - - private func rowSurface( - snapshot: FeedItemSnapshot, - actions: FeedRowActions, - showsDivider: Bool - ) -> some View { - FeedRowSurface( - snapshot: snapshot, - actions: actions, - isSelected: focusSnapshot.selectedItemId == snapshot.id, - isFocusActive: focusSnapshot.isKeyboardActive && focusSnapshot.selectedItemId == snapshot.id, - showsDivider: showsDivider, - stopDraft: stopDraftBinding(for: snapshot.id), - onPressSelect: { - selectRow(snapshot.id, focusFeed: false) - }, - onControlFocus: { - selectRow(snapshot.id, focusFeed: false) - }, - onControlAction: { - selectRow(snapshot.id, focusFeed: true) - }, - onControlBlur: { - syncFeedFocusSnapshot() - }, - onActivate: { - selectRow(snapshot.id, focusFeed: true) - actions.jump(snapshot.workstreamId) - } - ) - .id(snapshot.id) - } - - private func stopDraftBinding(for id: UUID) -> Binding { - Binding( - get: { stopDrafts[id] ?? FeedStopDraft() }, - set: { draft in - if draft.isPristine { - stopDrafts.removeValue(forKey: id) - } else { - stopDrafts[id] = draft - } - } - ) - } - - /// Walks the full items list (not just the filtered visible set), - /// ordered by createdAt, and records the most recent user-prompt - /// text per workstreamId. Rows consult this dict to show a - /// "You: …" echo line at the top of their card. - private static func lastPromptByWorkstream(_ items: [WorkstreamItem]) -> [String: String] { - var out: [String: String] = [:] - for item in items { - if case .userPrompt(let text) = item.payload, !text.isEmpty { - out[item.workstreamId] = text - } - } - return out - } - - private func filtered(_ items: [WorkstreamItem]) -> [WorkstreamItem] { - let base: [WorkstreamItem] - switch filter { - case .actionable: - base = items.filter { $0.kind.isActionable } - case .activity: - // Actionable kinds + todos + stop. Tool use, user prompts, - // assistant messages, session markers, and raw - // notifications are intentionally excluded — they're too - // noisy for a sidebar and already visible in the agent's - // terminal or the cmux notification system. Stop events - // render a "reply to Claude" textbox so the user can - // nudge Claude without switching focus to the terminal. - base = items.filter { item in - item.kind.isActionable - || item.kind == .todos - || item.kind == .stop - } - } - // Newest first. Status isn't a sort key — resolved items stay - // in the chronological slot where they arrived so the user's - // mental map of "this was the second request I got" doesn't - // get shuffled when they answer it. - return Array(base.reversed()) - } - - private func visibleSnapshots(_ items: [WorkstreamItem]) -> [FeedItemSnapshot] { - let lastPromptByWorkstream = Self.lastPromptByWorkstream(items) - return filtered(items).map { item in - FeedItemSnapshot( - item: item, - userPromptEcho: lastPromptByWorkstream[item.workstreamId] - ) - } - } - - private func prefersStableSurface(_ snapshot: FeedItemSnapshot) -> Bool { - snapshot.status.isPending || snapshot.kind == .stop - } - - private var shouldShowActivityHistoryLoader: Bool { - filter == .activity && hasMorePersistedItems - } - - private func selectRow(_ id: UUID, focusFeed: Bool) { - let selectionChanged = focusSnapshot.selectedItemId != id - let window = activeFeedWindow() -#if DEBUG - dlog( - "feed.focus.select begin id=\(id.uuidString.prefix(5)) " + - "focusFeed=\(focusFeed ? 1 : 0) selectionChanged=\(selectionChanged ? 1 : 0) " + - "frBefore=\(feedDebugResponderSummary(window?.firstResponder))" - ) -#endif - if focusFeed { - FeedInlineNativeTextView.blurActiveEditor() - } - let optimisticSnapshot = FeedFocusSnapshot(selectedItemId: id, isKeyboardActive: true) - focusSnapshot = optimisticSnapshot - if let controller = AppDelegate.shared?.keyboardFocusCoordinator(for: window) { - _ = controller.selectFeedItem(id, focusFeed: focusFeed) - focusSnapshot = controller.feedFocusSnapshot() - } else { - focusSnapshot = optimisticSnapshot - } -#if DEBUG - let afterWindow = activeFeedWindow() - dlog( - "feed.focus.select end id=\(id.uuidString.prefix(5)) " + - "focusFeed=\(focusFeed ? 1 : 0) selected=\(focusSnapshot.selectedItemId == id ? 1 : 0) " + - "active=\(focusSnapshot.isKeyboardActive ? 1 : 0) " + - "frAfter=\(feedDebugResponderSummary(afterWindow?.firstResponder))" - ) -#endif - } - - private func focusFirstVisibleItem(in snapshots: [FeedItemSnapshot], focusHost: Bool = true) { - guard let targetId = preferredFocusItemId(in: snapshots) else { - let window = activeFeedWindow() - if focusHost { - _ = AppDelegate.shared?.focusRightSidebarInActiveMainWindow( - mode: .feed, - focusFirstItem: false, - preferredWindow: window - ) - } else { - AppDelegate.shared?.noteRightSidebarKeyboardFocusIntent( - mode: .feed, - in: window - ) - } - syncFeedFocusSnapshot(window: window) - return - } - selectRow(targetId, focusFeed: focusHost) - scrollRequestSequence &+= 1 - scrollRequest = FeedScrollRequest(id: targetId, sequence: scrollRequestSequence) - } - - private func preferredFocusItemId(in snapshots: [FeedItemSnapshot]) -> UUID? { - let ids = snapshots.map(\.id) - let window = activeFeedWindow() - if let controllerSelectedId = AppDelegate.shared? - .keyboardFocusCoordinator(for: window)? - .feedFocusSnapshot() - .selectedItemId, - ids.contains(controllerSelectedId) - { - return controllerSelectedId - } - if let selectedItemId = focusSnapshot.selectedItemId, - ids.contains(selectedItemId) { - return selectedItemId - } - return ids.first - } - - private func moveSelection(in snapshots: [FeedItemSnapshot], delta: Int) { - guard !snapshots.isEmpty else { return } - let ids = snapshots.map(\.id) - let targetIndex: Int - if let selectedItemId = focusSnapshot.selectedItemId, - let currentIndex = ids.firstIndex(of: selectedItemId) { - targetIndex = min(max(currentIndex + delta, 0), ids.count - 1) - } else { - targetIndex = delta >= 0 ? 0 : ids.count - 1 - } - let targetId = ids[targetIndex] - let window = activeFeedWindow() - if let controller = AppDelegate.shared?.keyboardFocusCoordinator(for: window) { - _ = controller.selectFeedItem(targetId, focusFeed: false) - focusSnapshot = controller.feedFocusSnapshot() - } else { - focusSnapshot = FeedFocusSnapshot(selectedItemId: targetId, isKeyboardActive: true) - } - scrollRequestSequence &+= 1 - scrollRequest = FeedScrollRequest(id: targetId, sequence: scrollRequestSequence) -#if DEBUG - dlog( - "feed.focus.move delta=\(delta) " + - "target=\(targetId.uuidString.prefix(5)) count=\(ids.count)" - ) -#endif - } - - private func activateSelection( - in snapshots: [FeedItemSnapshot], - actions: FeedRowActions - ) { - guard !snapshots.isEmpty else { return } - let snapshot: FeedItemSnapshot - if let selectedItemId = focusSnapshot.selectedItemId, - let matched = snapshots.first(where: { $0.id == selectedItemId }) { - snapshot = matched - } else { - snapshot = snapshots[0] - } - selectRow(snapshot.id, focusFeed: true) - actions.jump(snapshot.workstreamId) - } - - private func activeFeedWindow() -> NSWindow? { - NSApp.keyWindow ?? NSApp.mainWindow - } - - private func syncFeedFocusSnapshot(window: NSWindow? = nil) { - let targetWindow = window ?? activeFeedWindow() - guard let controller = AppDelegate.shared?.keyboardFocusCoordinator(for: targetWindow) else { - return - } - focusSnapshot = controller.feedFocusSnapshot() - } - - private var rowSeparator: some View { - Rectangle() - .fill(Color.primary.opacity(0.08)) - .frame(maxWidth: .infinity) - .frame(height: 1) - } - - private var emptyState: some View { - VStack(spacing: 4) { - Text(filter == .actionable - ? String(localized: "feed.empty.actionable.title", - defaultValue: "No pending decisions") - : String(localized: "feed.empty.activity.title", - defaultValue: "No activity yet")) - .cmuxFont(size: 12) - .foregroundColor(.secondary) - Text(filter == .actionable - ? String(localized: "feed.empty.actionable.subtitle", - defaultValue: "Permission, plan, and question requests from AI agents will appear here.") - : String(localized: "feed.empty.activity.subtitle", - defaultValue: "Agent decisions and todo-list updates will appear here.")) - .cmuxFont(size: 11) - .foregroundColor(.secondary.opacity(0.7)) - .multilineTextAlignment(.center) - .padding(.horizontal, 16) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -private struct FeedScrollRequest: Equatable { - let id: UUID - let sequence: Int -} - -struct FeedStopDraft: Equatable { - var reply = "" - - var isPristine: Bool { - reply.isEmpty - } -} - -private struct FeedRowSurface: View { - let snapshot: FeedItemSnapshot - let actions: FeedRowActions - let isSelected: Bool - let isFocusActive: Bool - let showsDivider: Bool - @Binding var stopDraft: FeedStopDraft - let onPressSelect: () -> Void - let onControlFocus: () -> Void - let onControlAction: () -> Void - let onControlBlur: () -> Void - let onActivate: () -> Void - - @State private var isHovered = false - @State private var stopReplyFocusRequest = 0 - - var body: some View { - VStack(spacing: 0) { - FeedItemRow( - snapshot: snapshot, - actions: actions, - isSelected: isFocusActive, - onPressSelect: onPressSelect, - onControlFocus: onControlFocus, - onControlAction: onControlAction, - onControlBlur: onControlBlur, - onActivate: onActivate, - stopDraft: $stopDraft, - stopDraftValue: stopDraft, - stopFocusRequest: $stopReplyFocusRequest, - stopFocusRequestValue: stopReplyFocusRequest - ) - .equatable() - if showsDivider { - Rectangle() - .fill(Color.primary.opacity(0.08)) - .frame(maxWidth: .infinity) - .frame(height: 1) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .background(rowBackgroundFill) - .animation(.easeOut(duration: 0.14), value: isHovered) - .animation(.easeOut(duration: 0.14), value: isSelected) - .onHover { hovering in - withAnimation(.easeOut(duration: 0.14)) { - isHovered = hovering - } - } - } - - private var rowBackgroundFill: Color { - if isSelected { - guard isFocusActive else { - return Color.primary.opacity(0.07) - } - if snapshot.status.isPending { - return tint.opacity(0.14) - } - return Color.primary.opacity(0.075) - } - if isHovered { - if snapshot.status.isPending { - return tint.opacity(0.10) - } - return Color.primary.opacity(0.055) - } - return .clear - } - - private var tint: Color { - switch snapshot.kind { - case .permissionRequest: return .orange - case .exitPlan: return .purple - case .question: return .blue - default: return snapshot.status.isPending ? .orange : .secondary.opacity(0.8) - } - } -} - -private extension View { - @ViewBuilder - func feedZeroScrollContentMargins() -> some View { - if #available(macOS 14.0, *) { - contentMargins(.all, 0, for: .scrollContent) - } else { - self - } - } -} - -private struct FeedKeyboardFocusBridge: NSViewRepresentable { - let onEscape: () -> Void - let onMoveSelection: (Int) -> Void - let onActivateSelection: () -> Void - let onFocusFirstItemRequested: () -> Void - let onFocusChanged: (Bool) -> Void - let onFocusSnapshotChanged: (FeedFocusSnapshot) -> Void - - func makeNSView(context: Context) -> FeedKeyboardFocusView { - let view = FeedKeyboardFocusView(frame: NSRect(x: 0, y: 0, width: 1, height: 1)) - view.onEscape = onEscape - view.onMoveSelection = onMoveSelection - view.onActivateSelection = onActivateSelection - view.onFocusFirstItemRequested = onFocusFirstItemRequested - view.onFocusChanged = onFocusChanged - view.onFocusSnapshotChanged = onFocusSnapshotChanged - return view - } - - func updateNSView(_ nsView: FeedKeyboardFocusView, context: Context) { - nsView.onEscape = onEscape - nsView.onMoveSelection = onMoveSelection - nsView.onActivateSelection = onActivateSelection - nsView.onFocusFirstItemRequested = onFocusFirstItemRequested - nsView.onFocusChanged = onFocusChanged - nsView.onFocusSnapshotChanged = onFocusSnapshotChanged - nsView.registerWithKeyboardFocusCoordinatorIfNeeded() - } -} - -final class FeedKeyboardFocusView: NSView { - var onEscape: (() -> Void)? - var onMoveSelection: ((Int) -> Void)? - var onActivateSelection: (() -> Void)? - var onFocusFirstItemRequested: (() -> Void)? - var onFocusChanged: ((Bool) -> Void)? - var onFocusSnapshotChanged: ((FeedFocusSnapshot) -> Void)? - - override var acceptsFirstResponder: Bool { true } - override var canBecomeKeyView: Bool { true } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - guard let window else { return } - AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.registerFeedHost(self) -#if DEBUG - dlog("feed.focus.host attach window=\(ObjectIdentifier(window))") -#endif - } - - func registerWithKeyboardFocusCoordinatorIfNeeded() { - guard let window else { return } - AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.registerFeedHost(self) - } - - override func layout() { - super.layout() - registerWithKeyboardFocusCoordinatorIfNeeded() - } - - override func performKeyEquivalent(with event: NSEvent) -> Bool { - if event.type == .keyDown, event.keyCode == 53 { -#if DEBUG - dlog( - "feed.focus.host escape window=\(window.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + - "fr=\(feedDebugResponderSummary(window?.firstResponder))" - ) -#endif - onEscape?() - return true - } - if let delta = RightSidebarKeyboardNavigation.moveDelta(for: event) { - onMoveSelection?(delta) - return true - } - return super.performKeyEquivalent(with: event) - } - - override func keyDown(with event: NSEvent) { -#if DEBUG - let chars = event.charactersIgnoringModifiers ?? "" - dlog( - "feed.focus.host keyDown key=\(event.keyCode) chars=\(chars) " + - "fr=\(feedDebugResponderSummary(window?.firstResponder))" - ) -#endif - if let mode = AppDelegate.shared?.rightSidebarModeShortcut(for: event) { - _ = AppDelegate.shared?.focusRightSidebarInActiveMainWindow( - mode: mode, - focusFirstItem: true, - preferredWindow: window - ) - return - } - - if let delta = RightSidebarKeyboardNavigation.moveDelta(for: event) { - onMoveSelection?(delta) - return - } - - let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let hasShortcutModifier = !normalizedFlags.intersection([.command, .control, .option]).isEmpty - guard !hasShortcutModifier else { - super.keyDown(with: event) - return - } - - switch event.keyCode { - case 36, 76: - onActivateSelection?() - return - case 53: - onEscape?() - return - default: - break - } - - if let characters = event.charactersIgnoringModifiers, !characters.isEmpty { - return - } - super.keyDown(with: event) - } - - override func becomeFirstResponder() -> Bool { - let result = super.becomeFirstResponder() - if result { - onFocusChanged?(true) - } -#if DEBUG - dlog( - "feed.focus.host become result=\(result ? 1 : 0) " + - "window=\(window.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + - "fr=\(feedDebugResponderSummary(window?.firstResponder))" - ) -#endif - return result - } - - override func resignFirstResponder() -> Bool { - let result = super.resignFirstResponder() - if result { - onFocusChanged?(false) - } -#if DEBUG - dlog( - "feed.focus.host resign result=\(result ? 1 : 0) " + - "window=\(window.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + - "fr=\(feedDebugResponderSummary(window?.firstResponder))" - ) -#endif - return result - } - - func focusFirstItemFromCoordinator() { - onFocusFirstItemRequested?() - } - - func focusHostFromCoordinator() -> Bool { - guard let window else { return false } -#if DEBUG - let before = feedDebugResponderSummary(window.firstResponder) -#endif - let result = window.makeFirstResponder(self) -#if DEBUG - dlog( - "feed.focus.host request result=\(result ? 1 : 0) " + - "window=\(ObjectIdentifier(window)) before=\(before) " + - "after=\(feedDebugResponderSummary(window.firstResponder))" - ) -#endif - return result - } - - func applyFocusSnapshotFromController(_ snapshot: FeedFocusSnapshot) { - onFocusSnapshotChanged?(snapshot) - } - - func ownsKeyboardFocus(_ responder: NSResponder) -> Bool { - responder === self || responder is FeedKeyboardFocusResponder - } -} - -// MARK: - Row snapshot + actions (respects snapshot-boundary rule) - -/// Immutable snapshot of a `WorkstreamItem` handed to row views so rows -/// never hold a reference to the store. -struct FeedItemSnapshot: Equatable { - let id: UUID - let workstreamId: String - let source: WorkstreamSource - let kind: WorkstreamKind - let title: String? - let cwd: String? - let createdAt: Date - let status: WorkstreamStatus - let payload: WorkstreamPayload - let context: WorkstreamContext? - /// Most recent user-prompt text in the same workstream, attached - /// by the list view so every card can show a "You: …" echo for - /// context, even when the agent payload doesn't carry it directly. - let userPromptEcho: String? - - init(item: WorkstreamItem, userPromptEcho: String? = nil) { - self.id = item.id - self.workstreamId = item.workstreamId - self.source = item.source - self.kind = item.kind - self.title = item.title - self.cwd = item.cwd - self.createdAt = item.createdAt - self.status = item.status - self.payload = item.payload - self.context = item.context - self.userPromptEcho = userPromptEcho - } -} - -/// Closure bundle; binds to `FeedCoordinator` by default. -struct FeedRowActions { - let approvePermission: (UUID, WorkstreamPermissionMode) -> Void - let replyQuestion: (UUID, [String]) -> Void - let approveExitPlan: (UUID, WorkstreamExitPlanMode, String?) -> Void - let jump: (String) -> Void - /// Types the user's reply into the agent's terminal surface and - /// presses Return. Used by Stop-kind cards so the user can nudge - /// Claude without switching focus to the terminal. - let sendText: (String, String) -> Void - - static func bound() -> FeedRowActions { - FeedRowActions( - approvePermission: { itemId, mode in - Task { @MainActor in - FeedCoordinator.shared.deliverReply( - requestId: Self.requestId(for: itemId) ?? itemId.uuidString, - decision: .permission(mode) - ) - } - }, - replyQuestion: { itemId, selections in - Task { @MainActor in - FeedCoordinator.shared.deliverReply( - requestId: Self.requestId(for: itemId) ?? itemId.uuidString, - decision: .question(selections: selections) - ) - } - }, - approveExitPlan: { itemId, mode, feedback in - Task { @MainActor in - FeedCoordinator.shared.deliverReply( - requestId: Self.requestId(for: itemId) ?? itemId.uuidString, - decision: .exitPlan(mode, feedback: feedback) - ) - } - }, - jump: { workstreamId in - Task { @MainActor in - _ = FeedCoordinator.shared.focusIfPossible(workstreamId: workstreamId) - } - }, - sendText: { workstreamId, text in - Task { @MainActor in - FeedCoordinator.shared.sendTextToWorkstream( - workstreamId: workstreamId, - text: text - ) - } - } - ) - } - - @MainActor - private static func requestId(for itemId: UUID) -> String? { - guard let store = FeedCoordinator.shared.store else { return nil } - return store.items.first(where: { $0.id == itemId }).flatMap { item in - switch item.payload { - case .permissionRequest(let rid, _, _, _): return rid - case .exitPlan(let rid, _, _): return rid - case .question(let rid, _): return rid - default: return nil - } - } - } -} - -// MARK: - Row (matches SessionIndexView row aesthetic) - -struct FeedItemRow: View, Equatable { - let snapshot: FeedItemSnapshot - let actions: FeedRowActions - let isSelected: Bool - let onPressSelect: () -> Void - let onControlFocus: () -> Void - let onControlAction: () -> Void - let onControlBlur: () -> Void - let onActivate: () -> Void - @Binding var stopDraft: FeedStopDraft - let stopDraftValue: FeedStopDraft - @Binding var stopFocusRequest: Int - let stopFocusRequestValue: Int - - @State private var didHandlePressSelection = false - - static func == (lhs: FeedItemRow, rhs: FeedItemRow) -> Bool { - lhs.snapshot == rhs.snapshot - && lhs.isSelected == rhs.isSelected - && lhs.stopDraftValue == rhs.stopDraftValue - && lhs.stopFocusRequestValue == rhs.stopFocusRequestValue - } - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - chipHeader - if let context = displayContext { - FeedContextBlock(context: context, source: snapshot.source) - } else if let echo = promptEcho, !echo.isEmpty { - Text(echo) - .cmuxFont(size: 11) - .foregroundColor(.secondary) - .lineLimit(2) - .truncationMode(.tail) - } - actionArea - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .opacity(isResolvedOrExpired ? 0.6 : 1.0) - .contentShape(Rectangle()) - .help(helpText) - .simultaneousGesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in - if !didHandlePressSelection { - didHandlePressSelection = true - onPressSelect() - } - } - .onEnded { _ in - didHandlePressSelection = false - } - ) - .onTapGesture(count: 2, perform: onActivate) - } - - private var promptEcho: String? { - // Prefer the real user prompt attached by the list view (walks - // the same workstream for the most recent .userPrompt - // telemetry). Synthetic permission labels are intentionally - // avoided here because the Feed should show real context only. - if let echo = snapshot.userPromptEcho, - !echo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return String(localized: "feed.promptEcho", defaultValue: "You: \(echo)") - } - return nil - } - - private var displayContext: WorkstreamContext? { - let fallback = WorkstreamContext(lastUserMessage: snapshot.userPromptEcho) - let context = snapshot.context?.mergingMissing(from: fallback) ?? fallback - return context.isEmpty ? nil : context - } - - private var isResolvedOrExpired: Bool { - switch snapshot.status { - case .pending: return false - case .telemetry: return false - case .resolved, .expired: return true - } - } - - /// Compact header: kind icon + project/path title on the left, - /// agent and age on the right. - private var chipHeader: some View { - HStack(alignment: .center, spacing: 8) { - Image(systemName: snapshot.kind.symbolName) - .cmuxFont(size: 12, weight: .medium) - .foregroundColor(kindTint) - .frame(width: 14, height: 14) - Text(headerTitle) - .cmuxFont(size: 12, weight: .medium) - .foregroundColor(.primary.opacity(0.92)) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 4) - HStack(spacing: 4) { - chip( - text: snapshot.source.rawValue.capitalized, - fg: sourceChipForeground, - bg: sourceChipBackground - ) - chip( - text: relativeTimeChip(snapshot.createdAt), - fg: .secondary, - bg: Color.primary.opacity(0.10), - mono: true - ) - } - } - } - - private var headerTitle: String { - // Prefer the user prompt as the card title, but keep question - // headers before it so short labels like "Demo style" survive - // middle truncation. - let promptLine = (displayContext?.lastUserMessage ?? snapshot.userPromptEcho)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let questionHeader = questionHeaderForTitle - if !promptLine.isEmpty { - let detail = [questionHeader, promptLine].compactMap { $0 }.joined(separator: " · ") - if let cwd = snapshot.cwd, !cwd.isEmpty { - return "\(cwdBasename(cwd)) · \(detail)" - } - return detail - } - if let questionHeader { - if let cwd = snapshot.cwd, !cwd.isEmpty { - return "\(cwdBasename(cwd)) · \(questionHeader)" - } - return questionHeader - } - if let title = snapshot.title, !title.isEmpty { - if let cwd = snapshot.cwd, !cwd.isEmpty { - return "\(cwdBasename(cwd)) · \(title)" - } - return title - } - if let cwd = snapshot.cwd, !cwd.isEmpty { - return "\(cwdBasename(cwd)) · \(kindLabel.capitalized)" - } - return kindLabel.capitalized - } - - private var questionHeaderForTitle: String? { - guard case .question(_, let questions) = snapshot.payload else { return nil } - return questions - .compactMap { $0.header?.trimmingCharacters(in: .whitespacesAndNewlines) } - .first { !$0.isEmpty } - } - - /// Last path component only — `fun` instead of `~/fun` or the full - /// absolute path. Matches the Vibe-Island mockup's compact header. - private func cwdBasename(_ path: String) -> String { - let trimmed = path.hasSuffix("/") ? String(path.dropLast()) : path - let name = (trimmed as NSString).lastPathComponent - return name.isEmpty ? path : name - } - - private func chip(text: String, fg: Color, bg: Color, mono: Bool = false) -> some View { - Text(text) - .cmuxFont(size: 10, weight: .medium, monospacedDigit: mono) - .foregroundColor(fg) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(bg) - ) - } - - private var sourceChipForeground: Color { - switch snapshot.source { - case .claude: return Color(red: 0.92, green: 0.54, blue: 0.29) - case .codex: return .green - case .opencode: return .blue - case .hermesAgent: return .teal - case .cursor: return .purple - default: return .secondary - } - } - private var sourceChipBackground: Color { - return sourceChipForeground.opacity(0.18) - } - - private func relativeTimeChip(_ date: Date) -> String { - let interval = Date().timeIntervalSince(date) - if interval < 60 { return "<1m" } - if interval < 3600 { return "\(Int(interval / 60))m" } - if interval < 86_400 { return "\(Int(interval / 3600))h" } - return "\(Int(interval / 86_400))d" - } - - private var kindLabel: String { - switch snapshot.kind { - case .permissionRequest: - return String(localized: "feed.kind.permission", defaultValue: "PERMISSION") - case .exitPlan: - return String(localized: "feed.kind.plan", defaultValue: "PLAN") - case .question: - return String(localized: "feed.kind.question.upper", defaultValue: "QUESTION") - case .toolUse: - return String(localized: "feed.kind.toolUse", defaultValue: "TOOL USE") - case .toolResult: - return String(localized: "feed.kind.toolResult", defaultValue: "TOOL RESULT") - case .userPrompt: - return String(localized: "feed.kind.prompt", defaultValue: "PROMPT") - case .assistantMessage: - return String(localized: "feed.kind.message", defaultValue: "MESSAGE") - case .sessionStart: - return String(localized: "feed.kind.sessionStart.upper", defaultValue: "SESSION START") - case .sessionEnd: - return String(localized: "feed.kind.sessionEnd.upper", defaultValue: "SESSION END") - case .stop: - return String(localized: "feed.kind.stop", defaultValue: "STOP") - case .todos: - return String(localized: "feed.kind.todos", defaultValue: "TODOS") - } - } - - private var kindTint: Color { - switch snapshot.kind { - case .permissionRequest: return .orange - case .exitPlan: return .purple - case .question: return .blue - default: return snapshot.status.isPending ? .orange : .secondary.opacity(0.8) - } - } - - @ViewBuilder - private var actionArea: some View { - switch snapshot.payload { - case .permissionRequest(_, let toolName, let toolInputJSON, _): - PermissionActionArea( - toolName: toolName, - toolInputJSON: toolInputJSON, - source: snapshot.source, - status: snapshot.status, - onActionRow: onControlAction, - onApprove: { mode in - actions.approvePermission(snapshot.id, mode) - } - ) - case .exitPlan(_, let plan, _): - ExitPlanActionArea( - plan: plan, - source: snapshot.source, - status: snapshot.status, - isRowSelected: isSelected, - onFocusRow: onControlFocus, - onActionRow: onControlAction, - onBlurRow: onControlBlur, - onApprove: { mode, feedback in - actions.approveExitPlan(snapshot.id, mode, feedback) - } - ) - case .question(_, let questions): - QuestionActionArea( - questions: questions, - source: snapshot.source, - status: snapshot.status, - isRowSelected: isSelected, - onFocusRow: onControlFocus, - onActionRow: onControlAction, - onBlurRow: onControlBlur, - context: displayContext, - onReply: { selections in - actions.replyQuestion(snapshot.id, selections) - } - ) - case .stop: - StopActionArea( - draft: $stopDraft, - focusRequest: $stopFocusRequest, - onFocusRow: onControlFocus, - onActionRow: onControlAction, - onBlurRow: onControlBlur, - onSend: { text in actions.sendText(snapshot.workstreamId, text) } - ) - default: - TelemetryActionArea(snapshot: snapshot) - } - } - - private var primaryTitle: String { - switch snapshot.payload { - case .permissionRequest(_, let toolName, _, _): - return "\(snapshot.source.rawValue.capitalized) · \(toolName)" - case .exitPlan: - return "\(snapshot.source.rawValue.capitalized) · \(String(localized: "feed.kind.exitPlan", defaultValue: "Exit plan"))" - case .question: - return "\(snapshot.source.rawValue.capitalized) · \(String(localized: "feed.kind.question", defaultValue: "Question"))" - default: - if let title = snapshot.title, !title.isEmpty { - return "\(snapshot.source.rawValue.capitalized) · \(title)" - } - return snapshot.source.rawValue.capitalized - } - } - - private var helpText: String { - var lines: [String] = [primaryTitle] - if let cwd = snapshot.cwd { lines.append(cwd) } - lines.append(absoluteTime(snapshot.createdAt)) - return lines.joined(separator: "\n") - } - - private func resolvedBadgeLabel(_ decision: WorkstreamDecision) -> String { - let submitted = String(localized: "feed.badge.submitted", defaultValue: "Submitted") - switch decision { - case .permission(let m): - return "\(submitted) · \(m.displayLabel)" - case .exitPlan(let m, let feedback): - if let feedback, !feedback.isEmpty { - return "\(submitted) · " + String(localized: "feed.badge.refined", defaultValue: "refined") - } - return "\(submitted) · \(m.displayLabel)" - case .question: - return submitted - } - } - - private func statusTag(_ text: String, color: Color) -> some View { - Text(text) - .cmuxFont(size: 10, weight: .medium) - .foregroundColor(color) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill(color.opacity(0.12)) - ) - } - - private func relativeTime(_ date: Date) -> String { - Self.relativeFormatter.localizedString(for: date, relativeTo: Date()) - } - - private func absoluteTime(_ date: Date) -> String { - Self.absoluteFormatter.string(from: date) - } - - private static let relativeFormatter: RelativeDateTimeFormatter = { - let f = RelativeDateTimeFormatter() - f.unitsStyle = .short - return f - }() - - private static let absoluteFormatter: DateFormatter = { - let f = DateFormatter() - f.dateStyle = .short - f.timeStyle = .short - return f - }() -} - -// MARK: - Per-kind action areas - -private struct FeedContextBlock: View { - let context: WorkstreamContext - let source: WorkstreamSource - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - if let user = context.lastUserMessage { - FeedLabeledTextRow( - label: String(localized: "feed.context.you", defaultValue: "You:"), - text: user, - labelColor: .secondary, - textColor: .secondary - ) - } - if let preamble = context.assistantPreamble { - FeedLabeledTextRow( - label: agentLabel, - text: preamble, - labelColor: .secondary, - textColor: .secondary, - rendersMarkdown: source == .claude - ) - } - if let plan = context.planSummary { - FeedLabeledTextRow( - label: String(localized: "feed.context.plan", defaultValue: "Plan:"), - text: plan, - labelColor: Color.purple.opacity(0.85), - textColor: .secondary, - rendersMarkdown: source == .claude - ) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var agentLabel: String { - "\(source.rawValue.capitalized):" - } -} - -private struct FeedLabeledTextRow: View { - let label: String - let text: String - let labelColor: Color - let textColor: Color - var rendersMarkdown: Bool = false - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(label) - .cmuxFont(size: 10, weight: .semibold) - .foregroundColor(labelColor) - .frame(width: 48, alignment: .leading) - if rendersMarkdown { - FeedMarkdownInlineText( - text: text, - fontSize: 11, - foregroundColor: textColor - ) - } else { - Text(text) - .cmuxFont(size: 11) - .foregroundColor(textColor) - .fixedSize(horizontal: false, vertical: true) - } - } - } -} - -private struct PermissionActionArea: View { - let toolName: String - let toolInputJSON: String - let source: WorkstreamSource - let status: WorkstreamStatus - let onActionRow: () -> Void - let onApprove: (WorkstreamPermissionMode) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - toolLabel - codeBlock - if status.isPending { - HStack(spacing: 6) { - FeedButton(label: String(localized: "feed.permission.deny", defaultValue: "Deny"), - kind: .dark, size: .medium, fullWidth: true) { - onActionRow() - onApprove(.deny) - } - .accessibilityIdentifier("FeedPermissionDenyButton") - if FeedPermissionActionPolicy.supportsOncePermissionMode( - source: source, - toolInputJSON: toolInputJSON - ) { - FeedButton(label: String(localized: "feed.permission.once", defaultValue: "Allow Once"), - kind: .light, size: .medium, fullWidth: true) { - onActionRow() - onApprove(.once) - } - .accessibilityIdentifier("FeedPermissionAllowOnceButton") - } - if FeedPermissionActionPolicy.supportsAlwaysPermissionMode( - source: source, - toolInputJSON: toolInputJSON - ) { - FeedButton(label: String(localized: "feed.permission.always", defaultValue: "Always Allow"), - kind: .primary, size: .medium, fullWidth: true) { - onActionRow() - onApprove(.always) - } - .accessibilityIdentifier("FeedPermissionAlwaysAllowButton") - } - if FeedPermissionActionPolicy.supportsAllPermissionMode( - source: source, - toolInputJSON: toolInputJSON - ) { - FeedButton(label: String(localized: "feed.permission.all", defaultValue: "All tools"), - kind: .primary, size: .medium, fullWidth: true) { - onActionRow() - onApprove(.all) - } - .accessibilityIdentifier("FeedPermissionAllToolsButton") - } - if FeedPermissionActionPolicy.supportsBypassPermissions(source: source) { - FeedButton(label: String(localized: "feed.permission.bypass", defaultValue: "Bypass"), - kind: .destructive, size: .medium, fullWidth: true) { - onActionRow() - onApprove(.bypass) - } - .accessibilityIdentifier("FeedPermissionBypassButton") - } - } - } else if let badge = submittedBadge { - FeedButton( - label: badge, - leadingIcon: "checkmark", - kind: .success, - size: .medium, - fullWidth: true, - dimmed: true - ) {} - } - } - } - - private var submittedBadge: String? { - guard case .resolved(let decision, _) = status else { return nil } - let submitted = String(localized: "feed.badge.submitted", defaultValue: "Submitted") - if case .permission(let mode) = decision { - return "\(submitted) · \(mode.displayLabel)" - } - return submitted - } - - private var toolLabel: some View { - HStack(spacing: 5) { - Image(systemName: "exclamationmark.triangle.fill") - .cmuxFont(size: 10, weight: .medium) - .foregroundColor(.orange) - Text(toolName) - .cmuxFont(size: 11, weight: .semibold) - .foregroundColor(.orange) - } - } - - private var codeBlock: some View { - let preview = PermissionInputPreview( - toolName: toolName, - toolInputJSON: toolInputJSON - ) - return VStack(alignment: .leading, spacing: 6) { - if let primary = preview.primary { - HStack(alignment: .firstTextBaseline, spacing: 6) { - if let sigil = preview.sigil { - Text(sigil) - .cmuxFont(size: 11, weight: .medium, design: .monospaced) - .foregroundColor(.orange) - } - Text(primary) - .cmuxFont(size: 11, design: .monospaced) - .foregroundColor(.primary.opacity(0.95)) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - } - } - if let secondary = preview.secondary, !secondary.isEmpty { - Text(secondary) - .cmuxFont(size: 10) - .foregroundColor(.secondary.opacity(0.85)) - .fixedSize(horizontal: false, vertical: true) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.primary.opacity(0.05)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color.primary.opacity(0.10), lineWidth: 1) - ) - } -} - -/// Pulls a human-readable command + description out of an agent's -/// tool_input JSON. Handles Bash (`command` + `description`), Write / -/// Edit / Read (`file_path`), and falls back to the raw JSON. -private struct PermissionInputPreview { - let sigil: String? - let primary: String? - let secondary: String? - - init(toolName: String, toolInputJSON: String) { - let dict = (try? JSONSerialization.jsonObject( - with: Data(toolInputJSON.utf8) - )) as? [String: Any] ?? [:] - - switch toolName.lowercased() { - case "bash": - self.sigil = "$" - self.primary = (dict["command"] as? String) ?? toolInputJSON - self.secondary = (dict["description"] as? String) - case "write", "edit", "multiedit": - self.sigil = nil - self.primary = (dict["file_path"] as? String) ?? toolInputJSON - if toolName.lowercased() == "write" { - let content = (dict["content"] as? String) ?? "" - let preview = content.split(separator: "\n").first.map(String.init) ?? "" - self.secondary = preview.isEmpty ? nil : preview - } else { - self.secondary = nil - } - case "read": - self.sigil = nil - self.primary = (dict["file_path"] as? String) ?? toolInputJSON - self.secondary = nil - default: - self.sigil = nil - self.primary = toolInputJSON == "{}" ? nil : toolInputJSON - self.secondary = nil - } - } -} - -/// Single DRY button primitive used across every actionable card -/// (permission / plan / question / filter pills / option pills). -/// Replaces the old PermissionCTAButton / PlanCTAButton / -/// FeedPillButton trio so styling is defined in exactly one place. -struct FeedButton: View { - enum Kind: String { - /// Transparent pill that lights up on hover/selection. Used - /// for filter bar pills and single-select option pills. - case ghost - /// Soft neutral fill (e.g. Manual, disabled Submit). - case soft - /// Dark background with white text (Deny). - case dark - /// Light background with black text (Allow Once). - case light - /// Solid blue (Always Allow, Send feedback, active Submit). - case primary - /// Solid green (Auto, checked multi-select option, confirmations). - case success - /// Solid orange (warning actions). - case warning - /// Solid red (destructive deny). - case destructive - } - - enum Size { - case compact // filter bar / option pills - case medium // full-width CTAs - } - - let label: String - var leadingIcon: String? = nil - var trailingIcon: String? = nil - var kind: Kind = .ghost - var size: Size = .compact - var fullWidth: Bool = false - var isSelected: Bool = false - var dimmed: Bool = false - let action: () -> Void - - @Environment(\.colorScheme) private var colorScheme - @State private var isHovered: Bool = false -#if DEBUG - @AppStorage(FeedButtonDebugSettings.generationKey) private var debugStyleGeneration = 0 -#endif - - var body: some View { -#if DEBUG - #if compiler(>=6.2) - if #available(macOS 26.0, *), usesSystemGlassButtonStyle { - systemGlassButton - } else { - plainFeedButton - } - #else - plainFeedButton - #endif -#else - plainFeedButton -#endif - } - - private var plainFeedButton: some View { - Button { - performAction() - } label: { - labelContent - .foregroundColor(foreground) - .frame(maxWidth: fullWidth ? .infinity : nil) - .padding(.horizontal, horizontalPadding) - .padding(.vertical, verticalPadding) - .background(buttonBackground) - .overlay(buttonBorder) - .shadow( - color: buttonShadowColor, - radius: buttonShadowRadius, - x: 0, - y: buttonShadowY - ) - .opacity(dimmed ? 0.55 : 1.0) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in - handleHover(hovering) - } - .help(label) - } - - private var labelContent: some View { - HStack(spacing: iconSpacing) { - if let leadingIcon { - Image(systemName: leadingIcon) - .cmuxFont(size: iconSize, weight: .semibold) - } - Text(label) - .cmuxFont(size: labelSize, weight: .semibold) - if let trailingIcon { - Image(systemName: trailingIcon) - .cmuxFont(size: iconSize, weight: .semibold) - } - } - } - - private var standardLabelContent: some View { - HStack(spacing: 4) { - if let leadingIcon { - Image(systemName: leadingIcon) - } - Text(label) - if let trailingIcon { - Image(systemName: trailingIcon) - } - } - .cmuxFont(size: labelSize, weight: .semibold) - } - - private func performAction() { - // `dimmed` doubles as the disabled signal — swallow the - // click at the primitive so upstream action closures don't - // have to re-check. - guard !dimmed else { return } - action() - } - - private func handleHover(_ hovering: Bool) { - isHovered = hovering - // Only swap the cursor when the button is disabled — - // enabled buttons keep the default arrow so the Feed - // feels like the rest of the app. Pop on mouseout so a - // stale "not allowed" cursor doesn't stick. - if dimmed, hovering { - NSCursor.operationNotAllowed.push() - } else if dimmed, !hovering { - NSCursor.pop() - } - } - -#if DEBUG - private var usesSystemGlassButtonStyle: Bool { - _ = debugStyleGeneration - switch FeedButtonDebugSettings.visualStyle { - case .standardGlass, .standardTintedGlass, .nativeGlass, .nativeProminentGlass, .commandLight: - return true - case .solid, .glass, .liquid, .halo, .command, .outline, .flat: - return false - } - } - - #if compiler(>=6.2) - @available(macOS 26.0, *) - @ViewBuilder - private var systemGlassButton: some View { - if FeedButtonDebugSettings.visualStyle == .standardGlass { - standardSystemGlassButtonBase - .buttonStyle(.glass) - } else if FeedButtonDebugSettings.visualStyle == .standardTintedGlass { - standardSystemGlassButtonBase - .buttonStyle(.glass) - .tint(systemGlassTint) - } else if FeedButtonDebugSettings.visualStyle == .nativeProminentGlass { - systemGlassButtonBase - .buttonStyle(.glassProminent) - } else { - systemGlassButtonBase - .buttonStyle(.glass) - } - } - - @available(macOS 26.0, *) - private var standardSystemGlassButtonBase: some View { - Button { - performAction() - } label: { - standardLabelContent - .frame(maxWidth: fullWidth ? .infinity : nil) - } - .controlSize(size == .compact ? .small : .regular) - .disabled(dimmed) - .opacity(dimmed ? 0.55 : 1.0) - .onHover { hovering in - handleHover(hovering) - } - .help(label) - } - - @available(macOS 26.0, *) - private var systemGlassButtonBase: some View { - Button { - performAction() - } label: { - labelContent - .foregroundStyle(systemGlassForeground) - .frame(maxWidth: fullWidth ? .infinity : nil) - .padding(.horizontal, max(CGFloat(0), horizontalPadding - 2)) - .padding(.vertical, max(CGFloat(0), verticalPadding - 1)) - .contentShape( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - ) - } - .buttonBorderShape(.roundedRectangle(radius: cornerRadius)) - .controlSize(size == .compact ? .small : .regular) - .tint(systemGlassTint) - .disabled(dimmed) - .opacity(dimmed ? 0.55 : 1.0) - .onHover { hovering in - handleHover(hovering) - } - .help(label) - } - #endif - - private var systemGlassTint: Color { - glassEffectTint.opacity(FeedButtonDebugSettings.glassTintOpacity) - } - - private var systemGlassForeground: Color { - if let color = FeedButtonDebugSettings.color( - for: kind, - role: .foreground, - colorScheme: colorScheme - ) { - return color - } - - switch FeedButtonDebugSettings.visualStyle { - case .nativeProminentGlass: - return kind == .light ? .black : .white - case .nativeGlass: - return .primary - case .standardGlass, .standardTintedGlass, .solid, .glass, .liquid, .halo, .command, .commandLight, .outline, .flat: - return foreground - } - } -#endif - - // MARK: - Style resolution - - private var labelSize: CGFloat { size == .compact ? 10 : 10.5 } - private var iconSize: CGFloat { size == .compact ? 9 : 10 } - private var iconSpacing: CGFloat { size == .compact ? 3 : 5 } - private var cornerRadius: CGFloat { -#if DEBUG - _ = debugStyleGeneration - return size == .compact - ? CGFloat(FeedButtonDebugSettings.compactCornerRadius) - : CGFloat(FeedButtonDebugSettings.mediumCornerRadius) -#else - return size == .compact ? 5 : 6 -#endif - } - private var horizontalPadding: CGFloat { -#if DEBUG - _ = debugStyleGeneration - return size == .compact - ? CGFloat(FeedButtonDebugSettings.compactHorizontalPadding) - : CGFloat(FeedButtonDebugSettings.mediumHorizontalPadding) -#else - return size == .compact ? 8 : 12 -#endif - } - private var verticalPadding: CGFloat { -#if DEBUG - _ = debugStyleGeneration - return size == .compact - ? CGFloat(FeedButtonDebugSettings.compactVerticalPadding) - : CGFloat(FeedButtonDebugSettings.mediumVerticalPadding) -#else - return size == .compact ? 4 : 5 -#endif - } - - private var foreground: Color { -#if DEBUG - _ = debugStyleGeneration - if let color = FeedButtonDebugSettings.color( - for: kind, - role: .foreground, - colorScheme: colorScheme - ) { - return color - } -#endif - switch kind { - case .ghost: - return isSelected ? .primary : .primary.opacity(0.85) - case .soft: return .primary - case .dark: return .white - case .light: return .black - case .primary: return .white - case .success: return .white - case .warning: return .white - case .destructive: return .white - } - } - - private var backgroundFill: Color { -#if DEBUG - _ = debugStyleGeneration - if let color = FeedButtonDebugSettings.color( - for: kind, - role: isHovered ? .hoverBackground : .background, - colorScheme: colorScheme - ) { - return color - } -#endif - switch kind { - case .ghost: - if isSelected { return Color.primary.opacity(0.12) } - if isHovered { return Color.primary.opacity(0.06) } - return Color.clear - case .soft: - return isHovered ? Color.primary.opacity(0.16) : Color.primary.opacity(0.10) - case .dark: - return isHovered ? Color.black.opacity(0.85) : Color.black.opacity(0.75) - case .light: - return isHovered ? Color.white.opacity(0.96) : Color.white.opacity(0.88) - case .primary: - return isHovered - ? Color(red: 0.28, green: 0.55, blue: 0.95) - : Color(red: 0.24, green: 0.48, blue: 0.88) - case .success: - return isHovered - ? Color(red: 0.22, green: 0.72, blue: 0.42) - : Color(red: 0.18, green: 0.62, blue: 0.35) - case .warning: - return isHovered - ? Color(red: 0.95, green: 0.55, blue: 0.18) - : Color(red: 0.92, green: 0.54, blue: 0.29) - case .destructive: - return isHovered - ? Color(red: 0.85, green: 0.28, blue: 0.28) - : Color(red: 0.75, green: 0.22, blue: 0.22) - } - } - -#if DEBUG - private var glassEffectTint: Color { - _ = debugStyleGeneration - if let color = FeedButtonDebugSettings.color( - for: kind, - role: isHovered ? .hoverBackground : .background, - colorScheme: colorScheme - ) { - return color - } - - switch kind { - case .ghost: return Color.accentColor - case .soft: return Color.gray - case .dark: return Color.black - case .light: return Color.white - case .primary: return Color(red: 0.24, green: 0.48, blue: 0.88) - case .success: return Color(red: 0.18, green: 0.62, blue: 0.35) - case .warning: return Color(red: 0.92, green: 0.54, blue: 0.29) - case .destructive: return Color(red: 0.75, green: 0.22, blue: 0.22) - } - } -#endif - - @ViewBuilder - private var buttonBackground: some View { - let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) -#if DEBUG - let generation = debugStyleGeneration - switch generation >= 0 ? FeedButtonDebugSettings.visualStyle : .solid { - case .solid: - shape.fill(backgroundFill) - case .standardGlass: - shape.fill(.regularMaterial) - case .standardTintedGlass: - shape - .fill(.regularMaterial) - .overlay( - shape.fill( - backgroundFill.opacity( - FeedButtonDebugSettings.glassTintOpacity - ) - ) - ) - case .glass: - shape - .fill(.thinMaterial) - .overlay( - shape.fill( - backgroundFill.opacity( - FeedButtonDebugSettings.glassTintOpacity - ) - ) - ) - case .nativeGlass: - #if compiler(>=6.2) - if #available(macOS 26.0, *) { - shape - .fill(Color.clear) - .glassEffect( - .regular - .tint(glassEffectTint.opacity(FeedButtonDebugSettings.glassTintOpacity)) - .interactive(!dimmed), - in: shape - ) - } else { - shape - .fill(.ultraThinMaterial) - .overlay(shape.fill(backgroundFill.opacity(0.20))) - } - #else - shape - .fill(.ultraThinMaterial) - .overlay(shape.fill(backgroundFill.opacity(0.20))) - #endif - case .nativeProminentGlass: - #if compiler(>=6.2) - if #available(macOS 26.0, *) { - shape - .fill(Color.clear) - .glassEffect( - .regular - .tint(glassEffectTint.opacity(FeedButtonDebugSettings.glassTintOpacity)) - .interactive(!dimmed), - in: shape - ) - .overlay( - shape.fill( - backgroundFill.opacity(isHovered || isSelected ? 0.30 : 0.18) - ) - ) - } else { - shape - .fill(.regularMaterial) - .overlay(shape.fill(backgroundFill.opacity(0.26))) - } - #else - shape - .fill(.regularMaterial) - .overlay(shape.fill(backgroundFill.opacity(0.26))) - #endif - case .liquid: - shape - .fill(.ultraThinMaterial) - .overlay( - shape.fill( - backgroundFill.opacity( - FeedButtonDebugSettings.glassTintOpacity - ) - ) - ) - .overlay( - shape.fill( - LinearGradient( - colors: [ - Color.white.opacity(isHovered || isSelected ? 0.42 : 0.30), - Color.white.opacity(0.08), - Color.clear, - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .blendMode(.screen) - ) - case .halo: - shape - .fill(.thinMaterial) - .overlay( - shape.fill( - backgroundFill.opacity( - FeedButtonDebugSettings.glassTintOpacity - ) - ) - ) - .overlay( - shape.fill( - RadialGradient( - colors: [ - Color.white.opacity(isHovered || isSelected ? 0.30 : 0.18), - Color.clear, - ], - center: .topLeading, - startRadius: 1, - endRadius: 54 - ) - ) - .blendMode(.screen) - ) - case .command: - shape - .fill(.regularMaterial) - .overlay(shape.fill(Color.black.opacity(0.28))) - .overlay( - shape.fill( - backgroundFill.opacity( - FeedButtonDebugSettings.glassTintOpacity - ) - ) - ) - case .commandLight: - shape - .fill(.regularMaterial) - .overlay(shape.fill(Color.white.opacity(0.22))) - .overlay( - shape.fill( - backgroundFill.opacity( - FeedButtonDebugSettings.glassTintOpacity - ) - ) - ) - case .outline: - shape.fill(isHovered || isSelected ? backgroundFill.opacity(0.14) : Color.clear) - case .flat: - shape.fill(isHovered || isSelected ? backgroundFill.opacity(0.12) : Color.clear) - } -#else - shape.fill(backgroundFill) -#endif - } - - @ViewBuilder - private var buttonBorder: some View { -#if DEBUG - let generation = debugStyleGeneration - let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - switch generation >= 0 ? FeedButtonDebugSettings.visualStyle : .solid { - case .solid: - EmptyView() - case .standardGlass: - shape.stroke(Color.white.opacity(0.12), lineWidth: FeedButtonDebugSettings.borderWidth) - case .standardTintedGlass: - shape.stroke(backgroundFill.opacity(0.22), lineWidth: FeedButtonDebugSettings.borderWidth) - case .glass: - shape.stroke(Color.white.opacity(0.16), lineWidth: 0.75) - case .nativeGlass: - shape.stroke(Color.white.opacity(0.14), lineWidth: FeedButtonDebugSettings.borderWidth) - case .nativeProminentGlass: - shape.stroke(Color.white.opacity(0.18), lineWidth: FeedButtonDebugSettings.borderWidth) - case .liquid: - shape.stroke( - LinearGradient( - colors: [ - Color.white.opacity(0.42), - backgroundFill.opacity(0.28), - Color.white.opacity(0.10), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ), - lineWidth: FeedButtonDebugSettings.borderWidth - ) - case .halo: - shape.stroke( - backgroundFill.opacity(isHovered || isSelected ? 0.55 : 0.34), - lineWidth: FeedButtonDebugSettings.borderWidth - ) - case .command: - shape.stroke(Color.white.opacity(0.12), lineWidth: FeedButtonDebugSettings.borderWidth) - case .commandLight: - shape.stroke(Color.black.opacity(0.12), lineWidth: FeedButtonDebugSettings.borderWidth) - case .outline: - shape.stroke(backgroundFill.opacity(0.75), lineWidth: FeedButtonDebugSettings.borderWidth) - case .flat: - EmptyView() - } -#else - EmptyView() -#endif - } - - private var buttonShadowColor: Color { -#if DEBUG - _ = debugStyleGeneration - switch FeedButtonDebugSettings.visualStyle { - case .halo: - return backgroundFill.opacity(isHovered || isSelected ? 0.44 : 0.24) - case .liquid: - return backgroundFill.opacity(isHovered || isSelected ? 0.18 : 0.10) - case .command: - return Color.black.opacity(0.28) - case .commandLight: - return Color.black.opacity(isHovered || isSelected ? 0.16 : 0.08) - case .nativeProminentGlass: - return backgroundFill.opacity(isHovered || isSelected ? 0.18 : 0.10) - case .standardGlass, .standardTintedGlass, .solid, .glass, .nativeGlass, .outline, .flat: - return Color.clear - } -#else - return Color.clear -#endif - } - - private var buttonShadowRadius: CGFloat { -#if DEBUG - _ = debugStyleGeneration - switch FeedButtonDebugSettings.visualStyle { - case .halo: return isHovered || isSelected ? 9 : 6 - case .liquid: return isHovered || isSelected ? 5 : 3 - case .nativeProminentGlass: return isHovered || isSelected ? 5 : 3 - case .command: return 3 - case .commandLight: return isHovered || isSelected ? 4 : 2 - case .standardGlass, .standardTintedGlass, .solid, .glass, .nativeGlass, .outline, .flat: return 0 - } -#else - return 0 -#endif - } - - private var buttonShadowY: CGFloat { -#if DEBUG - _ = debugStyleGeneration - switch FeedButtonDebugSettings.visualStyle { - case .halo: return 2 - case .liquid, .nativeProminentGlass, .command, .commandLight: return 1 - case .standardGlass, .standardTintedGlass, .solid, .glass, .nativeGlass, .outline, .flat: return 0 - } -#else - return 0 -#endif - } -} - -private struct ExitPlanActionArea: View { - let plan: String - let source: WorkstreamSource - let status: WorkstreamStatus - let isRowSelected: Bool - let onFocusRow: () -> Void - let onActionRow: () -> Void - let onBlurRow: () -> Void - let onApprove: (WorkstreamExitPlanMode, String?) -> Void - - @State private var feedback: String = "" - @FocusState private var feedbackFocused: Bool - - private var trimmedFeedback: String { - feedback.trimmingCharacters(in: .whitespacesAndNewlines) - } - private var hasFeedback: Bool { !trimmedFeedback.isEmpty } - private var preview: WorkstreamExitPlanPreview { - WorkstreamExitPlanPreview(rawPlan: plan) - } - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - PlanBodyView( - plan: preview.planText, - rendersMarkdown: source == .claude - ) - if !preview.allowedPrompts.isEmpty { - ExitPlanAllowedPromptsView(prompts: preview.allowedPrompts) - } - if let path = preview.planFilePath { - ExitPlanPlanFileView(path: path) - } - if status.isPending { - TextField( - String( - localized: "feed.exitplan.feedback.placeholder", - defaultValue: "Tell Claude what to change…" - ), - text: $feedback, - axis: .vertical - ) - .textFieldStyle(.plain) - .cmuxFont(size: 12) - .tint(Color.primary.opacity(0.75)) - .focused($feedbackFocused) - .lineLimit(2...5) - .padding(10) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.primary.opacity(feedbackFocused ? 0.075 : 0.06)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke( - Color.primary.opacity(feedbackFocused ? 0.20 : (hasFeedback ? 0.25 : 0.10)), - lineWidth: 1 - ) - ) - .onChange(of: feedbackFocused) { _, focused in - if focused { - onFocusRow() - } else { - onBlurRow() - } - } - HStack(spacing: 6) { - FeedButton( - label: hasFeedback - ? String(localized: "feed.exitplan.refine", - defaultValue: "Send feedback") - : String(localized: "feed.exitplan.ultraplan", - defaultValue: "Ultraplan"), - kind: hasFeedback ? .primary : .soft, - size: .medium, fullWidth: true - ) { - feedbackFocused = false - onActionRow() - // Feedback always wins over mode; hook translates - // non-empty feedback into block+reason. - onApprove(hasFeedback ? .manual : .ultraplan, hasFeedback ? trimmedFeedback : nil) - } - FeedButton( - label: String(localized: "feed.exitplan.manual", - defaultValue: "Manual"), - kind: .soft, - size: .medium, fullWidth: true, - dimmed: hasFeedback - ) { - feedbackFocused = false - onActionRow() - onApprove(.manual, hasFeedback ? trimmedFeedback : nil) - } - FeedButton( - label: String(localized: "feed.exitplan.auto", - defaultValue: "Auto"), - kind: .success, - size: .medium, fullWidth: true, - dimmed: hasFeedback - ) { - feedbackFocused = false - onActionRow() - onApprove(.autoAccept, hasFeedback ? trimmedFeedback : nil) - } - } - } else if let badge = submittedBadge { - FeedButton( - label: badge, - leadingIcon: "checkmark", - kind: .success, - size: .medium, - fullWidth: true, - dimmed: true - ) {} - } - } - .onChange(of: isRowSelected) { _, selected in - if !selected { - feedbackFocused = false - } - } - } - - private var submittedBadge: String? { - guard case .resolved(let decision, _) = status else { return nil } - let submitted = String(localized: "feed.badge.submitted", defaultValue: "Submitted") - switch decision { - case .exitPlan(let mode, let feedback): - if let feedback, !feedback.isEmpty { - return "\(submitted) · " + String( - localized: "feed.badge.refined", defaultValue: "refined" - ) - } - return "\(submitted) · \(mode.displayLabel)" - default: - return submitted - } - } -} - -private struct ExitPlanAllowedPromptsView: View { - let prompts: [WorkstreamAllowedPrompt] - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 5) { - Image(systemName: "checklist") - .cmuxFont(size: 10, weight: .medium) - .foregroundColor(Color.purple.opacity(0.85)) - Text(String(localized: "feed.exitplan.allowedPrompts", defaultValue: "Allowed prompts")) - .cmuxFont(size: 11, weight: .semibold) - .foregroundColor(Color.purple.opacity(0.9)) - } - VStack(alignment: .leading, spacing: 5) { - ForEach(Array(prompts.enumerated()), id: \.offset) { _, prompt in - HStack(alignment: .firstTextBaseline, spacing: 6) { - if !prompt.tool.isEmpty { - Text(prompt.tool) - .cmuxFont(size: 10, weight: .semibold) - .foregroundColor(.purple) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color.purple.opacity(0.14)) - ) - } - Text(prompt.prompt) - .cmuxFont(size: 11) - .foregroundColor(.primary.opacity(0.86)) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.purple.opacity(0.06)) - ) - } - } -} - -private struct ExitPlanPlanFileView: View { - let path: String - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: "doc.text") - .cmuxFont(size: 10, weight: .medium) - .foregroundColor(.secondary) - Text(String(localized: "feed.exitplan.planFile", defaultValue: "Plan file")) - .cmuxFont(size: 10, weight: .semibold) - .foregroundColor(.secondary) - Text((path as NSString).lastPathComponent) - .cmuxFont(size: 10, design: .monospaced) - .foregroundColor(.secondary.opacity(0.85)) - .lineLimit(1) - .truncationMode(.middle) - .help(path) - } - } -} - -private struct FeedMarkdownInlineText: View { - let text: String - let fontSize: CGFloat - let weight: Font.Weight? - let foregroundColor: Color - - init( - text: String, - fontSize: CGFloat, - weight: Font.Weight? = nil, - foregroundColor: Color - ) { - self.text = text - self.fontSize = fontSize - self.weight = weight - self.foregroundColor = foregroundColor - } - - var body: some View { - let parsed = (try? AttributedString( - markdown: text, - options: AttributedString.MarkdownParsingOptions( - interpretedSyntax: .inlineOnlyPreservingWhitespace - ) - )) ?? AttributedString(text) - Text(parsed) - .cmuxFont(size: fontSize, weight: weight ?? .regular) - .foregroundColor(foregroundColor) - .fixedSize(horizontal: false, vertical: true) - } -} - -/// Renders plan text as a stack of small structured sections. Block -/// headings, lists, and paragraphs keep the Feed's compact rhythm, while -/// Claude markdown inside each line gets parsed tastefully. Heading text -/// intentionally stays at body scale. -private struct PlanBodyView: View { - let plan: String - let rendersMarkdown: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in - switch block { - case .heading(let text): - markdownText( - text, - weight: .semibold, - color: .primary.opacity(0.95) - ) - .padding(.top, 2) - case .paragraph(let text): - markdownText(text, color: .primary.opacity(0.85)) - case .numbered(let items): - VStack(alignment: .leading, spacing: 2) { - ForEach(Array(items.enumerated()), id: \.offset) { _, item in - HStack(alignment: .top, spacing: 5) { - Text("\(item.index).") - .cmuxFont(size: 11, weight: .medium, monospacedDigit: true) - .foregroundColor(.secondary) - markdownText(item.text, color: .primary.opacity(0.85)) - } - } - } - case .bulleted(let items): - VStack(alignment: .leading, spacing: 2) { - ForEach(Array(items.enumerated()), id: \.offset) { _, item in - HStack(alignment: .top, spacing: 8) { - Circle() - .fill(Color.blue.opacity(0.85)) - .frame(width: 3.5, height: 3.5) - .padding(.top, 5.5) - .frame(width: 10, alignment: .center) - markdownText(item, color: .primary.opacity(0.85)) - } - } - } - } - } - } - } - - @ViewBuilder - private func markdownText( - _ text: String, - weight: Font.Weight? = nil, - color: Color - ) -> some View { - if rendersMarkdown { - FeedMarkdownInlineText( - text: text, - fontSize: 11, - weight: weight, - foregroundColor: color - ) - } else { - Text(text) - .cmuxFont(size: 11, weight: weight ?? .regular) - .foregroundColor(color) - .fixedSize(horizontal: false, vertical: true) - } - } - - private enum Block { - case heading(String) - case paragraph(String) - case numbered([NumberedItem]) - case bulleted([String]) - } - - private struct NumberedItem { - let index: Int - let text: String - } - - private var blocks: [Block] { - var out: [Block] = [] - var buffer: [String] = [] - func flushParagraph() { - guard !buffer.isEmpty else { return } - let joined = buffer.joined(separator: " ") - out.append(.paragraph(joined)) - buffer = [] - } - var numbered: [NumberedItem] = [] - func flushNumbered() { - if !numbered.isEmpty { - out.append(.numbered(numbered)) - numbered = [] - } - } - var bulleted: [String] = [] - func flushBulleted() { - if !bulleted.isEmpty { - out.append(.bulleted(bulleted)) - bulleted = [] - } - } - - for rawLine in plan.split(separator: "\n", omittingEmptySubsequences: false) { - let line = String(rawLine).trimmingCharacters(in: .whitespaces) - if line.isEmpty { - flushParagraph(); flushNumbered(); flushBulleted() - continue - } - // **Bold heading** or ## heading or "Word:" on its own line - if line.hasPrefix("**") && line.hasSuffix("**") && line.count > 4 { - flushParagraph(); flushNumbered(); flushBulleted() - out.append(.heading(String(line.dropFirst(2).dropLast(2)))) - continue - } - if let heading = markdownHeadingText(line) { - flushParagraph(); flushNumbered(); flushBulleted() - out.append(.heading(heading)) - continue - } - if line.hasSuffix(":") && line.count <= 40 - && !line.contains(" ") == false && line.split(separator: " ").count <= 4 - { - flushParagraph(); flushNumbered(); flushBulleted() - out.append(.heading(line)) - continue - } - // Numbered list - if let match = line.range( - of: #"^(\d+)\.\s+(.+)$"#, - options: .regularExpression - ) { - flushParagraph(); flushBulleted() - let text = String(line[match]) - if let dotIdx = text.firstIndex(of: ".") { - let numStr = String(text[text.startIndex.. String? { - guard line.hasPrefix("#") else { return nil } - let hashCount = line.prefix(while: { $0 == "#" }).count - guard (1...6).contains(hashCount), - line.count > hashCount, - line[line.index(line.startIndex, offsetBy: hashCount)] == " " - else { return nil } - return String(line.dropFirst(hashCount + 1)) - } -} - -private struct QuestionActionArea: View { - let questions: [WorkstreamQuestionPrompt] - let source: WorkstreamSource - let status: WorkstreamStatus - let isRowSelected: Bool - let onFocusRow: () -> Void - let onActionRow: () -> Void - let onBlurRow: () -> Void - let context: WorkstreamContext? - let onReply: ([String]) -> Void - - private static let skipInterviewAndPlanAnswer = "Skip interview and plan immediately" - private static let customAnswerSelectionId = "__cmux_custom_answer__" - - // Per-question selections keyed by question id. - @State private var selections: [String: Set] = [:] - // Per-question "Type something…" free-form answers. When - // non-empty, wins over preset option selections for that - // question — mirrors Claude's TUI fallback. - @State private var freeTexts: [String: String] = [:] - @State private var customAnswerFocusKey: String? - @State private var customAnswerFocusRequest = 0 - @Environment(\.cmuxGlobalFontMagnificationPercent) private var globalFontPercent - - var body: some View { - let _ = globalFontPercent - VStack(alignment: .leading, spacing: 12) { - if shouldRenderLongForm, let q = questions.first { - longFormBlock(question: q) - } else { - ForEach(Array(questions.enumerated()), id: \.offset) { idx, q in - questionBlock(index: idx + 1, question: q) - } - } - if shouldShowSkipInterviewCTA { - HStack(spacing: 8) { - skipInterviewCTA - submitCTA - } - } else { - submitCTA - } - } - .onChange(of: isRowSelected) { _, selected in - if !selected { - clearCustomAnswerFocus() - } - } - } - - private var shouldRenderLongForm: Bool { - // Long-form: single question whose options carry descriptions - // (e.g. Claude's AskUserQuestion with `header` + per-option - // detail). Multi-option list with a bigger rich-text card per - // option, click-to-select. - guard questions.count == 1, let q = questions.first else { return false } - return q.options.contains { $0.description?.isEmpty == false } - } - - private var agentLabel: String { - "\(source.rawValue.capitalized):" - } - - /// Long-form rendering: single question with rich options. Each - /// option becomes a tappable card with numbered index, title, and - /// description. Selecting only updates local state; the Submit - /// button sends the answer. - @ViewBuilder - private func longFormBlock(question: WorkstreamQuestionPrompt) -> some View { - VStack(alignment: .leading, spacing: 6) { - if !question.prompt.isEmpty { - FeedLabeledTextRow( - label: agentLabel, - text: question.prompt, - labelColor: .secondary, - textColor: .primary.opacity(0.95) - ) - } - ForEach(Array(question.options.enumerated()), id: \.offset) { idx, option in - longFormOptionCard( - questionId: question.id, - multi: question.multiSelect, - index: idx + 1, - option: option - ) - } - if status.isPending { - longFormCustomAnswerCard( - questionId: question.id, - multi: question.multiSelect, - index: question.options.count + 1 - ) - } - } - } - - private func longFormOptionCard( - questionId: String, - multi: Bool, - index: Int, - option: WorkstreamQuestionOption - ) -> some View { - let selected = selections[questionId]?.contains(option.id) == true - return Button { - guard status.isPending else { return } - onActionRow() - clearCustomAnswerFocus() - var current = selections[questionId] ?? [] - if multi { - if current.contains(option.id) { - current.remove(option.id) - } else { - current.insert(option.id) - } - } else { - current = [option.id] - } - selections[questionId] = current - } label: { - HStack(alignment: .top, spacing: 10) { - Text("\(index)") - .cmuxFont(size: 11, weight: .bold, monospacedDigit: true) - .foregroundColor(selected ? .white : .secondary) - .frame(width: 20, height: 20) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(selected ? Color(red: 0.24, green: 0.48, blue: 0.88) : Color.primary.opacity(0.08)) - ) - VStack(alignment: .leading, spacing: 2) { - Text(option.label) - .cmuxFont(size: 12, weight: .semibold) - .foregroundColor(.primary) - if let description = option.description, !description.isEmpty { - Text(description) - .cmuxFont(size: 11) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - Spacer() - Image(systemName: selected ? "checkmark.circle.fill" : "circle") - .cmuxFont(size: 12, weight: .medium) - .foregroundColor(selected ? Color(red: 0.24, green: 0.48, blue: 0.88) : .secondary.opacity(0.45)) - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(selected ? Color(red: 0.24, green: 0.48, blue: 0.88).opacity(0.14) : Color.primary.opacity(0.05)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(selected ? Color(red: 0.24, green: 0.48, blue: 0.88).opacity(0.55) : Color.clear, lineWidth: 1) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(!status.isPending) - } - - private func longFormCustomAnswerCard( - questionId: String, - multi: Bool, - index: Int - ) -> some View { - let customId = Self.customAnswerSelectionId - let selected = selections[questionId]?.contains(customId) == true - let focusKey = customAnswerFocusKey(questionId) - let font = GlobalFontMagnification.systemFont(ofSize: 12, weight: .semibold) - return HStack(alignment: .top, spacing: 10) { - Text("\(index)") - .cmuxFont(size: 11, weight: .bold, monospacedDigit: true) - .foregroundColor(selected ? .white : .secondary) - .frame(width: 20, height: 20) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(selected ? Color(red: 0.24, green: 0.48, blue: 0.88) : Color.primary.opacity(0.08)) - ) - customAnswerField( - text: customAnswerBinding(questionId: questionId, multi: multi), - focusRequest: focusRequest(forCustomAnswerKey: focusKey), - font: font, - onFocus: { - onFocusRow() - selectCustomAnswer(questionId: questionId, multi: multi) - }, - onBlur: onBlurRow - ) - Image(systemName: selected ? "checkmark.circle.fill" : "circle") - .cmuxFont(size: 12, weight: .medium) - .foregroundColor(selected ? Color(red: 0.24, green: 0.48, blue: 0.88) : .secondary.opacity(0.45)) - .padding(.leading, 8) - .padding(.top, 3) - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(selected ? Color(red: 0.24, green: 0.48, blue: 0.88).opacity(0.14) : Color.primary.opacity(0.05)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(selected ? Color(red: 0.24, green: 0.48, blue: 0.88).opacity(0.55) : Color.clear, lineWidth: 1) - ) - .contentShape(Rectangle()) - .onTapGesture { - guard status.isPending else { return } - onFocusRow() - selectCustomAnswer(questionId: questionId, multi: multi) - requestCustomAnswerFocus(focusKey) - } - .feedIBeamCursorOnHover(enabled: status.isPending) - .disabled(!status.isPending) - } - - private func questionBlock(index: Int, question: WorkstreamQuestionPrompt) -> some View { - VStack(alignment: .leading, spacing: 5) { - HStack(alignment: .top, spacing: 5) { - Text("\(index).") - .cmuxFont(size: 11, weight: .semibold, monospacedDigit: true) - .foregroundColor(.blue) - Text(question.prompt) - .cmuxFont(size: 11, weight: .medium) - .foregroundColor(.primary.opacity(0.95)) - .fixedSize(horizontal: false, vertical: true) - } - if question.multiSelect { - HStack(spacing: 3) { - Image(systemName: "checklist") - .cmuxFont(size: 8, weight: .medium) - Text(String(localized: "feed.question.multiSelect", defaultValue: "Multi-select")) - .cmuxFont(size: 9, weight: .semibold) - .tracking(0.3) - } - .foregroundColor(.orange) - .padding(.horizontal, 4) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill(Color.orange.opacity(0.18)) - ) - } - if question.options.isEmpty { - Text(String(localized: "feed.question.noOptions", - defaultValue: "Agent provided no options.")) - .cmuxFont(size: 10) - .foregroundColor(.secondary) - } else { - WrapHStack(spacing: 6) { - ForEach(question.options, id: \.id) { option in - optionPill(questionId: question.id, option: option, multi: question.multiSelect) - } - } - } - if status.isPending { - freeFormField(questionId: question.id, multi: question.multiSelect) - } - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.primary.opacity(0.04)) - ) - } - - /// "Type something…" free-form text field — mirrors Claude's TUI - /// option 4 (custom answer). When non-empty it wins over the - /// preset option selection for that question on submit. - private func freeFormField(questionId: String, multi: Bool) -> some View { - let focusKey = customAnswerFocusKey(questionId) - let font = GlobalFontMagnification.systemFont(ofSize: 11) - return customAnswerField( - text: customAnswerBinding(questionId: questionId, multi: multi), - focusRequest: focusRequest(forCustomAnswerKey: focusKey), - font: font, - onFocus: { - onFocusRow() - selectCustomAnswer(questionId: questionId, multi: multi) - }, - onBlur: onBlurRow - ) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 5, style: .continuous) - .fill(Color.primary.opacity(0.05)) - ) - .overlay( - RoundedRectangle(cornerRadius: 5, style: .continuous) - .stroke(Color.primary.opacity(0.10), lineWidth: 1) - ) - .contentShape(Rectangle()) - .feedIBeamCursorOnHover(enabled: status.isPending) - .onTapGesture { - guard status.isPending else { return } - onFocusRow() - selectCustomAnswer(questionId: questionId, multi: multi) - requestCustomAnswerFocus(focusKey) - } - } - - private func customAnswerField( - text: Binding, - focusRequest: Int?, - font: NSFont, - onFocus: @escaping () -> Void, - onBlur: @escaping () -> Void - ) -> some View { - FeedInlineTextField( - text: text, - focusRequest: focusRequest, - placeholder: String(localized: "feed.question.typeSomething", - defaultValue: "Type something..."), - isEnabled: status.isPending, - font: font, - onFocus: onFocus, - onBlur: onBlur, - onSubmit: nil - ) - .frame( - maxWidth: .infinity, - minHeight: FeedInlineTextEditorView.minimumHeight(for: font), - alignment: .leading - ) - .layoutPriority(1) - } - - private func customAnswerBinding(questionId: String, multi: Bool) -> Binding { - Binding( - get: { freeTexts[questionId] ?? "" }, - set: { value in - freeTexts[questionId] = value - var current = selections[questionId] ?? [] - if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - current.remove(Self.customAnswerSelectionId) - } else if multi { - current.insert(Self.customAnswerSelectionId) - } else { - current = [Self.customAnswerSelectionId] - } - selections[questionId] = current - } - ) - } - - private func customAnswerFocusKey(_ questionId: String) -> String { - "\(questionId)::custom" - } - - private func focusRequest(forCustomAnswerKey focusKey: String) -> Int? { - customAnswerFocusKey == focusKey ? customAnswerFocusRequest : nil - } - - private func requestCustomAnswerFocus(_ focusKey: String) { - customAnswerFocusKey = focusKey - customAnswerFocusRequest += 1 - } - - private func selectCustomAnswer(questionId: String, multi: Bool) { - var current = selections[questionId] ?? [] - if multi { - current.insert(Self.customAnswerSelectionId) - } else { - current = [Self.customAnswerSelectionId] - } - selections[questionId] = current - } - - private func clearCustomAnswerFocus() { - customAnswerFocusKey = nil - } - - private func optionPill( - questionId: String, - option: WorkstreamQuestionOption, - multi: Bool - ) -> some View { - let selected = selections[questionId]?.contains(option.id) == true - let leading: String? = multi - ? (selected ? "checkmark.square.fill" : "square") - : nil - let selectedKind: FeedButton.Kind = multi ? .success : .primary - return FeedButton( - label: option.label, - leadingIcon: leading, - kind: selected ? selectedKind : .soft, - size: .compact, - dimmed: !status.isPending - ) { - guard status.isPending else { return } - onActionRow() - clearCustomAnswerFocus() - var current = selections[questionId] ?? [] - if multi { - if current.contains(option.id) { current.remove(option.id) } - else { current.insert(option.id) } - } else { - current = [option.id] - } - selections[questionId] = current - } - } - - /// One answer string per question: the user's free-form text if - /// they typed any, otherwise the labels of the selected options - /// joined by ", ". Questions with no answer are omitted entirely - /// so the agent doesn't see "question 2: ". - private var composedAnswers: [String] { - var out: [String] = [] - for q in questions { - let freeText = (freeTexts[q.id] ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - let ids = selections[q.id] ?? [] - if !freeText.isEmpty, ids.contains(Self.customAnswerSelectionId) { - out.append(freeText) - continue - } - guard !ids.isEmpty else { continue } - let labels = q.options - .filter { ids.contains($0.id) } - .map(\.label) - if !labels.isEmpty { - out.append(labels.joined(separator: ", ")) - } - } - return out - } - - private var hasAnyAnswer: Bool { !composedAnswers.isEmpty } - - private var canSubmitEmptyAnswer: Bool { - !questions.isEmpty && questions.allSatisfy { $0.options.isEmpty } - } - - private var shouldShowSkipInterviewCTA: Bool { - status.isPending && isPlanAskUserQuestion - } - - private var isPlanAskUserQuestion: Bool { - guard source == .claude else { return false } - if let mode = context?.permissionMode { - return mode.caseInsensitiveCompare("plan") == .orderedSame - } - return questionTextLooksLikePlanInterview - } - - private var questionTextLooksLikePlanInterview: Bool { - let fragments: [String?] = questions.flatMap { q in - let questionFragments: [String?] = [q.header, q.prompt] - let optionFragments: [String?] = q.options.flatMap { option in - [option.label, option.description] - } - return questionFragments + optionFragments - } - let text = ([context?.lastUserMessage, context?.assistantPreamble] + fragments) - .compactMap { $0 } - .joined(separator: " ") - .lowercased() - return text.contains("plan mode") - || text.contains("make a plan") - || text.contains("plan-only") - || text.contains("plan immediately") - } - - private var submitCTA: some View { - let isPending = status.isPending - let enabled = isPending && (hasAnyAnswer || canSubmitEmptyAnswer) - return FeedButton( - label: isPending - ? String(localized: "feed.question.submitAll", - defaultValue: "Submit All Answers") - : String(localized: "feed.badge.submitted", - defaultValue: "Submitted"), - leadingIcon: isPending ? "checkmark.circle.fill" : "checkmark", - kind: enabled ? .primary : (isPending ? .soft : .success), - size: .medium, - fullWidth: true, - dimmed: !enabled - ) { - onActionRow() - // Selections carry human-readable answer strings (one per - // answered question) so the hook can feed them straight - // back to the agent as the user's reply. - onReply(composedAnswers) - } - } - - private var skipInterviewCTA: some View { - FeedButton( - label: String(localized: "feed.question.skipInterviewPlan", - defaultValue: "Skip + plan immediately"), - leadingIcon: "forward.end.fill", - kind: .soft, - size: .medium, - fullWidth: true - ) { - onActionRow() - onReply([Self.skipInterviewAndPlanAnswer]) - } - } -} - -private final class FeedInlinePassthroughLabel: NSTextField { - override func hitTest(_ point: NSPoint) -> NSView? { nil } -} - -private final class FeedInlineNativeTextView: NSTextView, FeedKeyboardFocusResponder { - private static weak var activeEditor: FeedInlineNativeTextView? - - var onActivate: (() -> Void)? - var onEscape: (() -> Void)? - var onSubmit: (() -> Void)? - - static func blurActiveEditor() { - guard let activeEditor else { return } - guard let window = activeEditor.window else { - if Self.activeEditor === activeEditor { - Self.activeEditor = nil - } - return - } - guard window.firstResponder === activeEditor else { - if Self.activeEditor === activeEditor { - Self.activeEditor = nil - } - return - } -#if DEBUG - dlog("feed.editor.blurActive fr=\(feedDebugResponderSummary(window.firstResponder))") -#endif - window.makeFirstResponder(nil) - } - - override func mouseDown(with event: NSEvent) { -#if DEBUG - dlog("feed.editor.mouseDown frBefore=\(feedDebugResponderSummary(window?.firstResponder))") -#endif - onActivate?() - super.mouseDown(with: event) - } - - override func performKeyEquivalent(with event: NSEvent) -> Bool { - if event.type == .keyDown, event.keyCode == 53 { -#if DEBUG - dlog("feed.editor.escape fr=\(feedDebugResponderSummary(window?.firstResponder))") -#endif - onEscape?() - return true - } - return super.performKeyEquivalent(with: event) - } - - override func keyDown(with event: NSEvent) { - let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let shouldSubmit = (event.keyCode == 36 || event.keyCode == 76) - && normalizedFlags.intersection([.shift, .option, .command, .control]).isEmpty - if shouldSubmit, !hasMarkedText(), let onSubmit { - onSubmit() - return - } - super.keyDown(with: event) - } - - override func resetCursorRects() { - super.resetCursorRects() - addCursorRect(bounds, cursor: .iBeam) - } - - override func becomeFirstResponder() -> Bool { - let didBecomeFirstResponder = super.becomeFirstResponder() - if didBecomeFirstResponder { - Self.activeEditor = self - onActivate?() - } -#if DEBUG - dlog("feed.editor.become result=\(didBecomeFirstResponder ? 1 : 0) fr=\(feedDebugResponderSummary(window?.firstResponder))") -#endif - return didBecomeFirstResponder - } - - override func resignFirstResponder() -> Bool { - let didResignFirstResponder = super.resignFirstResponder() - if didResignFirstResponder, Self.activeEditor === self { - Self.activeEditor = nil - } -#if DEBUG - dlog("feed.editor.resign result=\(didResignFirstResponder ? 1 : 0) fr=\(feedDebugResponderSummary(window?.firstResponder))") -#endif - return didResignFirstResponder - } -} - -private final class FeedInlineTextEditorView: NSView { - private static let textInset = NSSize(width: 0, height: 1) - - let textView = FeedInlineNativeTextView(frame: .zero) - private let placeholderField = FeedInlinePassthroughLabel(labelWithString: "") - private var currentFont = GlobalFontMagnification.systemFont(ofSize: 11) - - static func minimumHeight(for font: NSFont) -> CGFloat { - ceil(font.ascender - font.descender + font.leading) + textInset.height * 2 - } - - var placeholder: String = "" { - didSet { - guard placeholder != oldValue else { return } - placeholderField.stringValue = placeholder - updatePlaceholderVisibility() - } - } - - var isEnabled: Bool = true { - didSet { - guard isEnabled != oldValue else { return } - textView.isEditable = isEnabled - textView.isSelectable = isEnabled - textView.textColor = isEnabled ? .labelColor : .disabledControlTextColor - textView.insertionPointColor = .controlAccentColor - } - } - - override var isFlipped: Bool { true } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setContentHuggingPriority(.defaultLow, for: .horizontal) - setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - - textView.isEditable = true - textView.isSelectable = true - textView.isRichText = false - textView.importsGraphics = false - textView.isHorizontallyResizable = false - textView.isVerticallyResizable = true - textView.backgroundColor = .clear - textView.drawsBackground = false - textView.textContainerInset = Self.textInset - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.heightTracksTextView = false - textView.minSize = .zero - textView.maxSize = NSSize( - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - ) - addSubview(textView) - - placeholderField.textColor = .placeholderTextColor - placeholderField.lineBreakMode = .byWordWrapping - placeholderField.maximumNumberOfLines = 0 - addSubview(placeholderField) - - apply(font: currentFont, isEnabled: true) - updatePlaceholderVisibility() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var intrinsicContentSize: NSSize { - NSSize(width: NSView.noIntrinsicMetric, height: fittingHeight()) - } - - override func mouseDown(with event: NSEvent) { - _ = window?.makeFirstResponder(textView) - super.mouseDown(with: event) - } - - override func layout() { - super.layout() - let availableWidth = max(bounds.width, 1) - let height = fittingHeight(for: availableWidth) - textView.frame = NSRect(x: 0, y: 0, width: availableWidth, height: height) - placeholderField.frame = NSRect( - x: Self.textInset.width, - y: Self.textInset.height, - width: max(bounds.width - Self.textInset.width * 2, 1), - height: Self.minimumHeight(for: currentFont) - ) - } - - func apply(font: NSFont, isEnabled: Bool) { - let fontChanged = currentFont != font || textView.font != font || placeholderField.font != font - let enabledChanged = self.isEnabled != isEnabled - - if fontChanged { - currentFont = font - textView.font = font - placeholderField.font = font - textView.textColor = self.isEnabled ? .labelColor : .disabledControlTextColor - textView.insertionPointColor = .controlAccentColor - } - if enabledChanged { - self.isEnabled = isEnabled - } - if fontChanged || enabledChanged { - refreshMetrics() - } - } - - func refreshMetrics() { - updatePlaceholderVisibility() - needsLayout = true - invalidateIntrinsicContentSize() - layoutSubtreeIfNeeded() - } - - func focusIfNeeded() { - guard let window, window.firstResponder !== textView else { return } - window.makeFirstResponder(textView) - let length = (textView.string as NSString).length - textView.setSelectedRange(NSRange(location: length, length: 0)) - } - - func fittingHeight(for width: CGFloat) -> CGFloat { - guard let layoutManager = textView.layoutManager, - let textContainer = textView.textContainer else { - return Self.minimumHeight(for: currentFont) - } - let availableWidth = max(width - Self.textInset.width * 2, 1) - textContainer.containerSize = NSSize( - width: availableWidth, - height: CGFloat.greatestFiniteMagnitude - ) - layoutManager.ensureLayout(for: textContainer) - let usedRect = layoutManager.usedRect(for: textContainer) - let extraLineHeight = layoutManager.extraLineFragmentTextContainer == textContainer - ? layoutManager.extraLineFragmentRect.height - : 0 - let lineHeight = ceil(currentFont.ascender - currentFont.descender + currentFont.leading) - let contentHeight = max(lineHeight, ceil(usedRect.height + extraLineHeight)) - return max( - Self.minimumHeight(for: currentFont), - ceil(contentHeight + Self.textInset.height * 2) - ) - } - - private func updateTextViewLayout() { - let availableWidth = max(bounds.width, 1) - let height = fittingHeight(for: availableWidth) - textView.frame = NSRect(x: 0, y: 0, width: availableWidth, height: height) - } - - private func fittingHeight() -> CGFloat { - guard bounds.width > 1 else { - return Self.minimumHeight(for: currentFont) - } - let availableWidth = max(bounds.width, 1) - return fittingHeight(for: availableWidth) - } - - private func updatePlaceholderVisibility() { - placeholderField.isHidden = !textView.string.isEmpty - } -} - -private struct FeedInlineTextField: NSViewRepresentable { - @Binding var text: String - - let focusRequest: Int? - let placeholder: String - let isEnabled: Bool - let font: NSFont - let onFocus: () -> Void - let onBlur: () -> Void - let onSubmit: (() -> Void)? - - final class Coordinator: NSObject, NSTextViewDelegate { - var parent: FeedInlineTextField - var isProgrammaticMutation = false - weak var view: FeedInlineTextEditorView? - var lastAppliedFocusRequest: Int? - - init(parent: FeedInlineTextField) { - self.parent = parent - self.lastAppliedFocusRequest = parent.focusRequest - } - - func activateField() { -#if DEBUG - dlog("feed.editor.activateField") -#endif - parent.onFocus() - } - - func blurField() { - guard let view, let window = view.window, window.firstResponder === view.textView else { - return - } -#if DEBUG - dlog("feed.editor.blurField frBefore=\(feedDebugResponderSummary(window.firstResponder))") -#endif - Task { @MainActor in - if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( - mode: .feed, - focusFirstItem: false, - preferredWindow: window - ) != true { - window.makeFirstResponder(nil) - } - } - } - - func textDidBeginEditing(_ notification: Notification) { - activateField() - } - - func textDidChange(_ notification: Notification) { - guard !isProgrammaticMutation else { return } - guard let textView = notification.object as? NSTextView else { return } - parent.text = textView.string - view?.refreshMetrics() - } - - func textDidEndEditing(_ notification: Notification) { - if !isProgrammaticMutation, let textView = notification.object as? NSTextView { - parent.text = textView.string - } - guard let window = view?.window else { - parent.onBlur() - return - } - let responder = window.firstResponder - if !(responder is FeedKeyboardFocusView) && !(responder is FeedInlineNativeTextView) { - parent.onBlur() - } - } - - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - func makeNSView(context: Context) -> FeedInlineTextEditorView { - let view = FeedInlineTextEditorView(frame: .zero) - view.textView.delegate = context.coordinator - view.textView.string = text - view.textView.onActivate = { [weak coordinator = context.coordinator] in - coordinator?.activateField() - } - view.textView.onEscape = { [weak coordinator = context.coordinator] in - coordinator?.blurField() - } - view.textView.onSubmit = onSubmit - configure(view) - context.coordinator.view = view - return view - } - - func updateNSView(_ nsView: FeedInlineTextEditorView, context: Context) { - context.coordinator.parent = self - context.coordinator.view = nsView - nsView.textView.onActivate = { [weak coordinator = context.coordinator] in - coordinator?.activateField() - } - nsView.textView.onEscape = { [weak coordinator = context.coordinator] in - coordinator?.blurField() - } - nsView.textView.onSubmit = onSubmit - configure(nsView) - - if nsView.textView.string != text, !nsView.textView.hasMarkedText() { - context.coordinator.isProgrammaticMutation = true - nsView.textView.string = text - context.coordinator.isProgrammaticMutation = false - nsView.refreshMetrics() - } - - guard let window = nsView.window else { return } - let isFirstResponder = window.firstResponder === nsView.textView - if let focusRequest, - focusRequest != context.coordinator.lastAppliedFocusRequest { - context.coordinator.lastAppliedFocusRequest = focusRequest - if isEnabled { - nsView.focusIfNeeded() - } else if isFirstResponder { - moveFocusToFeedHost(in: window) - } - } else if focusRequest == nil { - context.coordinator.lastAppliedFocusRequest = nil - if !isEnabled, isFirstResponder { - moveFocusToFeedHost(in: window) - } - } else if !isEnabled, isFirstResponder { - moveFocusToFeedHost(in: window) - } - } - - private func moveFocusToFeedHost(in window: NSWindow) { - if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( - mode: .feed, - focusFirstItem: false, - preferredWindow: window - ) == true { - return - } - window.makeFirstResponder(nil) - } - - private func configure(_ view: FeedInlineTextEditorView) { - view.placeholder = placeholder - view.apply(font: font, isEnabled: isEnabled) - } - - func sizeThatFits( - _ proposal: ProposedViewSize, - nsView: FeedInlineTextEditorView, - context: Context - ) -> CGSize? { - nil - } - - static func dismantleNSView(_ nsView: FeedInlineTextEditorView, coordinator: Coordinator) { - nsView.textView.delegate = nil - nsView.textView.onActivate = nil - nsView.textView.onEscape = nil - nsView.textView.onSubmit = nil - } -} - -private struct FeedHoverCursorModifier: ViewModifier { - let enabled: Bool - let cursor: NSCursor - - @State private var cursorPushed = false - - func body(content: Content) -> some View { - content - .onHover { hovering in - if hovering, enabled { - pushIfNeeded() - } else { - popIfNeeded() - } - } - .onDisappear { - popIfNeeded() - } - } - - private func pushIfNeeded() { - guard !cursorPushed else { return } - cursor.push() - cursorPushed = true - } - - private func popIfNeeded() { - guard cursorPushed else { return } - NSCursor.pop() - cursorPushed = false - } -} - -private extension View { - func feedIBeamCursorOnHover(enabled: Bool) -> some View { - modifier(FeedHoverCursorModifier(enabled: enabled, cursor: .iBeam)) - } -} - -/// Minimal wrapping HStack that flows its children into multiple rows. -private struct WrapHStack: View { - let spacing: CGFloat - let content: () -> Content - - init(spacing: CGFloat = 4, @ViewBuilder content: @escaping () -> Content) { - self.spacing = spacing - self.content = content - } - - var body: some View { - FlowLayout(spacing: spacing) { - content() - } - } -} - -private struct FlowLayout: Layout { - var spacing: CGFloat = 4 - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - let maxWidth = proposal.width ?? .infinity - var currentX: CGFloat = 0 - var currentRowHeight: CGFloat = 0 - var totalHeight: CGFloat = 0 - var totalWidth: CGFloat = 0 - for sub in subviews { - let size = sub.sizeThatFits(.unspecified) - if currentX + size.width > maxWidth && currentX > 0 { - totalHeight += currentRowHeight + spacing - totalWidth = max(totalWidth, currentX - spacing) - currentX = 0 - currentRowHeight = 0 - } - currentX += size.width + spacing - currentRowHeight = max(currentRowHeight, size.height) - } - totalHeight += currentRowHeight - totalWidth = max(totalWidth, currentX - spacing) - return CGSize(width: totalWidth, height: totalHeight) - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - var x: CGFloat = bounds.minX - var y: CGFloat = bounds.minY - var rowHeight: CGFloat = 0 - for sub in subviews { - let size = sub.sizeThatFits(.unspecified) - if x + size.width > bounds.maxX && x > bounds.minX { - x = bounds.minX - y += rowHeight + spacing - rowHeight = 0 - } - sub.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) - x += size.width + spacing - rowHeight = max(rowHeight, size.height) - } - } -} - -/// Renders a Stop event (Claude finished a turn and is waiting for -/// the next user prompt). Shows a text field + Send button that -/// types the reply into the agent's terminal surface and presses -/// Return — so the user can reply without switching focus. -private struct StopActionArea: View { - @Binding var draft: FeedStopDraft - @Binding var focusRequest: Int - - let onFocusRow: () -> Void - let onActionRow: () -> Void - let onBlurRow: () -> Void - let onSend: (String) -> Void - - private var trimmed: String { - draft.reply.trimmingCharacters(in: .whitespacesAndNewlines) - } - private var canSend: Bool { !trimmed.isEmpty } - @Environment(\.cmuxGlobalFontMagnificationPercent) private var globalFontPercent - private var replyFont: NSFont { GlobalFontMagnification.systemFont(ofSize: 12) } - private var replyBinding: Binding { - Binding( - get: { draft.reply }, - set: { draft.reply = $0 } - ) - } - - var body: some View { - let _ = globalFontPercent - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 5) { - Image(systemName: "checkmark.circle") - .cmuxFont(size: 10) - .foregroundColor(.secondary) - Text(String(localized: "feed.stop.label", defaultValue: "Claude finished — reply to continue")) - .cmuxFont(size: 11, weight: .medium) - .foregroundColor(.secondary) - } - FeedInlineTextField( - text: replyBinding, - focusRequest: focusRequest == 0 ? nil : focusRequest, - placeholder: String(localized: "feed.stop.placeholder", defaultValue: "Reply to Claude…"), - isEnabled: true, - font: replyFont, - onFocus: onFocusRow, - onBlur: onBlurRow, - onSubmit: sendReply - ) - .frame( - maxWidth: .infinity, - minHeight: FeedInlineTextEditorView.minimumHeight(for: replyFont), - alignment: .leading - ) - .padding(.horizontal, 10) - .padding(.vertical, 9) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.primary.opacity(0.06)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color.primary.opacity(canSend ? 0.25 : 0.10), lineWidth: 1) - ) - .contentShape(Rectangle()) - .feedIBeamCursorOnHover(enabled: true) - .onTapGesture { - onFocusRow() - requestReplyFocus() - } - FeedButton( - label: String(localized: "feed.stop.send", defaultValue: "Send to Claude"), - leadingIcon: "arrow.up.circle.fill", - kind: canSend ? .primary : .soft, - size: .medium, - fullWidth: true, - dimmed: !canSend - ) { - guard canSend else { return } - onActionRow() - sendReply() - } - } - } - - private func requestReplyFocus() { - focusRequest += 1 - } - - private func sendReply() { - guard canSend else { return } - onSend(trimmed) - draft.reply = "" - } -} - -private struct TelemetryActionArea: View { - let snapshot: FeedItemSnapshot - - var body: some View { - if case .todos(let todos) = snapshot.payload { - TodoListBody(todos: todos) - } else if case .assistantMessage(let text) = snapshot.payload, - snapshot.source == .claude { - FeedMarkdownInlineText( - text: text, - fontSize: 11, - foregroundColor: .secondary.opacity(0.85) - ) - .lineLimit(3) - .truncationMode(.tail) - } else if !summary.isEmpty { - Text(summary) - .cmuxFont(size: 11, design: .monospaced) - .foregroundColor(.secondary.opacity(0.85)) - .lineLimit(3) - .truncationMode(.tail) - } - } - - private var summary: String { - switch snapshot.payload { - case .toolUse(let name, let json): - return "\(name) \(json)" - case .toolResult(let name, let json, let err): - let status = err - ? String(localized: "feed.telemetry.error", defaultValue: "error") - : String(localized: "feed.telemetry.ok", defaultValue: "ok") - return "\(name) \(status) \(json)" - case .userPrompt(let text), .assistantMessage(let text): - return text - case .sessionStart: - return String(localized: "feed.telemetry.sessionStart", defaultValue: "session start") - case .sessionEnd: - return String(localized: "feed.telemetry.sessionEnd", defaultValue: "session end") - case .stop(let reason): - let label = String(localized: "feed.telemetry.stop", defaultValue: "stop") - guard let reason, !reason.isEmpty else { return label } - return "\(label) \(reason)" - default: - return "" - } - } -} - -private struct TodoListBody: View { - let todos: [WorkstreamTaskTodo] - - @State private var expanded = false - - private var done: [WorkstreamTaskTodo] { todos.filter { $0.state == .completed } } - private var inProgress: [WorkstreamTaskTodo] { todos.filter { $0.state == .inProgress } } - private var pending: [WorkstreamTaskTodo] { todos.filter { $0.state == .pending } } - - private var visibleDone: [WorkstreamTaskTodo] { - expanded ? done : Array(done.prefix(2)) - } - - var body: some View { - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 4) { - Text(String(localized: "feed.todos.title", defaultValue: "Tasks")) - .cmuxFont(size: 11, weight: .semibold) - .foregroundColor(.primary.opacity(0.9)) - Text(summaryLabel) - .cmuxFont(size: 10) - .foregroundColor(.secondary) - } - VStack(alignment: .leading, spacing: 3) { - ForEach(inProgress, id: \.id) { row($0) } - ForEach(pending, id: \.id) { row($0) } - ForEach(visibleDone, id: \.id) { row($0) } - if done.count > visibleDone.count { - Button { - expanded.toggle() - } label: { - Text(String( - localized: "feed.todos.moreCompleted", - defaultValue: "... +\(done.count - visibleDone.count) completed" - )) - .cmuxFont(size: 11) - .foregroundColor(.secondary.opacity(0.8)) - .padding(.leading, 22) - } - .buttonStyle(.plain) - } - if expanded && done.count > 2 { - Button { expanded = false } label: { - Text(String(localized: "feed.todos.collapse", defaultValue: "Collapse")) - .cmuxFont(size: 11) - .foregroundColor(.secondary.opacity(0.8)) - .padding(.leading, 22) - } - .buttonStyle(.plain) - } - } - } - } - - private var summaryLabel: String { - let d = done.count, ip = inProgress.count, p = pending.count - var parts: [String] = [] - if d > 0 { - parts.append(String(localized: "feed.todos.summary.done", defaultValue: "\(d) done")) - } - if ip > 0 { - parts.append(String(localized: "feed.todos.summary.inProgress", defaultValue: "\(ip) in progress")) - } - if p > 0 { - parts.append(String(localized: "feed.todos.summary.open", defaultValue: "\(p) open")) - } - return "(" + parts.joined(separator: ", ") + ")" - } - - @ViewBuilder - private func row(_ todo: WorkstreamTaskTodo) -> some View { - HStack(alignment: .top, spacing: 6) { - Image(systemName: symbol(for: todo.state)) - .cmuxFont(size: 11, weight: .medium) - .foregroundColor(color(for: todo.state)) - .frame(width: 14, height: 14) - Text(todo.content) - .cmuxFont(size: 12) - .foregroundColor(todo.state == .completed - ? .secondary.opacity(0.7) - : .primary.opacity(0.9)) - .strikethrough(todo.state == .completed, color: .secondary.opacity(0.6)) - .fixedSize(horizontal: false, vertical: true) - Spacer() - } - } - - private func symbol(for state: WorkstreamTaskTodo.State) -> String { - switch state { - case .completed: return "checkmark.square.fill" - case .inProgress: return "circle.fill" - case .pending: return "square" - } - } - - private func color(for state: WorkstreamTaskTodo.State) -> Color { - switch state { - case .completed: return .secondary.opacity(0.7) - case .inProgress: return .blue - case .pending: return .secondary - } - } -} - -/// Dashed separator between pending items and resolved ones. -private struct ResolvedDivider: View { - var body: some View { - HStack(spacing: 8) { - line - Text(String(localized: "feed.divider.resolved", defaultValue: "Resolved")) - .cmuxFont(size: 10, weight: .medium) - .tracking(0.5) - .foregroundColor(.secondary.opacity(0.7)) - line - } - .padding(.vertical, 2) - } - - private var line: some View { - Rectangle() - .fill(Color.primary.opacity(0.08)) - .frame(height: 1) - } -} - -// MARK: - Kind → SF Symbol - -private extension WorkstreamKind { - var symbolName: String { - switch self { - case .permissionRequest: return "lock.shield" - case .exitPlan: return "list.bullet.rectangle" - case .question: return "questionmark.circle" - case .toolUse, .toolResult: return "terminal" - case .userPrompt: return "person" - case .assistantMessage: return "sparkles" - case .sessionStart, .sessionEnd: return "play.circle" - case .stop: return "stop.circle" - case .todos: return "checklist" - } - } -} diff --git a/Sources/Feed/FeedPanelViewModel.swift b/Sources/Feed/FeedPanelViewModel.swift index 15e476e0072b..2f5c7519f556 100644 --- a/Sources/Feed/FeedPanelViewModel.swift +++ b/Sources/Feed/FeedPanelViewModel.swift @@ -4,37 +4,43 @@ import Foundation import Observation import SwiftUI -/// Bridges the `@Observable` WorkstreamStore to a Combine `@Published` -/// snapshot so SwiftUI reliably re-renders the Feed panel on every -/// mutation. +/// Projects the shared Workstream store into immutable Feed snapshots. @MainActor -final class FeedPanelViewModel: ObservableObject { - @Published private(set) var items: [WorkstreamItem] = [] - @Published private(set) var hasMorePersistedItems = false - @Published private(set) var isLoadingOlderItems = false - private var storeInstalledObserver: NSObjectProtocol? +@Observable +final class FeedPanelViewModel { + private(set) var items: [WorkstreamItem] = [] + private(set) var hasMorePersistedItems = false + private(set) var isLoadingOlderItems = false - init() { - storeInstalledObserver = NotificationCenter.default.addObserver( - forName: FeedCoordinator.storeInstalledNotification, - object: FeedCoordinator.shared, - queue: .main - ) { [weak self] _ in - Task { @MainActor in - self?.arm() + @ObservationIgnored private let coordinator: FeedCoordinator + @ObservationIgnored private var storeInstallTask: Task? + @ObservationIgnored private var loadOlderTask: Task? + + init( + coordinator: FeedCoordinator = .shared, + notificationCenter: NotificationCenter = .default + ) { + self.coordinator = coordinator + arm() + storeInstallTask = Task { @MainActor [weak self, weak coordinator] in + guard let coordinator else { return } + for await _ in notificationCenter.notifications( + named: FeedCoordinator.storeInstalledNotification, + object: coordinator + ) { + guard !Task.isCancelled, let self else { return } + self.arm() } } - arm() } deinit { - if let storeInstalledObserver { - NotificationCenter.default.removeObserver(storeInstalledObserver) - } + storeInstallTask?.cancel() + loadOlderTask?.cancel() } private func arm() { - guard let store = FeedCoordinator.shared.store else { return } + guard let store = coordinator.store else { return } withObservationTracking { items = store.items hasMorePersistedItems = store.hasMorePersistedItems @@ -46,10 +52,13 @@ final class FeedPanelViewModel: ObservableObject { } } - nonisolated func loadOlderItems() { - Task { @MainActor [weak self] in - guard let self, !self.isLoadingOlderItems, self.hasMorePersistedItems else { return } - await FeedCoordinator.shared.store?.loadOlderItems() + func loadOlderItems() { + guard !isLoadingOlderItems, + hasMorePersistedItems, + let store = coordinator.store else { return } + loadOlderTask?.cancel() + loadOlderTask = Task { @MainActor in + await store.loadOlderItems() } } } diff --git a/Sources/Feed/FeedPermissionView.swift b/Sources/Feed/FeedPermissionView.swift new file mode 100644 index 000000000000..a14696cbc884 --- /dev/null +++ b/Sources/Feed/FeedPermissionView.swift @@ -0,0 +1,800 @@ +import AppKit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +struct PermissionActionArea: View { + let toolName: String + let toolInputJSON: String + let source: WorkstreamSource + let status: WorkstreamStatus + let onActionRow: () -> Void + let onApprove: (WorkstreamPermissionMode) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + toolLabel + codeBlock + if status.isPending { + HStack(spacing: 6) { + FeedButton(label: String(localized: "feed.permission.deny", defaultValue: "Deny"), + kind: .dark, size: .medium, fullWidth: true) { + onActionRow() + onApprove(.deny) + } + .accessibilityIdentifier("FeedPermissionDenyButton") + if FeedPermissionActionPolicy.supportsOncePermissionMode( + source: source, + toolInputJSON: toolInputJSON + ) { + FeedButton(label: String(localized: "feed.permission.once", defaultValue: "Allow Once"), + kind: .light, size: .medium, fullWidth: true) { + onActionRow() + onApprove(.once) + } + .accessibilityIdentifier("FeedPermissionAllowOnceButton") + } + if FeedPermissionActionPolicy.supportsAlwaysPermissionMode( + source: source, + toolInputJSON: toolInputJSON + ) { + FeedButton(label: String(localized: "feed.permission.always", defaultValue: "Always Allow"), + kind: .primary, size: .medium, fullWidth: true) { + onActionRow() + onApprove(.always) + } + .accessibilityIdentifier("FeedPermissionAlwaysAllowButton") + } + if FeedPermissionActionPolicy.supportsAllPermissionMode( + source: source, + toolInputJSON: toolInputJSON + ) { + FeedButton(label: String(localized: "feed.permission.all", defaultValue: "All tools"), + kind: .primary, size: .medium, fullWidth: true) { + onActionRow() + onApprove(.all) + } + .accessibilityIdentifier("FeedPermissionAllToolsButton") + } + if FeedPermissionActionPolicy.supportsBypassPermissions(source: source) { + FeedButton(label: String(localized: "feed.permission.bypass", defaultValue: "Bypass"), + kind: .destructive, size: .medium, fullWidth: true) { + onActionRow() + onApprove(.bypass) + } + .accessibilityIdentifier("FeedPermissionBypassButton") + } + } + } else if let badge = submittedBadge { + FeedButton( + label: badge, + leadingIcon: "checkmark", + kind: .success, + size: .medium, + fullWidth: true, + dimmed: true + ) {} + } + } + } + + private var submittedBadge: String? { + guard case .resolved(let decision, _) = status else { return nil } + let submitted = String(localized: "feed.badge.submitted", defaultValue: "Submitted") + if case .permission(let mode) = decision { + return "\(submitted) · \(mode.displayLabel)" + } + return submitted + } + + private var toolLabel: some View { + HStack(spacing: 5) { + Image(systemName: "exclamationmark.triangle.fill") + .cmuxFont(size: 10, weight: .medium) + .foregroundColor(.orange) + Text(toolName) + .cmuxFont(size: 11, weight: .semibold) + .foregroundColor(.orange) + } + } + + private var codeBlock: some View { + let preview = PermissionInputPreview( + toolName: toolName, + toolInputJSON: toolInputJSON + ) + return VStack(alignment: .leading, spacing: 6) { + if let primary = preview.primary { + HStack(alignment: .firstTextBaseline, spacing: 6) { + if let sigil = preview.sigil { + Text(sigil) + .cmuxFont(size: 11, weight: .medium, design: .monospaced) + .foregroundColor(.orange) + } + Text(primary) + .cmuxFont(size: 11, design: .monospaced) + .foregroundColor(.primary.opacity(0.95)) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + if let secondary = preview.secondary, !secondary.isEmpty { + Text(secondary) + .cmuxFont(size: 10) + .foregroundColor(.secondary.opacity(0.85)) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.05)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.primary.opacity(0.10), lineWidth: 1) + ) + } +} + +/// Pulls a human-readable command + description out of an agent's +/// tool_input JSON. Handles Bash (`command` + `description`), Write / +/// Edit / Read (`file_path`), and falls back to the raw JSON. +private struct PermissionInputPreview { + let sigil: String? + let primary: String? + let secondary: String? + + init(toolName: String, toolInputJSON: String) { + let dict = (try? JSONSerialization.jsonObject( + with: Data(toolInputJSON.utf8) + )) as? [String: Any] ?? [:] + + switch toolName.lowercased() { + case "bash": + self.sigil = "$" + self.primary = (dict["command"] as? String) ?? toolInputJSON + self.secondary = (dict["description"] as? String) + case "write", "edit", "multiedit": + self.sigil = nil + self.primary = (dict["file_path"] as? String) ?? toolInputJSON + if toolName.lowercased() == "write" { + let content = (dict["content"] as? String) ?? "" + let preview = content.split(separator: "\n").first.map(String.init) ?? "" + self.secondary = preview.isEmpty ? nil : preview + } else { + self.secondary = nil + } + case "read": + self.sigil = nil + self.primary = (dict["file_path"] as? String) ?? toolInputJSON + self.secondary = nil + default: + self.sigil = nil + self.primary = toolInputJSON == "{}" ? nil : toolInputJSON + self.secondary = nil + } + } +} + +/// Single DRY button primitive used across every actionable card +/// (permission / plan / question / filter pills / option pills). +/// Replaces the old PermissionCTAButton / PlanCTAButton / +/// FeedPillButton trio so styling is defined in exactly one place. +struct FeedButton: View { + enum Kind: String { + /// Transparent pill that lights up on hover/selection. Used + /// for filter bar pills and single-select option pills. + case ghost + /// Soft neutral fill (e.g. Manual, disabled Submit). + case soft + /// Dark background with white text (Deny). + case dark + /// Light background with black text (Allow Once). + case light + /// Solid blue (Always Allow, Send feedback, active Submit). + case primary + /// Solid green (Auto, checked multi-select option, confirmations). + case success + /// Solid orange (warning actions). + case warning + /// Solid red (destructive deny). + case destructive + } + + enum Size { + case compact // filter bar / option pills + case medium // full-width CTAs + } + + let label: String + var leadingIcon: String? = nil + var trailingIcon: String? = nil + var kind: Kind = .ghost + var size: Size = .compact + var fullWidth: Bool = false + var isSelected: Bool = false + var dimmed: Bool = false + let action: () -> Void + + @Environment(\.colorScheme) private var colorScheme + @State private var isHovered: Bool = false +#if DEBUG + @AppStorage(FeedButtonDebugSettings.generationKey) private var debugStyleGeneration = 0 +#endif + + var body: some View { +#if DEBUG + #if compiler(>=6.2) + if #available(macOS 26.0, *), usesSystemGlassButtonStyle { + systemGlassButton + } else { + plainFeedButton + } + #else + plainFeedButton + #endif +#else + plainFeedButton +#endif + } + + private var plainFeedButton: some View { + Button { + performAction() + } label: { + labelContent + .foregroundColor(foreground) + .frame(maxWidth: fullWidth ? .infinity : nil) + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) + .background(buttonBackground) + .overlay(buttonBorder) + .shadow( + color: buttonShadowColor, + radius: buttonShadowRadius, + x: 0, + y: buttonShadowY + ) + .opacity(dimmed ? 0.55 : 1.0) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in + handleHover(hovering) + } + .help(label) + } + + private var labelContent: some View { + HStack(spacing: iconSpacing) { + if let leadingIcon { + Image(systemName: leadingIcon) + .cmuxFont(size: iconSize, weight: .semibold) + } + Text(label) + .cmuxFont(size: labelSize, weight: .semibold) + if let trailingIcon { + Image(systemName: trailingIcon) + .cmuxFont(size: iconSize, weight: .semibold) + } + } + } + + private var standardLabelContent: some View { + HStack(spacing: 4) { + if let leadingIcon { + Image(systemName: leadingIcon) + } + Text(label) + if let trailingIcon { + Image(systemName: trailingIcon) + } + } + .cmuxFont(size: labelSize, weight: .semibold) + } + + private func performAction() { + // `dimmed` doubles as the disabled signal — swallow the + // click at the primitive so upstream action closures don't + // have to re-check. + guard !dimmed else { return } + action() + } + + private func handleHover(_ hovering: Bool) { + isHovered = hovering + // Only swap the cursor when the button is disabled — + // enabled buttons keep the default arrow so the Feed + // feels like the rest of the app. Pop on mouseout so a + // stale "not allowed" cursor doesn't stick. + if dimmed, hovering { + NSCursor.operationNotAllowed.push() + } else if dimmed, !hovering { + NSCursor.pop() + } + } + +#if DEBUG + private var usesSystemGlassButtonStyle: Bool { + _ = debugStyleGeneration + switch FeedButtonDebugSettings.visualStyle { + case .standardGlass, .standardTintedGlass, .nativeGlass, .nativeProminentGlass, .commandLight: + return true + case .solid, .glass, .liquid, .halo, .command, .outline, .flat: + return false + } + } + + #if compiler(>=6.2) + @available(macOS 26.0, *) + @ViewBuilder + private var systemGlassButton: some View { + if FeedButtonDebugSettings.visualStyle == .standardGlass { + standardSystemGlassButtonBase + .buttonStyle(.glass) + } else if FeedButtonDebugSettings.visualStyle == .standardTintedGlass { + standardSystemGlassButtonBase + .buttonStyle(.glass) + .tint(systemGlassTint) + } else if FeedButtonDebugSettings.visualStyle == .nativeProminentGlass { + systemGlassButtonBase + .buttonStyle(.glassProminent) + } else { + systemGlassButtonBase + .buttonStyle(.glass) + } + } + + @available(macOS 26.0, *) + private var standardSystemGlassButtonBase: some View { + Button { + performAction() + } label: { + standardLabelContent + .frame(maxWidth: fullWidth ? .infinity : nil) + } + .controlSize(size == .compact ? .small : .regular) + .disabled(dimmed) + .opacity(dimmed ? 0.55 : 1.0) + .onHover { hovering in + handleHover(hovering) + } + .help(label) + } + + @available(macOS 26.0, *) + private var systemGlassButtonBase: some View { + Button { + performAction() + } label: { + labelContent + .foregroundStyle(systemGlassForeground) + .frame(maxWidth: fullWidth ? .infinity : nil) + .padding(.horizontal, max(CGFloat(0), horizontalPadding - 2)) + .padding(.vertical, max(CGFloat(0), verticalPadding - 1)) + .contentShape( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + ) + } + .buttonBorderShape(.roundedRectangle(radius: cornerRadius)) + .controlSize(size == .compact ? .small : .regular) + .tint(systemGlassTint) + .disabled(dimmed) + .opacity(dimmed ? 0.55 : 1.0) + .onHover { hovering in + handleHover(hovering) + } + .help(label) + } + #endif + + private var systemGlassTint: Color { + glassEffectTint.opacity(FeedButtonDebugSettings.glassTintOpacity) + } + + private var systemGlassForeground: Color { + if let color = FeedButtonDebugSettings.color( + for: kind, + role: .foreground, + colorScheme: colorScheme + ) { + return color + } + + switch FeedButtonDebugSettings.visualStyle { + case .nativeProminentGlass: + return kind == .light ? .black : .white + case .nativeGlass: + return .primary + case .standardGlass, .standardTintedGlass, .solid, .glass, .liquid, .halo, .command, .commandLight, .outline, .flat: + return foreground + } + } +#endif + + // MARK: - Style resolution + + private var labelSize: CGFloat { size == .compact ? 10 : 10.5 } + private var iconSize: CGFloat { size == .compact ? 9 : 10 } + private var iconSpacing: CGFloat { size == .compact ? 3 : 5 } + private var cornerRadius: CGFloat { +#if DEBUG + _ = debugStyleGeneration + return size == .compact + ? CGFloat(FeedButtonDebugSettings.compactCornerRadius) + : CGFloat(FeedButtonDebugSettings.mediumCornerRadius) +#else + return size == .compact ? 5 : 6 +#endif + } + private var horizontalPadding: CGFloat { +#if DEBUG + _ = debugStyleGeneration + return size == .compact + ? CGFloat(FeedButtonDebugSettings.compactHorizontalPadding) + : CGFloat(FeedButtonDebugSettings.mediumHorizontalPadding) +#else + return size == .compact ? 8 : 12 +#endif + } + private var verticalPadding: CGFloat { +#if DEBUG + _ = debugStyleGeneration + return size == .compact + ? CGFloat(FeedButtonDebugSettings.compactVerticalPadding) + : CGFloat(FeedButtonDebugSettings.mediumVerticalPadding) +#else + return size == .compact ? 4 : 5 +#endif + } + + private var foreground: Color { +#if DEBUG + _ = debugStyleGeneration + if let color = FeedButtonDebugSettings.color( + for: kind, + role: .foreground, + colorScheme: colorScheme + ) { + return color + } +#endif + switch kind { + case .ghost: + return isSelected ? .primary : .primary.opacity(0.85) + case .soft: return .primary + case .dark: return .white + case .light: return .black + case .primary: return .white + case .success: return .white + case .warning: return .white + case .destructive: return .white + } + } + + private var backgroundFill: Color { +#if DEBUG + _ = debugStyleGeneration + if let color = FeedButtonDebugSettings.color( + for: kind, + role: isHovered ? .hoverBackground : .background, + colorScheme: colorScheme + ) { + return color + } +#endif + switch kind { + case .ghost: + if isSelected { return Color.primary.opacity(0.12) } + if isHovered { return Color.primary.opacity(0.06) } + return Color.clear + case .soft: + return isHovered ? Color.primary.opacity(0.16) : Color.primary.opacity(0.10) + case .dark: + return isHovered ? Color.black.opacity(0.85) : Color.black.opacity(0.75) + case .light: + return isHovered ? Color.white.opacity(0.96) : Color.white.opacity(0.88) + case .primary: + return isHovered + ? Color(red: 0.28, green: 0.55, blue: 0.95) + : Color(red: 0.24, green: 0.48, blue: 0.88) + case .success: + return isHovered + ? Color(red: 0.22, green: 0.72, blue: 0.42) + : Color(red: 0.18, green: 0.62, blue: 0.35) + case .warning: + return isHovered + ? Color(red: 0.95, green: 0.55, blue: 0.18) + : Color(red: 0.92, green: 0.54, blue: 0.29) + case .destructive: + return isHovered + ? Color(red: 0.85, green: 0.28, blue: 0.28) + : Color(red: 0.75, green: 0.22, blue: 0.22) + } + } + +#if DEBUG + private var glassEffectTint: Color { + _ = debugStyleGeneration + if let color = FeedButtonDebugSettings.color( + for: kind, + role: isHovered ? .hoverBackground : .background, + colorScheme: colorScheme + ) { + return color + } + + switch kind { + case .ghost: return Color.accentColor + case .soft: return Color.gray + case .dark: return Color.black + case .light: return Color.white + case .primary: return Color(red: 0.24, green: 0.48, blue: 0.88) + case .success: return Color(red: 0.18, green: 0.62, blue: 0.35) + case .warning: return Color(red: 0.92, green: 0.54, blue: 0.29) + case .destructive: return Color(red: 0.75, green: 0.22, blue: 0.22) + } + } +#endif + + @ViewBuilder + private var buttonBackground: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) +#if DEBUG + let generation = debugStyleGeneration + switch generation >= 0 ? FeedButtonDebugSettings.visualStyle : .solid { + case .solid: + shape.fill(backgroundFill) + case .standardGlass: + shape.fill(.regularMaterial) + case .standardTintedGlass: + shape + .fill(.regularMaterial) + .overlay( + shape.fill( + backgroundFill.opacity( + FeedButtonDebugSettings.glassTintOpacity + ) + ) + ) + case .glass: + shape + .fill(.thinMaterial) + .overlay( + shape.fill( + backgroundFill.opacity( + FeedButtonDebugSettings.glassTintOpacity + ) + ) + ) + case .nativeGlass: + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + shape + .fill(Color.clear) + .glassEffect( + .regular + .tint(glassEffectTint.opacity(FeedButtonDebugSettings.glassTintOpacity)) + .interactive(!dimmed), + in: shape + ) + } else { + shape + .fill(.ultraThinMaterial) + .overlay(shape.fill(backgroundFill.opacity(0.20))) + } + #else + shape + .fill(.ultraThinMaterial) + .overlay(shape.fill(backgroundFill.opacity(0.20))) + #endif + case .nativeProminentGlass: + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + shape + .fill(Color.clear) + .glassEffect( + .regular + .tint(glassEffectTint.opacity(FeedButtonDebugSettings.glassTintOpacity)) + .interactive(!dimmed), + in: shape + ) + .overlay( + shape.fill( + backgroundFill.opacity(isHovered || isSelected ? 0.30 : 0.18) + ) + ) + } else { + shape + .fill(.regularMaterial) + .overlay(shape.fill(backgroundFill.opacity(0.26))) + } + #else + shape + .fill(.regularMaterial) + .overlay(shape.fill(backgroundFill.opacity(0.26))) + #endif + case .liquid: + shape + .fill(.ultraThinMaterial) + .overlay( + shape.fill( + backgroundFill.opacity( + FeedButtonDebugSettings.glassTintOpacity + ) + ) + ) + .overlay( + shape.fill( + LinearGradient( + colors: [ + Color.white.opacity(isHovered || isSelected ? 0.42 : 0.30), + Color.white.opacity(0.08), + Color.clear, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .blendMode(.screen) + ) + case .halo: + shape + .fill(.thinMaterial) + .overlay( + shape.fill( + backgroundFill.opacity( + FeedButtonDebugSettings.glassTintOpacity + ) + ) + ) + .overlay( + shape.fill( + RadialGradient( + colors: [ + Color.white.opacity(isHovered || isSelected ? 0.30 : 0.18), + Color.clear, + ], + center: .topLeading, + startRadius: 1, + endRadius: 54 + ) + ) + .blendMode(.screen) + ) + case .command: + shape + .fill(.regularMaterial) + .overlay(shape.fill(Color.black.opacity(0.28))) + .overlay( + shape.fill( + backgroundFill.opacity( + FeedButtonDebugSettings.glassTintOpacity + ) + ) + ) + case .commandLight: + shape + .fill(.regularMaterial) + .overlay(shape.fill(Color.white.opacity(0.22))) + .overlay( + shape.fill( + backgroundFill.opacity( + FeedButtonDebugSettings.glassTintOpacity + ) + ) + ) + case .outline: + shape.fill(isHovered || isSelected ? backgroundFill.opacity(0.14) : Color.clear) + case .flat: + shape.fill(isHovered || isSelected ? backgroundFill.opacity(0.12) : Color.clear) + } +#else + shape.fill(backgroundFill) +#endif + } + + @ViewBuilder + private var buttonBorder: some View { +#if DEBUG + let generation = debugStyleGeneration + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + switch generation >= 0 ? FeedButtonDebugSettings.visualStyle : .solid { + case .solid: + EmptyView() + case .standardGlass: + shape.stroke(Color.white.opacity(0.12), lineWidth: FeedButtonDebugSettings.borderWidth) + case .standardTintedGlass: + shape.stroke(backgroundFill.opacity(0.22), lineWidth: FeedButtonDebugSettings.borderWidth) + case .glass: + shape.stroke(Color.white.opacity(0.16), lineWidth: 0.75) + case .nativeGlass: + shape.stroke(Color.white.opacity(0.14), lineWidth: FeedButtonDebugSettings.borderWidth) + case .nativeProminentGlass: + shape.stroke(Color.white.opacity(0.18), lineWidth: FeedButtonDebugSettings.borderWidth) + case .liquid: + shape.stroke( + LinearGradient( + colors: [ + Color.white.opacity(0.42), + backgroundFill.opacity(0.28), + Color.white.opacity(0.10), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: FeedButtonDebugSettings.borderWidth + ) + case .halo: + shape.stroke( + backgroundFill.opacity(isHovered || isSelected ? 0.55 : 0.34), + lineWidth: FeedButtonDebugSettings.borderWidth + ) + case .command: + shape.stroke(Color.white.opacity(0.12), lineWidth: FeedButtonDebugSettings.borderWidth) + case .commandLight: + shape.stroke(Color.black.opacity(0.12), lineWidth: FeedButtonDebugSettings.borderWidth) + case .outline: + shape.stroke(backgroundFill.opacity(0.75), lineWidth: FeedButtonDebugSettings.borderWidth) + case .flat: + EmptyView() + } +#else + EmptyView() +#endif + } + + private var buttonShadowColor: Color { +#if DEBUG + _ = debugStyleGeneration + switch FeedButtonDebugSettings.visualStyle { + case .halo: + return backgroundFill.opacity(isHovered || isSelected ? 0.44 : 0.24) + case .liquid: + return backgroundFill.opacity(isHovered || isSelected ? 0.18 : 0.10) + case .command: + return Color.black.opacity(0.28) + case .commandLight: + return Color.black.opacity(isHovered || isSelected ? 0.16 : 0.08) + case .nativeProminentGlass: + return backgroundFill.opacity(isHovered || isSelected ? 0.18 : 0.10) + case .standardGlass, .standardTintedGlass, .solid, .glass, .nativeGlass, .outline, .flat: + return Color.clear + } +#else + return Color.clear +#endif + } + + private var buttonShadowRadius: CGFloat { +#if DEBUG + _ = debugStyleGeneration + switch FeedButtonDebugSettings.visualStyle { + case .halo: return isHovered || isSelected ? 9 : 6 + case .liquid: return isHovered || isSelected ? 5 : 3 + case .nativeProminentGlass: return isHovered || isSelected ? 5 : 3 + case .command: return 3 + case .commandLight: return isHovered || isSelected ? 4 : 2 + case .standardGlass, .standardTintedGlass, .solid, .glass, .nativeGlass, .outline, .flat: return 0 + } +#else + return 0 +#endif + } + + private var buttonShadowY: CGFloat { +#if DEBUG + _ = debugStyleGeneration + switch FeedButtonDebugSettings.visualStyle { + case .halo: return 2 + case .liquid, .nativeProminentGlass, .command, .commandLight: return 1 + case .standardGlass, .standardTintedGlass, .solid, .glass, .nativeGlass, .outline, .flat: return 0 + } +#else + return 0 +#endif + } +} diff --git a/Sources/Feed/FeedQuestionView.swift b/Sources/Feed/FeedQuestionView.swift new file mode 100644 index 000000000000..10e5ca8b0499 --- /dev/null +++ b/Sources/Feed/FeedQuestionView.swift @@ -0,0 +1,1032 @@ +import AppKit +import Bonsplit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +struct QuestionActionArea: View { + let questions: [WorkstreamQuestionPrompt] + let source: WorkstreamSource + let status: WorkstreamStatus + let isRowSelected: Bool + let onFocusRow: () -> Void + let onActionRow: () -> Void + let onBlurRow: () -> Void + let context: WorkstreamContext? + let onReply: ([String]) -> Void + + private static let skipInterviewAndPlanAnswer = "Skip interview and plan immediately" + private static let customAnswerSelectionId = "__cmux_custom_answer__" + + // Per-question selections keyed by question id. + @State private var selections: [String: Set] = [:] + // Per-question "Type something…" free-form answers. When + // non-empty, wins over preset option selections for that + // question — mirrors Claude's TUI fallback. + @State private var freeTexts: [String: String] = [:] + @State private var customAnswerFocusKey: String? + @State private var customAnswerFocusRequest = 0 + @Environment(\.cmuxGlobalFontMagnificationPercent) private var globalFontPercent + + var body: some View { + let _ = globalFontPercent + VStack(alignment: .leading, spacing: 12) { + if shouldRenderLongForm, let q = questions.first { + longFormBlock(question: q) + } else { + ForEach(Array(questions.enumerated()), id: \.offset) { idx, q in + questionBlock(index: idx + 1, question: q) + } + } + if shouldShowSkipInterviewCTA { + HStack(spacing: 8) { + skipInterviewCTA + submitCTA + } + } else { + submitCTA + } + } + .onChange(of: isRowSelected) { _, selected in + if !selected { + clearCustomAnswerFocus() + } + } + } + + private var shouldRenderLongForm: Bool { + // Long-form: single question whose options carry descriptions + // (e.g. Claude's AskUserQuestion with `header` + per-option + // detail). Multi-option list with a bigger rich-text card per + // option, click-to-select. + guard questions.count == 1, let q = questions.first else { return false } + return q.options.contains { $0.description?.isEmpty == false } + } + + private var agentLabel: String { + "\(source.rawValue.capitalized):" + } + + /// Long-form rendering: single question with rich options. Each + /// option becomes a tappable card with numbered index, title, and + /// description. Selecting only updates local state; the Submit + /// button sends the answer. + @ViewBuilder + private func longFormBlock(question: WorkstreamQuestionPrompt) -> some View { + VStack(alignment: .leading, spacing: 6) { + if !question.prompt.isEmpty { + FeedLabeledTextRow( + label: agentLabel, + text: question.prompt, + labelColor: .secondary, + textColor: .primary.opacity(0.95) + ) + } + ForEach(Array(question.options.enumerated()), id: \.offset) { idx, option in + longFormOptionCard( + questionId: question.id, + multi: question.multiSelect, + index: idx + 1, + option: option + ) + } + if status.isPending { + longFormCustomAnswerCard( + questionId: question.id, + multi: question.multiSelect, + index: question.options.count + 1 + ) + } + } + } + + private func longFormOptionCard( + questionId: String, + multi: Bool, + index: Int, + option: WorkstreamQuestionOption + ) -> some View { + let selected = selections[questionId]?.contains(option.id) == true + return Button { + guard status.isPending else { return } + onActionRow() + clearCustomAnswerFocus() + var current = selections[questionId] ?? [] + if multi { + if current.contains(option.id) { + current.remove(option.id) + } else { + current.insert(option.id) + } + } else { + current = [option.id] + } + selections[questionId] = current + } label: { + HStack(alignment: .top, spacing: 10) { + Text("\(index)") + .cmuxFont(size: 11, weight: .bold, monospacedDigit: true) + .foregroundColor(selected ? .white : .secondary) + .frame(width: 20, height: 20) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(selected ? Color(red: 0.24, green: 0.48, blue: 0.88) : Color.primary.opacity(0.08)) + ) + VStack(alignment: .leading, spacing: 2) { + Text(option.label) + .cmuxFont(size: 12, weight: .semibold) + .foregroundColor(.primary) + if let description = option.description, !description.isEmpty { + Text(description) + .cmuxFont(size: 11) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer() + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .cmuxFont(size: 12, weight: .medium) + .foregroundColor(selected ? Color(red: 0.24, green: 0.48, blue: 0.88) : .secondary.opacity(0.45)) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(selected ? Color(red: 0.24, green: 0.48, blue: 0.88).opacity(0.14) : Color.primary.opacity(0.05)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(selected ? Color(red: 0.24, green: 0.48, blue: 0.88).opacity(0.55) : Color.clear, lineWidth: 1) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!status.isPending) + } + + private func longFormCustomAnswerCard( + questionId: String, + multi: Bool, + index: Int + ) -> some View { + let customId = Self.customAnswerSelectionId + let selected = selections[questionId]?.contains(customId) == true + let focusKey = customAnswerFocusKey(questionId) + let font = GlobalFontMagnification.systemFont(ofSize: 12, weight: .semibold) + return HStack(alignment: .top, spacing: 10) { + Text("\(index)") + .cmuxFont(size: 11, weight: .bold, monospacedDigit: true) + .foregroundColor(selected ? .white : .secondary) + .frame(width: 20, height: 20) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(selected ? Color(red: 0.24, green: 0.48, blue: 0.88) : Color.primary.opacity(0.08)) + ) + customAnswerField( + text: customAnswerBinding(questionId: questionId, multi: multi), + focusRequest: focusRequest(forCustomAnswerKey: focusKey), + font: font, + onFocus: { + onFocusRow() + selectCustomAnswer(questionId: questionId, multi: multi) + }, + onBlur: onBlurRow + ) + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .cmuxFont(size: 12, weight: .medium) + .foregroundColor(selected ? Color(red: 0.24, green: 0.48, blue: 0.88) : .secondary.opacity(0.45)) + .padding(.leading, 8) + .padding(.top, 3) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(selected ? Color(red: 0.24, green: 0.48, blue: 0.88).opacity(0.14) : Color.primary.opacity(0.05)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(selected ? Color(red: 0.24, green: 0.48, blue: 0.88).opacity(0.55) : Color.clear, lineWidth: 1) + ) + .contentShape(Rectangle()) + .onTapGesture { + guard status.isPending else { return } + onFocusRow() + selectCustomAnswer(questionId: questionId, multi: multi) + requestCustomAnswerFocus(focusKey) + } + .feedIBeamCursorOnHover(enabled: status.isPending) + .disabled(!status.isPending) + } + + private func questionBlock(index: Int, question: WorkstreamQuestionPrompt) -> some View { + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .top, spacing: 5) { + Text("\(index).") + .cmuxFont(size: 11, weight: .semibold, monospacedDigit: true) + .foregroundColor(.blue) + Text(question.prompt) + .cmuxFont(size: 11, weight: .medium) + .foregroundColor(.primary.opacity(0.95)) + .fixedSize(horizontal: false, vertical: true) + } + if question.multiSelect { + HStack(spacing: 3) { + Image(systemName: "checklist") + .cmuxFont(size: 8, weight: .medium) + Text(String(localized: "feed.question.multiSelect", defaultValue: "Multi-select")) + .cmuxFont(size: 9, weight: .semibold) + .tracking(0.3) + } + .foregroundColor(.orange) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(Color.orange.opacity(0.18)) + ) + } + if question.options.isEmpty { + Text(String(localized: "feed.question.noOptions", + defaultValue: "Agent provided no options.")) + .cmuxFont(size: 10) + .foregroundColor(.secondary) + } else { + WrapHStack(spacing: 6) { + ForEach(question.options, id: \.id) { option in + optionPill(questionId: question.id, option: option, multi: question.multiSelect) + } + } + } + if status.isPending { + freeFormField(questionId: question.id, multi: question.multiSelect) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.04)) + ) + } + + /// "Type something…" free-form text field — mirrors Claude's TUI + /// option 4 (custom answer). When non-empty it wins over the + /// preset option selection for that question on submit. + private func freeFormField(questionId: String, multi: Bool) -> some View { + let focusKey = customAnswerFocusKey(questionId) + let font = GlobalFontMagnification.systemFont(ofSize: 11) + return customAnswerField( + text: customAnswerBinding(questionId: questionId, multi: multi), + focusRequest: focusRequest(forCustomAnswerKey: focusKey), + font: font, + onFocus: { + onFocusRow() + selectCustomAnswer(questionId: questionId, multi: multi) + }, + onBlur: onBlurRow + ) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(Color.primary.opacity(0.05)) + ) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(Color.primary.opacity(0.10), lineWidth: 1) + ) + .contentShape(Rectangle()) + .feedIBeamCursorOnHover(enabled: status.isPending) + .onTapGesture { + guard status.isPending else { return } + onFocusRow() + selectCustomAnswer(questionId: questionId, multi: multi) + requestCustomAnswerFocus(focusKey) + } + } + + private func customAnswerField( + text: Binding, + focusRequest: Int?, + font: NSFont, + onFocus: @escaping () -> Void, + onBlur: @escaping () -> Void + ) -> some View { + FeedInlineTextField( + text: text, + focusRequest: focusRequest, + placeholder: String(localized: "feed.question.typeSomething", + defaultValue: "Type something..."), + isEnabled: status.isPending, + font: font, + onFocus: onFocus, + onBlur: onBlur, + onSubmit: nil + ) + .frame( + maxWidth: .infinity, + minHeight: FeedInlineTextEditorView.minimumHeight(for: font), + alignment: .leading + ) + .layoutPriority(1) + } + + private func customAnswerBinding(questionId: String, multi: Bool) -> Binding { + Binding( + get: { freeTexts[questionId] ?? "" }, + set: { value in + freeTexts[questionId] = value + var current = selections[questionId] ?? [] + if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + current.remove(Self.customAnswerSelectionId) + } else if multi { + current.insert(Self.customAnswerSelectionId) + } else { + current = [Self.customAnswerSelectionId] + } + selections[questionId] = current + } + ) + } + + private func customAnswerFocusKey(_ questionId: String) -> String { + "\(questionId)::custom" + } + + private func focusRequest(forCustomAnswerKey focusKey: String) -> Int? { + customAnswerFocusKey == focusKey ? customAnswerFocusRequest : nil + } + + private func requestCustomAnswerFocus(_ focusKey: String) { + customAnswerFocusKey = focusKey + customAnswerFocusRequest += 1 + } + + private func selectCustomAnswer(questionId: String, multi: Bool) { + var current = selections[questionId] ?? [] + if multi { + current.insert(Self.customAnswerSelectionId) + } else { + current = [Self.customAnswerSelectionId] + } + selections[questionId] = current + } + + private func clearCustomAnswerFocus() { + customAnswerFocusKey = nil + } + + private func optionPill( + questionId: String, + option: WorkstreamQuestionOption, + multi: Bool + ) -> some View { + let selected = selections[questionId]?.contains(option.id) == true + let leading: String? = multi + ? (selected ? "checkmark.square.fill" : "square") + : nil + let selectedKind: FeedButton.Kind = multi ? .success : .primary + return FeedButton( + label: option.label, + leadingIcon: leading, + kind: selected ? selectedKind : .soft, + size: .compact, + dimmed: !status.isPending + ) { + guard status.isPending else { return } + onActionRow() + clearCustomAnswerFocus() + var current = selections[questionId] ?? [] + if multi { + if current.contains(option.id) { current.remove(option.id) } + else { current.insert(option.id) } + } else { + current = [option.id] + } + selections[questionId] = current + } + } + + /// One answer string per question: the user's free-form text if + /// they typed any, otherwise the labels of the selected options + /// joined by ", ". Questions with no answer are omitted entirely + /// so the agent doesn't see "question 2: ". + private var composedAnswers: [String] { + var out: [String] = [] + for q in questions { + let freeText = (freeTexts[q.id] ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let ids = selections[q.id] ?? [] + if !freeText.isEmpty, ids.contains(Self.customAnswerSelectionId) { + out.append(freeText) + continue + } + guard !ids.isEmpty else { continue } + let labels = q.options + .filter { ids.contains($0.id) } + .map(\.label) + if !labels.isEmpty { + out.append(labels.joined(separator: ", ")) + } + } + return out + } + + private var hasAnyAnswer: Bool { !composedAnswers.isEmpty } + + private var canSubmitEmptyAnswer: Bool { + !questions.isEmpty && questions.allSatisfy { $0.options.isEmpty } + } + + private var shouldShowSkipInterviewCTA: Bool { + status.isPending && isPlanAskUserQuestion + } + + private var isPlanAskUserQuestion: Bool { + guard source == .claude else { return false } + if let mode = context?.permissionMode { + return mode.caseInsensitiveCompare("plan") == .orderedSame + } + return questionTextLooksLikePlanInterview + } + + private var questionTextLooksLikePlanInterview: Bool { + let fragments: [String?] = questions.flatMap { q in + let questionFragments: [String?] = [q.header, q.prompt] + let optionFragments: [String?] = q.options.flatMap { option in + [option.label, option.description] + } + return questionFragments + optionFragments + } + let text = ([context?.lastUserMessage, context?.assistantPreamble] + fragments) + .compactMap { $0 } + .joined(separator: " ") + .lowercased() + return text.contains("plan mode") + || text.contains("make a plan") + || text.contains("plan-only") + || text.contains("plan immediately") + } + + private var submitCTA: some View { + let isPending = status.isPending + let enabled = isPending && (hasAnyAnswer || canSubmitEmptyAnswer) + return FeedButton( + label: isPending + ? String(localized: "feed.question.submitAll", + defaultValue: "Submit All Answers") + : String(localized: "feed.badge.submitted", + defaultValue: "Submitted"), + leadingIcon: isPending ? "checkmark.circle.fill" : "checkmark", + kind: enabled ? .primary : (isPending ? .soft : .success), + size: .medium, + fullWidth: true, + dimmed: !enabled + ) { + onActionRow() + // Selections carry human-readable answer strings (one per + // answered question) so the hook can feed them straight + // back to the agent as the user's reply. + onReply(composedAnswers) + } + } + + private var skipInterviewCTA: some View { + FeedButton( + label: String(localized: "feed.question.skipInterviewPlan", + defaultValue: "Skip + plan immediately"), + leadingIcon: "forward.end.fill", + kind: .soft, + size: .medium, + fullWidth: true + ) { + onActionRow() + onReply([Self.skipInterviewAndPlanAnswer]) + } + } +} + +private final class FeedInlinePassthroughLabel: NSTextField { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +final class FeedInlineNativeTextView: NSTextView, FeedKeyboardFocusResponder { + private static weak var activeEditor: FeedInlineNativeTextView? + + var onActivate: (() -> Void)? + var onEscape: (() -> Void)? + var onSubmit: (() -> Void)? + + static func blurActiveEditor() { + guard let activeEditor else { return } + guard let window = activeEditor.window else { + if Self.activeEditor === activeEditor { + Self.activeEditor = nil + } + return + } + guard window.firstResponder === activeEditor else { + if Self.activeEditor === activeEditor { + Self.activeEditor = nil + } + return + } +#if DEBUG + dlog("feed.editor.blurActive fr=\(feedDebugResponderSummary(window.firstResponder))") +#endif + window.makeFirstResponder(nil) + } + + override func mouseDown(with event: NSEvent) { +#if DEBUG + dlog("feed.editor.mouseDown frBefore=\(feedDebugResponderSummary(window?.firstResponder))") +#endif + onActivate?() + super.mouseDown(with: event) + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if event.type == .keyDown, event.keyCode == 53 { +#if DEBUG + dlog("feed.editor.escape fr=\(feedDebugResponderSummary(window?.firstResponder))") +#endif + onEscape?() + return true + } + return super.performKeyEquivalent(with: event) + } + + override func keyDown(with event: NSEvent) { + let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let shouldSubmit = (event.keyCode == 36 || event.keyCode == 76) + && normalizedFlags.intersection([.shift, .option, .command, .control]).isEmpty + if shouldSubmit, !hasMarkedText(), let onSubmit { + onSubmit() + return + } + super.keyDown(with: event) + } + + override func resetCursorRects() { + super.resetCursorRects() + addCursorRect(bounds, cursor: .iBeam) + } + + override func becomeFirstResponder() -> Bool { + let didBecomeFirstResponder = super.becomeFirstResponder() + if didBecomeFirstResponder { + Self.activeEditor = self + onActivate?() + } +#if DEBUG + dlog("feed.editor.become result=\(didBecomeFirstResponder ? 1 : 0) fr=\(feedDebugResponderSummary(window?.firstResponder))") +#endif + return didBecomeFirstResponder + } + + override func resignFirstResponder() -> Bool { + let didResignFirstResponder = super.resignFirstResponder() + if didResignFirstResponder, Self.activeEditor === self { + Self.activeEditor = nil + } +#if DEBUG + dlog("feed.editor.resign result=\(didResignFirstResponder ? 1 : 0) fr=\(feedDebugResponderSummary(window?.firstResponder))") +#endif + return didResignFirstResponder + } +} + +final class FeedInlineTextEditorView: NSView { + private static let textInset = NSSize(width: 0, height: 1) + + let textView = FeedInlineNativeTextView(frame: .zero) + private let placeholderField = FeedInlinePassthroughLabel(labelWithString: "") + private var currentFont = GlobalFontMagnification.systemFont(ofSize: 11) + + static func minimumHeight(for font: NSFont) -> CGFloat { + ceil(font.ascender - font.descender + font.leading) + textInset.height * 2 + } + + var placeholder: String = "" { + didSet { + guard placeholder != oldValue else { return } + placeholderField.stringValue = placeholder + updatePlaceholderVisibility() + } + } + + var isEnabled: Bool = true { + didSet { + guard isEnabled != oldValue else { return } + textView.isEditable = isEnabled + textView.isSelectable = isEnabled + textView.textColor = isEnabled ? .labelColor : .disabledControlTextColor + textView.insertionPointColor = .controlAccentColor + } + } + + override var isFlipped: Bool { true } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setContentHuggingPriority(.defaultLow, for: .horizontal) + setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + textView.isEditable = true + textView.isSelectable = true + textView.isRichText = false + textView.importsGraphics = false + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.backgroundColor = .clear + textView.drawsBackground = false + textView.textContainerInset = Self.textInset + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.heightTracksTextView = false + textView.minSize = .zero + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + addSubview(textView) + + placeholderField.textColor = .placeholderTextColor + placeholderField.lineBreakMode = .byWordWrapping + placeholderField.maximumNumberOfLines = 0 + addSubview(placeholderField) + + apply(font: currentFont, isEnabled: true) + updatePlaceholderVisibility() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: fittingHeight()) + } + + override func mouseDown(with event: NSEvent) { + _ = window?.makeFirstResponder(textView) + super.mouseDown(with: event) + } + + override func layout() { + super.layout() + let availableWidth = max(bounds.width, 1) + let height = fittingHeight(for: availableWidth) + textView.frame = NSRect(x: 0, y: 0, width: availableWidth, height: height) + placeholderField.frame = NSRect( + x: Self.textInset.width, + y: Self.textInset.height, + width: max(bounds.width - Self.textInset.width * 2, 1), + height: Self.minimumHeight(for: currentFont) + ) + } + + func apply(font: NSFont, isEnabled: Bool) { + let fontChanged = currentFont != font || textView.font != font || placeholderField.font != font + let enabledChanged = self.isEnabled != isEnabled + + if fontChanged { + currentFont = font + textView.font = font + placeholderField.font = font + textView.textColor = self.isEnabled ? .labelColor : .disabledControlTextColor + textView.insertionPointColor = .controlAccentColor + } + if enabledChanged { + self.isEnabled = isEnabled + } + if fontChanged || enabledChanged { + refreshMetrics() + } + } + + func refreshMetrics() { + updatePlaceholderVisibility() + needsLayout = true + invalidateIntrinsicContentSize() + layoutSubtreeIfNeeded() + } + + func focusIfNeeded() { + guard let window, window.firstResponder !== textView else { return } + window.makeFirstResponder(textView) + let length = (textView.string as NSString).length + textView.setSelectedRange(NSRange(location: length, length: 0)) + } + + func fittingHeight(for width: CGFloat) -> CGFloat { + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { + return Self.minimumHeight(for: currentFont) + } + let availableWidth = max(width - Self.textInset.width * 2, 1) + textContainer.containerSize = NSSize( + width: availableWidth, + height: CGFloat.greatestFiniteMagnitude + ) + layoutManager.ensureLayout(for: textContainer) + let usedRect = layoutManager.usedRect(for: textContainer) + let extraLineHeight = layoutManager.extraLineFragmentTextContainer == textContainer + ? layoutManager.extraLineFragmentRect.height + : 0 + let lineHeight = ceil(currentFont.ascender - currentFont.descender + currentFont.leading) + let contentHeight = max(lineHeight, ceil(usedRect.height + extraLineHeight)) + return max( + Self.minimumHeight(for: currentFont), + ceil(contentHeight + Self.textInset.height * 2) + ) + } + + private func updateTextViewLayout() { + let availableWidth = max(bounds.width, 1) + let height = fittingHeight(for: availableWidth) + textView.frame = NSRect(x: 0, y: 0, width: availableWidth, height: height) + } + + private func fittingHeight() -> CGFloat { + guard bounds.width > 1 else { + return Self.minimumHeight(for: currentFont) + } + let availableWidth = max(bounds.width, 1) + return fittingHeight(for: availableWidth) + } + + private func updatePlaceholderVisibility() { + placeholderField.isHidden = !textView.string.isEmpty + } +} + +struct FeedInlineTextField: NSViewRepresentable { + @Binding var text: String + + let focusRequest: Int? + let placeholder: String + let isEnabled: Bool + let font: NSFont + let onFocus: () -> Void + let onBlur: () -> Void + let onSubmit: (() -> Void)? + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: FeedInlineTextField + var isProgrammaticMutation = false + weak var view: FeedInlineTextEditorView? + var lastAppliedFocusRequest: Int? + + init(parent: FeedInlineTextField) { + self.parent = parent + self.lastAppliedFocusRequest = parent.focusRequest + } + + func activateField() { +#if DEBUG + dlog("feed.editor.activateField") +#endif + parent.onFocus() + } + + func blurField() { + guard let view, let window = view.window, window.firstResponder === view.textView else { + return + } +#if DEBUG + dlog("feed.editor.blurField frBefore=\(feedDebugResponderSummary(window.firstResponder))") +#endif + Task { @MainActor in + if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( + mode: .feed, + focusFirstItem: false, + preferredWindow: window + ) != true { + window.makeFirstResponder(nil) + } + } + } + + func textDidBeginEditing(_ notification: Notification) { + activateField() + } + + func textDidChange(_ notification: Notification) { + guard !isProgrammaticMutation else { return } + guard let textView = notification.object as? NSTextView else { return } + parent.text = textView.string + view?.refreshMetrics() + } + + func textDidEndEditing(_ notification: Notification) { + if !isProgrammaticMutation, let textView = notification.object as? NSTextView { + parent.text = textView.string + } + guard let window = view?.window else { + parent.onBlur() + return + } + let responder = window.firstResponder + if !(responder is FeedKeyboardFocusView) && !(responder is FeedInlineNativeTextView) { + parent.onBlur() + } + } + + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> FeedInlineTextEditorView { + let view = FeedInlineTextEditorView(frame: .zero) + view.textView.delegate = context.coordinator + view.textView.string = text + view.textView.onActivate = { [weak coordinator = context.coordinator] in + coordinator?.activateField() + } + view.textView.onEscape = { [weak coordinator = context.coordinator] in + coordinator?.blurField() + } + view.textView.onSubmit = onSubmit + configure(view) + context.coordinator.view = view + return view + } + + func updateNSView(_ nsView: FeedInlineTextEditorView, context: Context) { + context.coordinator.parent = self + context.coordinator.view = nsView + nsView.textView.onActivate = { [weak coordinator = context.coordinator] in + coordinator?.activateField() + } + nsView.textView.onEscape = { [weak coordinator = context.coordinator] in + coordinator?.blurField() + } + nsView.textView.onSubmit = onSubmit + configure(nsView) + + if nsView.textView.string != text, !nsView.textView.hasMarkedText() { + context.coordinator.isProgrammaticMutation = true + nsView.textView.string = text + context.coordinator.isProgrammaticMutation = false + nsView.refreshMetrics() + } + + guard let window = nsView.window else { return } + let isFirstResponder = window.firstResponder === nsView.textView + if let focusRequest, + focusRequest != context.coordinator.lastAppliedFocusRequest { + context.coordinator.lastAppliedFocusRequest = focusRequest + if isEnabled { + nsView.focusIfNeeded() + } else if isFirstResponder { + moveFocusToFeedHost(in: window) + } + } else if focusRequest == nil { + context.coordinator.lastAppliedFocusRequest = nil + if !isEnabled, isFirstResponder { + moveFocusToFeedHost(in: window) + } + } else if !isEnabled, isFirstResponder { + moveFocusToFeedHost(in: window) + } + } + + private func moveFocusToFeedHost(in window: NSWindow) { + if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( + mode: .feed, + focusFirstItem: false, + preferredWindow: window + ) == true { + return + } + window.makeFirstResponder(nil) + } + + private func configure(_ view: FeedInlineTextEditorView) { + view.placeholder = placeholder + view.apply(font: font, isEnabled: isEnabled) + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + nsView: FeedInlineTextEditorView, + context: Context + ) -> CGSize? { + nil + } + + static func dismantleNSView(_ nsView: FeedInlineTextEditorView, coordinator: Coordinator) { + nsView.textView.delegate = nil + nsView.textView.onActivate = nil + nsView.textView.onEscape = nil + nsView.textView.onSubmit = nil + } +} + +private struct FeedHoverCursorModifier: ViewModifier { + let enabled: Bool + let cursor: NSCursor + + @State private var cursorPushed = false + + func body(content: Content) -> some View { + content + .onHover { hovering in + if hovering, enabled { + pushIfNeeded() + } else { + popIfNeeded() + } + } + .onDisappear { + popIfNeeded() + } + } + + private func pushIfNeeded() { + guard !cursorPushed else { return } + cursor.push() + cursorPushed = true + } + + private func popIfNeeded() { + guard cursorPushed else { return } + NSCursor.pop() + cursorPushed = false + } +} + +extension View { + func feedIBeamCursorOnHover(enabled: Bool) -> some View { + modifier(FeedHoverCursorModifier(enabled: enabled, cursor: .iBeam)) + } +} + +/// Minimal wrapping HStack that flows its children into multiple rows. +private struct WrapHStack: View { + let spacing: CGFloat + let content: () -> Content + + init(spacing: CGFloat = 4, @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.content = content + } + + var body: some View { + FlowLayout(spacing: spacing) { + content() + } + } +} + +private struct FlowLayout: Layout { + var spacing: CGFloat = 4 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var currentX: CGFloat = 0 + var currentRowHeight: CGFloat = 0 + var totalHeight: CGFloat = 0 + var totalWidth: CGFloat = 0 + for sub in subviews { + let size = sub.sizeThatFits(.unspecified) + if currentX + size.width > maxWidth && currentX > 0 { + totalHeight += currentRowHeight + spacing + totalWidth = max(totalWidth, currentX - spacing) + currentX = 0 + currentRowHeight = 0 + } + currentX += size.width + spacing + currentRowHeight = max(currentRowHeight, size.height) + } + totalHeight += currentRowHeight + totalWidth = max(totalWidth, currentX - spacing) + return CGSize(width: totalWidth, height: totalHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x: CGFloat = bounds.minX + var y: CGFloat = bounds.minY + var rowHeight: CGFloat = 0 + for sub in subviews { + let size = sub.sizeThatFits(.unspecified) + if x + size.width > bounds.maxX && x > bounds.minX { + x = bounds.minX + y += rowHeight + spacing + rowHeight = 0 + } + sub.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} + +/// Renders a Stop event (Claude finished a turn and is waiting for +/// the next user prompt). Shows a text field + Send button that +/// types the reply into the agent's terminal surface and presses +/// Return — so the user can reply without switching focus. diff --git a/Sources/Feed/FeedSocketEncoding.swift b/Sources/Feed/FeedSocketEncoding.swift new file mode 100644 index 000000000000..d34cef35a58d --- /dev/null +++ b/Sources/Feed/FeedSocketEncoding.swift @@ -0,0 +1,170 @@ +import CMUXAgentLaunch +import Foundation + +/// JSON-shape helpers used by the V2 `feed.*` socket handlers. +enum FeedSocketEncoding { + private static let primaryTextLimit = 8_000 + private static let secondaryTextLimit = 2_000 + + static func payload(for result: FeedCoordinator.IngestBlockingResult) -> [String: Any] { + switch result { + case .acknowledged(let itemId): + var dict: [String: Any] = ["status": "acknowledged"] + if let itemId { dict["item_id"] = itemId.uuidString } + return dict + case .resolved(let itemId, let decision): + var dict: [String: Any] = [ + "status": "resolved", + "decision": decisionDict(decision) + ] + if let itemId { dict["item_id"] = itemId.uuidString } + return dict + case .timedOut(let itemId): + var dict: [String: Any] = ["status": "timed_out"] + if let itemId { dict["item_id"] = itemId.uuidString } + return dict + } + } + + static func decisionDict(_ decision: WorkstreamDecision) -> [String: Any] { + switch decision { + case .permission(let mode): + return ["kind": "permission", "mode": mode.rawValue] + case .exitPlan(let mode, let feedback): + var dict: [String: Any] = ["kind": "exit_plan", "mode": mode.rawValue] + if let feedback, !feedback.isEmpty { + dict["feedback"] = feedback + } + return dict + case .question(let selections): + return ["kind": "question", "selections": selections] + } + } + + private static func limitedText(_ value: String, limit: Int) -> (text: String, truncated: Bool) { + guard value.count > limit else { return (value, false) } + let end = value.index(value.startIndex, offsetBy: max(limit - 3, 0)) + return (String(value[.. [String: Any] { + var dict: [String: Any] = [ + "id": question.id, + "multi_select": question.multiSelect, + ] + if let header = question.header { + assignLimitedText(header, key: "header", to: &dict, limit: secondaryTextLimit) + } + assignLimitedText(question.prompt, key: "prompt", to: &dict, limit: primaryTextLimit) + dict["options"] = question.options.map { option in + var optionDict: [String: Any] = [ + "id": option.id, + "label": limitedText(option.label, limit: secondaryTextLimit).text, + ] + if let description = option.description { + assignLimitedText(description, key: "description", to: &optionDict, limit: secondaryTextLimit) + } + return optionDict + } + return dict + } + + static func itemDict(_ item: WorkstreamItem) -> [String: Any] { + let isoFormatter = ISO8601DateFormatter() + var dict: [String: Any] = [ + "id": item.id.uuidString, + "workstream_id": item.workstreamId, + "source": item.source.rawValue, + "kind": item.kind.rawValue, + "created_at": isoFormatter.string(from: item.createdAt), + "updated_at": isoFormatter.string(from: item.updatedAt), + ] + if let cwd = item.cwd { dict["cwd"] = cwd } + if let title = item.title { dict["title"] = title } + switch item.status { + case .pending: + dict["status"] = "pending" + case .resolved(let decision, let at): + dict["status"] = "resolved" + dict["decision"] = decisionDict(decision) + dict["resolved_at"] = isoFormatter.string(from: at) + case .expired(let at): + dict["status"] = "expired" + dict["resolved_at"] = isoFormatter.string(from: at) + case .telemetry: + dict["status"] = "telemetry" + } + switch item.payload { + case .permissionRequest(let requestId, let toolName, let toolInputJSON, let pattern): + dict["request_id"] = requestId + dict["tool_name"] = toolName + if let capabilityJSON = FeedPermissionActionPolicy.codexCapabilityToolInputJSON( + source: item.source, + toolInputJSON: toolInputJSON + ) { + dict["tool_input_capabilities"] = capabilityJSON + } + assignLimitedText(toolInputJSON, key: "tool_input", to: &dict) + if let pattern { dict["pattern"] = pattern } + case .exitPlan(let requestId, let plan, let defaultMode): + dict["request_id"] = requestId + assignLimitedText(plan, key: "plan", to: &dict) + dict["plan_summary"] = plan.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } + dict["default_mode"] = defaultMode.rawValue + case .question(let requestId, let questions): + dict["request_id"] = requestId + dict["questions"] = questions.map(questionDict) + if let firstQuestion = questions.first { + assignLimitedText(firstQuestion.prompt, key: "question_prompt", to: &dict) + dict["question_multi_select"] = firstQuestion.multiSelect + dict["question_options"] = firstQuestion.options.map { option in + var optionDict: [String: Any] = [ + "id": option.id, + "label": limitedText(option.label, limit: secondaryTextLimit).text, + ] + if let description = option.description { + assignLimitedText(description, key: "description", to: &optionDict, limit: secondaryTextLimit) + } + return optionDict + } + } + case .toolUse(let toolName, let toolInputJSON): + dict["tool_name"] = toolName + assignLimitedText(toolInputJSON, key: "tool_input", to: &dict) + case .toolResult(let toolName, let resultJSON, let isError): + dict["tool_name"] = toolName + assignLimitedText(resultJSON, key: "tool_result", to: &dict) + dict["tool_result_is_error"] = isError + case .userPrompt(let text), .assistantMessage(let text): + assignLimitedText(text, key: "text", to: &dict) + case .sessionStart, .sessionEnd: + break + case .stop(let reason): + if let reason { assignLimitedText(reason, key: "reason", to: &dict, limit: secondaryTextLimit) } + case .todos(let todos): + dict["todos"] = todos.map { todo in + [ + "id": todo.id, + "content": limitedText(todo.content, limit: secondaryTextLimit).text, + "state": todo.state.rawValue, + ] + } + } + return dict + } +} diff --git a/Sources/Feed/FeedTelemetryView.swift b/Sources/Feed/FeedTelemetryView.swift new file mode 100644 index 000000000000..0b6a2fbb1f77 --- /dev/null +++ b/Sources/Feed/FeedTelemetryView.swift @@ -0,0 +1,287 @@ +import AppKit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +struct StopActionArea: View { + @Binding var draft: FeedStopDraft + @Binding var focusRequest: Int + + let onFocusRow: () -> Void + let onActionRow: () -> Void + let onBlurRow: () -> Void + let onSend: (String) -> Void + + private var trimmed: String { + draft.reply.trimmingCharacters(in: .whitespacesAndNewlines) + } + private var canSend: Bool { !trimmed.isEmpty } + @Environment(\.cmuxGlobalFontMagnificationPercent) private var globalFontPercent + private var replyFont: NSFont { GlobalFontMagnification.systemFont(ofSize: 12) } + private var replyBinding: Binding { + Binding( + get: { draft.reply }, + set: { draft.reply = $0 } + ) + } + + var body: some View { + let _ = globalFontPercent + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 5) { + Image(systemName: "checkmark.circle") + .cmuxFont(size: 10) + .foregroundColor(.secondary) + Text(String(localized: "feed.stop.label", defaultValue: "Claude finished — reply to continue")) + .cmuxFont(size: 11, weight: .medium) + .foregroundColor(.secondary) + } + FeedInlineTextField( + text: replyBinding, + focusRequest: focusRequest == 0 ? nil : focusRequest, + placeholder: String(localized: "feed.stop.placeholder", defaultValue: "Reply to Claude…"), + isEnabled: true, + font: replyFont, + onFocus: onFocusRow, + onBlur: onBlurRow, + onSubmit: sendReply + ) + .frame( + maxWidth: .infinity, + minHeight: FeedInlineTextEditorView.minimumHeight(for: replyFont), + alignment: .leading + ) + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.primary.opacity(canSend ? 0.25 : 0.10), lineWidth: 1) + ) + .contentShape(Rectangle()) + .feedIBeamCursorOnHover(enabled: true) + .onTapGesture { + onFocusRow() + requestReplyFocus() + } + FeedButton( + label: String(localized: "feed.stop.send", defaultValue: "Send to Claude"), + leadingIcon: "arrow.up.circle.fill", + kind: canSend ? .primary : .soft, + size: .medium, + fullWidth: true, + dimmed: !canSend + ) { + guard canSend else { return } + onActionRow() + sendReply() + } + } + } + + private func requestReplyFocus() { + focusRequest += 1 + } + + private func sendReply() { + guard canSend else { return } + onSend(trimmed) + draft.reply = "" + } +} + +struct TelemetryActionArea: View { + let snapshot: FeedItemSnapshot + + var body: some View { + if case .todos(let todos) = snapshot.payload { + TodoListBody(todos: todos) + } else if case .assistantMessage(let text) = snapshot.payload, + snapshot.source == .claude { + FeedMarkdownInlineText( + text: text, + fontSize: 11, + foregroundColor: .secondary.opacity(0.85) + ) + .lineLimit(3) + .truncationMode(.tail) + } else if !summary.isEmpty { + Text(summary) + .cmuxFont(size: 11, design: .monospaced) + .foregroundColor(.secondary.opacity(0.85)) + .lineLimit(3) + .truncationMode(.tail) + } + } + + private var summary: String { + switch snapshot.payload { + case .toolUse(let name, let json): + return "\(name) \(json)" + case .toolResult(let name, let json, let err): + let status = err + ? String(localized: "feed.telemetry.error", defaultValue: "error") + : String(localized: "feed.telemetry.ok", defaultValue: "ok") + return "\(name) \(status) \(json)" + case .userPrompt(let text), .assistantMessage(let text): + return text + case .sessionStart: + return String(localized: "feed.telemetry.sessionStart", defaultValue: "session start") + case .sessionEnd: + return String(localized: "feed.telemetry.sessionEnd", defaultValue: "session end") + case .stop(let reason): + let label = String(localized: "feed.telemetry.stop", defaultValue: "stop") + guard let reason, !reason.isEmpty else { return label } + return "\(label) \(reason)" + default: + return "" + } + } +} + +private struct TodoListBody: View { + let todos: [WorkstreamTaskTodo] + + @State private var expanded = false + + private var done: [WorkstreamTaskTodo] { todos.filter { $0.state == .completed } } + private var inProgress: [WorkstreamTaskTodo] { todos.filter { $0.state == .inProgress } } + private var pending: [WorkstreamTaskTodo] { todos.filter { $0.state == .pending } } + + private var visibleDone: [WorkstreamTaskTodo] { + expanded ? done : Array(done.prefix(2)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 4) { + Text(String(localized: "feed.todos.title", defaultValue: "Tasks")) + .cmuxFont(size: 11, weight: .semibold) + .foregroundColor(.primary.opacity(0.9)) + Text(summaryLabel) + .cmuxFont(size: 10) + .foregroundColor(.secondary) + } + VStack(alignment: .leading, spacing: 3) { + ForEach(inProgress, id: \.id) { row($0) } + ForEach(pending, id: \.id) { row($0) } + ForEach(visibleDone, id: \.id) { row($0) } + if done.count > visibleDone.count { + Button { + expanded.toggle() + } label: { + Text(String( + localized: "feed.todos.moreCompleted", + defaultValue: "... +\(done.count - visibleDone.count) completed" + )) + .cmuxFont(size: 11) + .foregroundColor(.secondary.opacity(0.8)) + .padding(.leading, 22) + } + .buttonStyle(.plain) + } + if expanded && done.count > 2 { + Button { expanded = false } label: { + Text(String(localized: "feed.todos.collapse", defaultValue: "Collapse")) + .cmuxFont(size: 11) + .foregroundColor(.secondary.opacity(0.8)) + .padding(.leading, 22) + } + .buttonStyle(.plain) + } + } + } + } + + private var summaryLabel: String { + let d = done.count, ip = inProgress.count, p = pending.count + var parts: [String] = [] + if d > 0 { + parts.append(String(localized: "feed.todos.summary.done", defaultValue: "\(d) done")) + } + if ip > 0 { + parts.append(String(localized: "feed.todos.summary.inProgress", defaultValue: "\(ip) in progress")) + } + if p > 0 { + parts.append(String(localized: "feed.todos.summary.open", defaultValue: "\(p) open")) + } + return "(" + parts.joined(separator: ", ") + ")" + } + + @ViewBuilder + private func row(_ todo: WorkstreamTaskTodo) -> some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: symbol(for: todo.state)) + .cmuxFont(size: 11, weight: .medium) + .foregroundColor(color(for: todo.state)) + .frame(width: 14, height: 14) + Text(todo.content) + .cmuxFont(size: 12) + .foregroundColor(todo.state == .completed + ? .secondary.opacity(0.7) + : .primary.opacity(0.9)) + .strikethrough(todo.state == .completed, color: .secondary.opacity(0.6)) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } + } + + private func symbol(for state: WorkstreamTaskTodo.State) -> String { + switch state { + case .completed: return "checkmark.square.fill" + case .inProgress: return "circle.fill" + case .pending: return "square" + } + } + + private func color(for state: WorkstreamTaskTodo.State) -> Color { + switch state { + case .completed: return .secondary.opacity(0.7) + case .inProgress: return .blue + case .pending: return .secondary + } + } +} + +/// Dashed separator between pending items and resolved ones. +private struct ResolvedDivider: View { + var body: some View { + HStack(spacing: 8) { + line + Text(String(localized: "feed.divider.resolved", defaultValue: "Resolved")) + .cmuxFont(size: 10, weight: .medium) + .tracking(0.5) + .foregroundColor(.secondary.opacity(0.7)) + line + } + .padding(.vertical, 2) + } + + private var line: some View { + Rectangle() + .fill(Color.primary.opacity(0.08)) + .frame(height: 1) + } +} + +// MARK: - Kind → SF Symbol + +extension WorkstreamKind { + var symbolName: String { + switch self { + case .permissionRequest: return "lock.shield" + case .exitPlan: return "list.bullet.rectangle" + case .question: return "questionmark.circle" + case .toolUse, .toolResult: return "terminal" + case .userPrompt: return "person" + case .assistantMessage: return "sparkles" + case .sessionStart, .sessionEnd: return "play.circle" + case .stop: return "stop.circle" + case .todos: return "checklist" + } + } +} diff --git a/Sources/FileExplorerState.swift b/Sources/FileExplorerState.swift index 7f92a0b86d42..7dbb46ab070d 100644 --- a/Sources/FileExplorerState.swift +++ b/Sources/FileExplorerState.swift @@ -3,6 +3,7 @@ import SwiftUI // MARK: - State (visibility toggle) +@MainActor final class FileExplorerState: ObservableObject { private static let modeKey = "rightSidebar.mode" private static let customSidebarNameKey = "rightSidebar.customSidebarName" diff --git a/Sources/NewWorkspaceMenuModel.swift b/Sources/NewWorkspaceMenuModel.swift index 6501aacc169e..060ff05b83c0 100644 --- a/Sources/NewWorkspaceMenuModel.swift +++ b/Sources/NewWorkspaceMenuModel.swift @@ -19,6 +19,7 @@ struct NewWorkspaceMenuModel: Equatable { enum Section: Equatable { case create([CreateRow]) + case feed case cloud case layouts([LayoutRow]) case templates([String]) @@ -30,6 +31,7 @@ struct NewWorkspaceMenuModel: Equatable { static func build( newWorkspaceContextMenuItems: [CmuxResolvedConfigContextMenuItem], agentChatAction: CmuxResolvedConfigAction?, + feedSectionEnabled: Bool = false, cloudSectionEnabled: Bool, templateNames: [String], loadedActions: [CmuxResolvedConfigAction], @@ -102,6 +104,7 @@ struct NewWorkspaceMenuModel: Equatable { var sections: [Section] = [] let createSection: Section? = createRows.isEmpty ? nil : .create(createRows) + let feedSection: Section? = feedSectionEnabled ? .feed : nil let cloudSection: Section? = cloudSectionEnabled ? .cloud : nil let layoutsSection: Section? = layoutRows.isEmpty ? nil : .layouts(layoutRows) @@ -111,9 +114,9 @@ struct NewWorkspaceMenuModel: Equatable { // labeled Layouts section) stays above the built-in Cloud VM section. switch sectionOrder { case .customFirst: - sections.append(contentsOf: [createSection, layoutsSection, cloudSection].compactMap { $0 }) + sections.append(contentsOf: [createSection, feedSection, layoutsSection, cloudSection].compactMap { $0 }) case .cloudFirst: - sections.append(contentsOf: [cloudSection, createSection, layoutsSection].compactMap { $0 }) + sections.append(contentsOf: [cloudSection, createSection, feedSection, layoutsSection].compactMap { $0 }) } if !templateNames.isEmpty { sections.append(.templates(templateNames)) diff --git a/Sources/RightSidebarMode+Availability.swift b/Sources/RightSidebarMode+Availability.swift index 26e8440683ac..982a8a7d24b0 100644 --- a/Sources/RightSidebarMode+Availability.swift +++ b/Sources/RightSidebarMode+Availability.swift @@ -19,9 +19,10 @@ extension RightSidebarMode { } } + @MainActor static func availableModes(defaults: UserDefaults = .standard) -> [RightSidebarMode] { availableModes( - feedEnabled: RightSidebarBetaFeatureSettings.isFeedEnabled(defaults: defaults), + feedEnabled: CmuxFeatureFlags.shared.isFeedUIEnabled, dockEnabled: RightSidebarBetaFeatureSettings.isDockEnabled(defaults: defaults) ) } @@ -30,9 +31,10 @@ extension RightSidebarMode { allCases.filter { $0 != .customSidebar && $0.isAvailable(feedEnabled: feedEnabled, dockEnabled: dockEnabled) } } + @MainActor func isAvailable(defaults: UserDefaults = .standard) -> Bool { isAvailable( - feedEnabled: RightSidebarBetaFeatureSettings.isFeedEnabled(defaults: defaults), + feedEnabled: CmuxFeatureFlags.shared.isFeedUIEnabled, dockEnabled: RightSidebarBetaFeatureSettings.isDockEnabled(defaults: defaults) ) } diff --git a/Sources/RightSidebarModeShortcutMatcher.swift b/Sources/RightSidebarModeShortcutMatcher.swift index a219733fff42..86d0c72cc4f5 100644 --- a/Sources/RightSidebarModeShortcutMatcher.swift +++ b/Sources/RightSidebarModeShortcutMatcher.swift @@ -7,7 +7,7 @@ import Foundation @MainActor final class RightSidebarModeShortcutMatcher { typealias ShortcutProvider = (KeyboardShortcutSettings.Action) -> StoredShortcut - typealias Availability = (RightSidebarMode) -> Bool + typealias Availability = @MainActor (RightSidebarMode) -> Bool typealias LayoutCharacterProvider = (UInt16, NSEvent.ModifierFlags) -> String? private let shortcutProvider: ShortcutProvider diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 2022ecec4bc0..243a2781450a 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -56,7 +56,7 @@ enum RightSidebarMode: String, CaseIterable, Codable, Sendable { } extension RightSidebarMode { - static let paneModes: [RightSidebarMode] = [.files, .find, .sessions] + static let paneModes: [RightSidebarMode] = [.files, .find, .sessions, .feed] var canOpenAsPane: Bool { Self.paneModes.contains(self) @@ -82,10 +82,12 @@ enum FileExplorerRootSyncPolicy { } extension RightSidebarMode { + @MainActor static func modeShortcut(for event: NSEvent) -> RightSidebarMode? { modeShortcut(for: event, allowingAction: { _ in true }) } + @MainActor static func modeShortcut( for event: NSEvent, allowingAction: (KeyboardShortcutSettings.Action) -> Bool @@ -132,10 +134,9 @@ struct RightSidebarPanelView: View { private let focusShortcutHintXOffset = ShortcutHintDebugSettings.defaultRightSidebarFocusHintX private let focusShortcutHintYOffset = ShortcutHintDebugSettings.defaultRightSidebarFocusHintY @LiveSetting(\.shortcuts.showModifierHoldHints) private var showModifierHoldHints - @AppStorage(RightSidebarBetaFeatureSettings.feedEnabledKey) - private var feedEnabled = RightSidebarBetaFeatureSettings.defaultFeedEnabled @AppStorage(RightSidebarBetaFeatureSettings.dockEnabledKey) private var dockEnabled = RightSidebarBetaFeatureSettings.defaultDockEnabled + private let featureFlags = CmuxFeatureFlags.shared // Re-reading the observable store inside modeBar causes SwiftUI to // track the pending count so the badge updates live when hooks push @@ -145,7 +146,10 @@ struct RightSidebarPanelView: View { } private var availableModes: [RightSidebarMode] { - RightSidebarMode.availableModes(feedEnabled: feedEnabled, dockEnabled: dockEnabled) + RightSidebarMode.availableModes( + feedEnabled: featureFlags.isFeedUIEnabled, + dockEnabled: dockEnabled + ) } private var modeBarItems: [RightSidebarModeBarItem] { @@ -209,7 +213,7 @@ struct RightSidebarPanelView: View { .onChange(of: fileExplorerState.isVisible) { _, visible in if visible { hasMountedRightSidebarContent = true } } - .onChange(of: feedEnabled) { _, _ in refreshModeAvailabilityAndFocusIfNeeded() } + .onChange(of: featureFlags.isFeedUIEnabled) { _, _ in refreshModeAvailabilityAndFocusIfNeeded() } .onChange(of: dockEnabled) { _, _ in refreshModeAvailabilityAndFocusIfNeeded() } } diff --git a/Sources/RightSidebarToolPanel.swift b/Sources/RightSidebarToolPanel.swift index f923507d70a5..b2dcc531d662 100644 --- a/Sources/RightSidebarToolPanel.swift +++ b/Sources/RightSidebarToolPanel.swift @@ -294,7 +294,9 @@ struct RightSidebarToolPanelView: View { RightSidebarToolFocusAnchor(onViewChange: panel.attachSessionIndexFocusAnchor) .frame(width: 0, height: 0) ) - case .feed, .dock, .customSidebar: + case .feed: + FeedPanelView() + case .dock, .customSidebar: EmptyView() } } diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index 388d40fd095d..c5f47d9fa1f7 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -416,7 +416,6 @@ enum SettingsSearchIndex { setting(.sidebarAppearance, "right-max-width", String(localized: "settings.sidebar.rightMaxWidth", defaultValue: "Dock Max Width"), "dock right sidebar max width terminal reservation cap logs lazygit"), setting(.customSidebars, "enabled", String(localized: "settings.customSidebars.enabled", defaultValue: "Show Custom Sidebars"), "custom sidebars enable show vibe swift json interpreted picker"), setting(.customSidebars, "renderer", String(localized: "settings.customSidebars.renderer", defaultValue: "Renderer"), "renderer in-process in app remote worker isolated process hover focus typing input"), - setting(.betaFeatures, "feed", String(localized: "settings.betaFeatures.feed", defaultValue: "Feed"), "feed right sidebar agent decisions permissions questions"), setting(.betaFeatures, "dock", String(localized: "settings.betaFeatures.dock", defaultValue: "Dock"), "dock right sidebar terminal controls tui"), setting(.betaFeatures, "workspace-todos-checklist-style", String(localized: "settings.betaFeatures.workspaceTodosChecklistStyle", defaultValue: "Checklist Style"), "workspace todo todos task status checklist popover inline presentation style beta"), setting(.automation, "socket-mode", String(localized: "settings.automation.socketMode", defaultValue: "Socket Control Mode"), "unix socket api access password auth"), @@ -480,7 +479,6 @@ enum SettingsSearchIndex { ) private static let settingsPathAnchorIDs: [String: String] = [ - "rightSidebar.beta.feed.enabled": settingID(for: .betaFeatures, idSuffix: "feed"), "rightSidebar.beta.dock.enabled": settingID(for: .betaFeatures, idSuffix: "dock"), "app.language": settingID(for: .app, idSuffix: "language"), "app.appearance": settingID(for: .app, idSuffix: "appearance"), diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index 37c79581f24b..9f5133301d13 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -129,7 +129,6 @@ enum SettingsSearchAliasIndex { "sidebarAppearance:notification-badge-position": localized("settings.search.alias.setting.app.notification-badge-position", defaultValue: "sidebar.notificationBadgePosition notification unread badge position left right leading trailing side workspace"), "sidebarAppearance:show-metadata": localized("settings.search.alias.setting.app.show-metadata", defaultValue: "sidebar.showCustomMetadata metadata meta report_meta status custom block"), "sidebarAppearance:right-max-width": localized("settings.search.alias.setting.sidebarAppearance.right-max-width", defaultValue: "sidebar.rightMaxWidth dock right sidebar max width terminal reservation cap logs lazygit"), - "betaFeatures:feed": localized("settings.search.alias.setting.betaFeatures.feed", defaultValue: "feed right sidebar agent decisions permissions questions approval beta unstable"), "betaFeatures:dock": localized("settings.search.alias.setting.betaFeatures.dock", defaultValue: "dock right sidebar terminal controls tui beta unstable"), "mobile:iOSPairingHost": localized("settings.search.alias.setting.mobile.iOSPairingHost", defaultValue: "ios iphone ipad mobile pairing local network permission sync"), "mobile:iOSPairingPort": localized("settings.search.alias.setting.mobile.iOSPairingPort", defaultValue: "mobile ios iphone pairing port tcp listener firewall conflict bind"), diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 1144bc80facb..411bb5d511c0 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1086,6 +1086,8 @@ class TabManager: ObservableObject { // single-panel title sync keeps the workspace title following // the page title once the user navigates. defaultTitle = String(localized: "browser.newTab", defaultValue: "New tab") + case .feed: + defaultTitle = String(localized: "rightSidebar.mode.feed", defaultValue: "Feed") case .cloudVMLoading: defaultTitle = String(localized: "workspace.cloudVM.defaultTitle", defaultValue: "Cloud VM") } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 76aeffaf2c12..6aee58058bba 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2996,6 +2996,22 @@ final class Workspace: Identifiable, ObservableObject { initialTabId = tabId } installBrowserPanelSubscription(browserPanel) + } else if initialSurface == .feed { + let feedPanel = RightSidebarToolPanel(workspace: self, mode: .feed) + panels[feedPanel.id] = feedPanel + panelTitles[feedPanel.id] = feedPanel.displayTitle + + if let tabId = bonsplitController.createTab( + title: feedPanel.displayTitle, + icon: feedPanel.displayIcon, + kind: SurfaceKind.rightSidebarTool.rawValue, + isDirty: false, + isLoading: false, + isPinned: false + ) { + bindSurface(tabId, toPanelId: feedPanel.id) + initialTabId = tabId + } } else if initialSurface == .cloudVMLoading { let loadingPanel = CloudVMLoadingPanel(workspaceId: id) panels[loadingPanel.id] = loadingPanel diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c561cdbda1b5..e75b58a28b35 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -740,17 +740,34 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources E50320010000000000000001 /* ExtensionWorktreeSpawnArgsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50320010000000000000002 /* ExtensionWorktreeSpawnArgsTests.swift */; }; C0DEA7710000000000000007 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA7710000000000000008 /* FeatureFlags.swift */; }; FEED0A00000000000000F007 /* feed-tui in Resources */ = {isa = PBXBuildFile; fileRef = FEED0A00000000000000F006 /* feed-tui */; }; + FEE200000000000000000002 /* FeedBlockingWaiterRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000002 /* FeedBlockingWaiterRegistry.swift */; }; FEED0000000000000000F00D /* FeedButtonStyleDebugWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00C /* FeedButtonStyleDebugWindowController.swift */; }; + FEE200000000000000000003 /* FeedCoordinator+Attention.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000003 /* FeedCoordinator+Attention.swift */; }; + FEE200000000000000000004 /* FeedCoordinator+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000004 /* FeedCoordinator+Notifications.swift */; }; + FEE200000000000000000005 /* FeedCoordinator+Socket.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000005 /* FeedCoordinator+Socket.swift */; }; FEED0000000000000000F002 /* FeedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F001 /* FeedCoordinator.swift */; }; + FEE200000000000000000006 /* FeedCoordinatorState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000006 /* FeedCoordinatorState.swift */; }; FEEDC0DEC0DEC0DEC0DE0001 /* FeedCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDC0DEC0DEC0DEC0DE0002 /* FeedCoordinatorTests.swift */; }; FEED49850000000000000001 /* FeedEventClassificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED49850000000000000002 /* FeedEventClassificationTests.swift */; }; FEEDC1A50000000000000001 /* FeedEventClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDC1A50000000000000002 /* FeedEventClassifier.swift */; }; FEEDC1A50000000000000003 /* FeedEventClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDC1A50000000000000002 /* FeedEventClassifier.swift */; }; + FEE20000000000000000000F /* FeedExitPlanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000F /* FeedExitPlanView.swift */; }; + FEE20000000000000000000D /* FeedItemRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000D /* FeedItemRow.swift */; }; + FEE20000000000000000000C /* FeedItemSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000C /* FeedItemSnapshot.swift */; }; + FEE200000000000000000007 /* FeedJumpResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000007 /* FeedJumpResolver.swift */; }; + FEE20000000000000000000B /* FeedKeyboardFocus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000B /* FeedKeyboardFocus.swift */; }; + FEE20000000000000000000A /* FeedListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000A /* FeedListView.swift */; }; + FEE200000000000000000008 /* FeedNotificationPolicyContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000008 /* FeedNotificationPolicyContext.swift */; }; + FEE200000000000000000001 /* FeedOpeningCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000001 /* FeedOpeningCoordinator.swift */; }; FEED0000000000000000F005 /* FeedPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F004 /* FeedPanelView.swift */; }; FEED0000000000000000F011 /* FeedPanelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F010 /* FeedPanelViewModel.swift */; }; FEED0000000000000000F013 /* FeedPermissionActionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */; }; + FEE20000000000000000000E /* FeedPermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000E /* FeedPermissionView.swift */; }; FEED0000000000000000F00B /* FeedPreviewWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00A /* FeedPreviewWindowController.swift */; }; + FEE200000000000000000010 /* FeedQuestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000010 /* FeedQuestionView.swift */; }; FEED0000000000000000F009 /* FeedSidebarUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F008 /* FeedSidebarUITests.swift */; }; + FEE200000000000000000009 /* FeedSocketEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000009 /* FeedSocketEncoding.swift */; }; + FEE200000000000000000011 /* FeedTelemetryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000011 /* FeedTelemetryView.swift */; }; FEED0000000000000000F00F /* FeedTextEditorDebugWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00E /* FeedTextEditorDebugWindowController.swift */; }; D0B1001AA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1001BA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift */; }; D0B10020A1B2C3D4E5F60001 /* FileDropOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10021A1B2C3D4E5F60001 /* FileDropOverlayView.swift */; }; @@ -2654,16 +2671,33 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E50320010000000000000002 /* ExtensionWorktreeSpawnArgsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionWorktreeSpawnArgsTests.swift; sourceTree = ""; }; C0DEA7710000000000000008 /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; FEED0A00000000000000F006 /* feed-tui */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; path = "feed-tui"; sourceTree = ""; }; + FEE100000000000000000002 /* FeedBlockingWaiterRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedBlockingWaiterRegistry.swift; sourceTree = ""; }; FEED0000000000000000F00C /* FeedButtonStyleDebugWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedButtonStyleDebugWindowController.swift; sourceTree = ""; }; + FEE100000000000000000003 /* FeedCoordinator+Attention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedCoordinator+Attention.swift"; sourceTree = ""; }; + FEE100000000000000000004 /* FeedCoordinator+Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedCoordinator+Notifications.swift"; sourceTree = ""; }; + FEE100000000000000000005 /* FeedCoordinator+Socket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedCoordinator+Socket.swift"; sourceTree = ""; }; FEED0000000000000000F001 /* FeedCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedCoordinator.swift; sourceTree = ""; }; + FEE100000000000000000006 /* FeedCoordinatorState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCoordinatorState.swift; sourceTree = ""; }; FEEDC0DEC0DEC0DEC0DE0002 /* FeedCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCoordinatorTests.swift; sourceTree = ""; }; FEED49850000000000000002 /* FeedEventClassificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedEventClassificationTests.swift; sourceTree = ""; }; FEEDC1A50000000000000002 /* FeedEventClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedEventClassifier.swift; sourceTree = ""; }; + FEE10000000000000000000F /* FeedExitPlanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedExitPlanView.swift; sourceTree = ""; }; + FEE10000000000000000000D /* FeedItemRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemRow.swift; sourceTree = ""; }; + FEE10000000000000000000C /* FeedItemSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemSnapshot.swift; sourceTree = ""; }; + FEE100000000000000000007 /* FeedJumpResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedJumpResolver.swift; sourceTree = ""; }; + FEE10000000000000000000B /* FeedKeyboardFocus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedKeyboardFocus.swift; sourceTree = ""; }; + FEE10000000000000000000A /* FeedListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListView.swift; sourceTree = ""; }; + FEE100000000000000000008 /* FeedNotificationPolicyContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedNotificationPolicyContext.swift; sourceTree = ""; }; + FEE100000000000000000001 /* FeedOpeningCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedOpeningCoordinator.swift; sourceTree = ""; }; FEED0000000000000000F004 /* FeedPanelView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPanelView.swift; sourceTree = ""; }; FEED0000000000000000F010 /* FeedPanelViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPanelViewModel.swift; sourceTree = ""; }; FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPermissionActionPolicy.swift; sourceTree = ""; }; + FEE10000000000000000000E /* FeedPermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPermissionView.swift; sourceTree = ""; }; FEED0000000000000000F00A /* FeedPreviewWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPreviewWindowController.swift; sourceTree = ""; }; + FEE100000000000000000010 /* FeedQuestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedQuestionView.swift; sourceTree = ""; }; FEED0000000000000000F008 /* FeedSidebarUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSidebarUITests.swift; sourceTree = ""; }; + FEE100000000000000000009 /* FeedSocketEncoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSocketEncoding.swift; sourceTree = ""; }; + FEE100000000000000000011 /* FeedTelemetryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedTelemetryView.swift; sourceTree = ""; }; FEED0000000000000000F00E /* FeedTextEditorDebugWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedTextEditorDebugWindowController.swift; sourceTree = ""; }; D0B1001BA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropHintBadgeView.swift; sourceTree = ""; }; D0B10021A1B2C3D4E5F60001 /* FileDropOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayView.swift; sourceTree = ""; }; @@ -5837,7 +5871,24 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = isa = PBXGroup; children = ( FEED0000000000000000F001 /* FeedCoordinator.swift */, + FEE100000000000000000003 /* FeedCoordinator+Attention.swift */, + FEE100000000000000000004 /* FeedCoordinator+Notifications.swift */, + FEE100000000000000000005 /* FeedCoordinator+Socket.swift */, + FEE100000000000000000006 /* FeedCoordinatorState.swift */, + FEE100000000000000000002 /* FeedBlockingWaiterRegistry.swift */, + FEE100000000000000000007 /* FeedJumpResolver.swift */, + FEE100000000000000000008 /* FeedNotificationPolicyContext.swift */, + FEE100000000000000000009 /* FeedSocketEncoding.swift */, + FEE100000000000000000001 /* FeedOpeningCoordinator.swift */, FEED0000000000000000F004 /* FeedPanelView.swift */, + FEE10000000000000000000A /* FeedListView.swift */, + FEE10000000000000000000B /* FeedKeyboardFocus.swift */, + FEE10000000000000000000C /* FeedItemSnapshot.swift */, + FEE10000000000000000000D /* FeedItemRow.swift */, + FEE10000000000000000000E /* FeedPermissionView.swift */, + FEE10000000000000000000F /* FeedExitPlanView.swift */, + FEE100000000000000000010 /* FeedQuestionView.swift */, + FEE100000000000000000011 /* FeedTelemetryView.swift */, FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */, FEED0000000000000000F010 /* FeedPanelViewModel.swift */, FEED0000000000000000F00A /* FeedPreviewWindowController.swift */, @@ -6658,12 +6709,29 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E4C001000000000000000001 /* ExtensionSidebarWorkspaceRowView.swift in Sources */, E4C001000000000000000003 /* ExtensionWorktreePrototype.swift in Sources */, C0DEA7710000000000000007 /* FeatureFlags.swift in Sources */, + FEE200000000000000000002 /* FeedBlockingWaiterRegistry.swift in Sources */, FEED0000000000000000F00D /* FeedButtonStyleDebugWindowController.swift in Sources */, + FEE200000000000000000003 /* FeedCoordinator+Attention.swift in Sources */, + FEE200000000000000000004 /* FeedCoordinator+Notifications.swift in Sources */, + FEE200000000000000000005 /* FeedCoordinator+Socket.swift in Sources */, FEED0000000000000000F002 /* FeedCoordinator.swift in Sources */, + FEE200000000000000000006 /* FeedCoordinatorState.swift in Sources */, + FEE20000000000000000000F /* FeedExitPlanView.swift in Sources */, + FEE20000000000000000000D /* FeedItemRow.swift in Sources */, + FEE20000000000000000000C /* FeedItemSnapshot.swift in Sources */, + FEE200000000000000000007 /* FeedJumpResolver.swift in Sources */, + FEE20000000000000000000B /* FeedKeyboardFocus.swift in Sources */, + FEE20000000000000000000A /* FeedListView.swift in Sources */, + FEE200000000000000000008 /* FeedNotificationPolicyContext.swift in Sources */, + FEE200000000000000000001 /* FeedOpeningCoordinator.swift in Sources */, FEED0000000000000000F005 /* FeedPanelView.swift in Sources */, FEED0000000000000000F011 /* FeedPanelViewModel.swift in Sources */, FEED0000000000000000F013 /* FeedPermissionActionPolicy.swift in Sources */, + FEE20000000000000000000E /* FeedPermissionView.swift in Sources */, FEED0000000000000000F00B /* FeedPreviewWindowController.swift in Sources */, + FEE200000000000000000010 /* FeedQuestionView.swift in Sources */, + FEE200000000000000000009 /* FeedSocketEncoding.swift in Sources */, + FEE200000000000000000011 /* FeedTelemetryView.swift in Sources */, FEED0000000000000000F00F /* FeedTextEditorDebugWindowController.swift in Sources */, D0B1001AA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift in Sources */, D0B10020A1B2C3D4E5F60001 /* FileDropOverlayView.swift in Sources */, diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index c18dd8744133..b6a695bd88f9 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -10,6 +10,34 @@ import CMUXAgentLaunch @Suite("Feed coordinator", .serialized) struct FeedCoordinatorTests { + @MainActor + @Test func feedWorkspaceContainsOnlyFeedAndIsPinned() throws { + let manager = TabManager() + let coordinator = FeedOpeningCoordinator(isEnabled: { true }) + + let workspace = try #require(coordinator.openPinnedWorkspace(in: manager)) + + #expect(workspace.isPinned) + #expect(manager.selectedTabId == workspace.id) + #expect(workspace.panels.count == 1) + let panel = try #require(workspace.panels.values.first as? RightSidebarToolPanel) + #expect(panel.mode == .feed) + #expect(!workspace.panels.values.contains { $0 is TerminalPanel }) + } + + @MainActor + @Test func feedPaneUsesNativeRightSidebarToolSurface() throws { + let manager = TabManager() + let workspace = manager.addWorkspace() + let coordinator = FeedOpeningCoordinator(isEnabled: { true }) + + let panel = try #require(coordinator.openPane(in: workspace)) + + #expect(panel.mode == .feed) + #expect(workspace.panels[panel.id] === panel) + #expect(workspace.focusedPanelId == panel.id) + } + @Test func codexTeamsResolvesExplicitWorkingDirectoryFlags() { let base = "/tmp/cmux-base" diff --git a/cmuxTests/FileExplorerStateModePersistenceTests.swift b/cmuxTests/FileExplorerStateModePersistenceTests.swift index 6295f0318671..ad307c99c019 100644 --- a/cmuxTests/FileExplorerStateModePersistenceTests.swift +++ b/cmuxTests/FileExplorerStateModePersistenceTests.swift @@ -1,5 +1,5 @@ import Foundation -import XCTest +import Testing #if canImport(cmux_DEV) @testable import cmux_DEV @@ -7,108 +7,111 @@ import XCTest @testable import cmux #endif -final class FileExplorerStateModePersistenceTests: XCTestCase { +@MainActor +@Suite("Right sidebar mode persistence", .serialized) +struct FileExplorerStateModePersistenceTests { private let modeKey = "rightSidebar.mode" private let customSidebarNameKey = "rightSidebar.customSidebarName" - private let feedEnabledKey = RightSidebarBetaFeatureSettings.feedEnabledKey private let dockEnabledKey = RightSidebarBetaFeatureSettings.dockEnabledKey - func testDisabledFeedStoredModeFallsBackToFiles() { - withSavedRightSidebarModeDefaults { - let defaults = UserDefaults.standard - defaults.set(RightSidebarMode.feed.rawValue, forKey: modeKey) - defaults.set(false, forKey: feedEnabledKey) - - let state = FileExplorerState() - - XCTAssertEqual(state.mode, .files) - XCTAssertEqual(defaults.string(forKey: modeKey), RightSidebarMode.files.rawValue) + @Test("Remote-disabled Feed falls back to Files") + func remoteDisabledFeedStoredModeFallsBackToFiles() throws { + try withSavedRightSidebarModeDefaults { + try withFeedFlag(false) { + UserDefaults.standard.set(RightSidebarMode.feed.rawValue, forKey: modeKey) + let state = FileExplorerState() + #expect(state.mode == .files) + #expect(UserDefaults.standard.string(forKey: modeKey) == RightSidebarMode.files.rawValue) + } } } - func testEnabledFeedStoredModeSurvives() { - withSavedRightSidebarModeDefaults { - let defaults = UserDefaults.standard - defaults.set(RightSidebarMode.feed.rawValue, forKey: modeKey) - defaults.set(true, forKey: feedEnabledKey) - - let state = FileExplorerState() - - XCTAssertEqual(state.mode, .feed) - XCTAssertEqual(defaults.string(forKey: modeKey), RightSidebarMode.feed.rawValue) + @Test("Default-on Feed stored mode survives") + func defaultOnFeedStoredModeSurvives() throws { + try withSavedRightSidebarModeDefaults { + try withFeedFlag(true) { + UserDefaults.standard.set(RightSidebarMode.feed.rawValue, forKey: modeKey) + let state = FileExplorerState() + #expect(state.mode == .feed) + #expect(UserDefaults.standard.string(forKey: modeKey) == RightSidebarMode.feed.rawValue) + } } } - func testModeSetterClampsUnavailableBetaModes() { - withSavedRightSidebarModeDefaults { - let defaults = UserDefaults.standard - defaults.set(false, forKey: feedEnabledKey) - defaults.set(false, forKey: dockEnabledKey) - let state = FileExplorerState() - - state.mode = .feed - XCTAssertEqual(state.mode, .files) - XCTAssertEqual(defaults.string(forKey: modeKey), RightSidebarMode.files.rawValue) - - defaults.set(true, forKey: dockEnabledKey) - state.mode = .dock - XCTAssertEqual(state.mode, .dock) - XCTAssertEqual(defaults.string(forKey: modeKey), RightSidebarMode.dock.rawValue) - - defaults.set(false, forKey: dockEnabledKey) - state.refreshModeAvailability() - XCTAssertEqual(state.mode, .files) - XCTAssertEqual(defaults.string(forKey: modeKey), RightSidebarMode.files.rawValue) + @Test("Mode setter clamps unavailable modes") + func modeSetterClampsUnavailableModes() throws { + try withSavedRightSidebarModeDefaults { + try withFeedFlag(false) { + let defaults = UserDefaults.standard + defaults.set(false, forKey: dockEnabledKey) + let state = FileExplorerState() + + state.mode = .feed + #expect(state.mode == .files) + + defaults.set(true, forKey: dockEnabledKey) + state.mode = .dock + #expect(state.mode == .dock) + + defaults.set(false, forKey: dockEnabledKey) + state.refreshModeAvailability() + #expect(state.mode == .files) + } } } - func testStoredCustomSidebarModeFallsBackToFiles() { - withSavedRightSidebarModeDefaults { + @Test("Stored custom sidebar mode falls back to Files") + func storedCustomSidebarModeFallsBackToFiles() throws { + try withSavedRightSidebarModeDefaults { let defaults = UserDefaults.standard defaults.set(RightSidebarMode.customSidebar.rawValue, forKey: modeKey) defaults.set("status-board", forKey: customSidebarNameKey) - let state = FileExplorerState() - - XCTAssertEqual(state.mode, .files) - XCTAssertEqual(defaults.string(forKey: modeKey), RightSidebarMode.files.rawValue) + #expect(state.mode == .files) + #expect(defaults.string(forKey: modeKey) == RightSidebarMode.files.rawValue) } } - func testCLIArgumentNormalizerMapsVaultAndSessionsToSessions() { - XCTAssertEqual(RightSidebarMode.from(cliArgument: "files"), .files) - XCTAssertEqual(RightSidebarMode.from(cliArgument: "find"), .find) - XCTAssertEqual(RightSidebarMode.from(cliArgument: "vault"), .sessions) - XCTAssertEqual(RightSidebarMode.from(cliArgument: "sessions"), .sessions) - XCTAssertEqual(RightSidebarMode.from(cliArgument: "feed"), .feed) - XCTAssertEqual(RightSidebarMode.from(cliArgument: "dock"), .dock) - XCTAssertEqual(RightSidebarMode.from(cliArgument: " Vault "), .sessions) - XCTAssertNil(RightSidebarMode.from(cliArgument: "custom-sidebar")) - XCTAssertNil(RightSidebarMode.from(cliArgument: "custom")) - XCTAssertNil(RightSidebarMode.from(cliArgument: "unknown")) + @Test("CLI aliases normalize to sidebar modes") + func cliAliasesNormalizeToSidebarModes() { + #expect(RightSidebarMode.from(cliArgument: "files") == .files) + #expect(RightSidebarMode.from(cliArgument: "find") == .find) + #expect(RightSidebarMode.from(cliArgument: "vault") == .sessions) + #expect(RightSidebarMode.from(cliArgument: "sessions") == .sessions) + #expect(RightSidebarMode.from(cliArgument: "feed") == .feed) + #expect(RightSidebarMode.from(cliArgument: "dock") == .dock) + #expect(RightSidebarMode.from(cliArgument: " Vault ") == .sessions) + #expect(RightSidebarMode.from(cliArgument: "custom-sidebar") == nil) + #expect(RightSidebarMode.from(cliArgument: "unknown") == nil) + } + + private func withFeedFlag(_ enabled: Bool, _ body: () throws -> T) throws -> T { + let flags = CmuxFeatureFlags.shared + let definition = try #require(CmuxFeatureFlags.allFlags.first { $0.key == "feed-ui-enabled-release" }) + let previous = flags.overrideValue(for: definition) + flags.setOverride(enabled, for: definition) + defer { flags.setOverride(previous, for: definition) } + return try body() } - private func withSavedRightSidebarModeDefaults(_ body: () -> Void) { + private func withSavedRightSidebarModeDefaults(_ body: () throws -> T) rethrows -> T { let defaults = UserDefaults.standard let previousMode = defaults.object(forKey: modeKey) let previousCustomSidebarName = defaults.object(forKey: customSidebarNameKey) - let previousFeedEnabled = defaults.object(forKey: feedEnabledKey) let previousDockEnabled = defaults.object(forKey: dockEnabledKey) defer { restore(previousMode, forKey: modeKey) restore(previousCustomSidebarName, forKey: customSidebarNameKey) - restore(previousFeedEnabled, forKey: feedEnabledKey) restore(previousDockEnabled, forKey: dockEnabledKey) } - body() + return try body() } private func restore(_ value: Any?, forKey key: String) { - let defaults = UserDefaults.standard if let value { - defaults.set(value, forKey: key) + UserDefaults.standard.set(value, forKey: key) } else { - defaults.removeObject(forKey: key) + UserDefaults.standard.removeObject(forKey: key) } } } diff --git a/cmuxTests/NewWorkspaceMenuModelTests.swift b/cmuxTests/NewWorkspaceMenuModelTests.swift index 1111f006d5ed..975ad4c0e842 100644 --- a/cmuxTests/NewWorkspaceMenuModelTests.swift +++ b/cmuxTests/NewWorkspaceMenuModelTests.swift @@ -11,6 +11,26 @@ import Testing /// Plus-button menu section model: section classification/ordering and the /// always-present management tail. struct NewWorkspaceMenuModelTests { + @Test func feedGetsDedicatedCreateSection() { + let model = NewWorkspaceMenuModel.build( + newWorkspaceContextMenuItems: [], + agentChatAction: nil, + feedSectionEnabled: true, + cloudSectionEnabled: false, + templateNames: [], + loadedActions: [], + newWorkspaceActionID: nil, + deletable: { _ in false }, + sectionOrder: .customFirst + ) + + #expect(model.sections.count == 2) + guard case .feed = model.sections[0], case .management = model.sections[1] else { + Issue.record("Expected Feed followed by workspace management, got \(model.sections)") + return + } + } + @MainActor private func loadStore(globalJSON: String) throws -> (store: CmuxConfigStore, root: URL) { diff --git a/cmuxTests/PostHogAnalyticsPropertiesTests.swift b/cmuxTests/PostHogAnalyticsPropertiesTests.swift index 71bac55eb098..daeb9f37d3ff 100644 --- a/cmuxTests/PostHogAnalyticsPropertiesTests.swift +++ b/cmuxTests/PostHogAnalyticsPropertiesTests.swift @@ -6,6 +6,27 @@ import Testing @Suite(.serialized) struct PostHogAnalyticsPropertiesTests { + @MainActor + @Test("Feed feature flag defaults on and supports a remote kill switch") + func feedFeatureFlagDefaultsOnAndSupportsRemoteKillSwitch() throws { + let definition = try #require(CmuxFeatureFlags.allFlags.first { $0.key == "feed-ui-enabled-release" }) + #expect(definition.defaultWhenUnavailable) + + let suiteName = "cmux.feature.flags.feed.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + var remoteValue: Any? + let flags = CmuxFeatureFlags(defaults: defaults) { key in + key == definition.key ? remoteValue : nil + } + #expect(flags.isFeedUIEnabled) + + remoteValue = false + flags.applyLoadedFlags() + #expect(!flags.isFeedUIEnabled) + } + @Test("feature flag bool coercion accepts PostHog bool-like values") func featureFlagBoolCoercionAcceptsPostHogBoolLikeValues() { #expect(CmuxFeatureFlags.coerceBoolFlagValue(true, default: false)) diff --git a/cmuxTests/RightSidebarCommandPaletteTests.swift b/cmuxTests/RightSidebarCommandPaletteTests.swift index e1d621f65041..943658d0538c 100644 --- a/cmuxTests/RightSidebarCommandPaletteTests.swift +++ b/cmuxTests/RightSidebarCommandPaletteTests.swift @@ -1,6 +1,6 @@ import CmuxCommandPalette import Foundation -import XCTest +import Testing #if canImport(cmux_DEV) @testable import cmux_DEV @@ -8,86 +8,89 @@ import XCTest @testable import cmux #endif -final class RightSidebarCommandPaletteTests: XCTestCase { - func testCommandPaletteIncludesDefaultRightSidebarModes() throws { - try withSavedBetaFeatureDefaults { +@MainActor +@Suite("Right sidebar command palette", .serialized) +struct RightSidebarCommandPaletteTests { + @Test("Feed commands are available by default") + func feedCommandsAreAvailableByDefault() throws { + try withFeedFlag(true) { let defaults = UserDefaults.standard - defaults.removeObject(forKey: RightSidebarBetaFeatureSettings.feedEnabledKey) + let previousDock = defaults.object(forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) + defer { restore(previousDock, forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) } defaults.removeObject(forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) + let contributions = ContentView.commandPaletteRightSidebarModeCommandContributions() let contributionsByID = Dictionary(uniqueKeysWithValues: contributions.map { ($0.commandId, $0) }) let context = CommandPaletteContextSnapshot() for mode in RightSidebarMode.availableModes() { let commandID = ContentView.commandPaletteRightSidebarModeCommandID(mode) - let contribution = try XCTUnwrap( - contributionsByID[commandID], - "Expected command palette contribution for \(mode.rawValue)" - ) - - XCTAssertEqual(contribution.title(context), mode.shortcutAction?.label ?? mode.label) - XCTAssertEqual( - contribution.subtitle(context), - String(localized: "command.rightSidebarMode.subtitle", defaultValue: "Right Sidebar") - ) - XCTAssertTrue(contribution.keywords.contains("right")) - XCTAssertTrue(contribution.keywords.contains("sidebar")) - XCTAssertTrue(contribution.keywords.contains(mode.rawValue)) - XCTAssertTrue(contribution.when(context)) - XCTAssertTrue(contribution.enablement(context)) + let contribution = try #require(contributionsByID[commandID]) + #expect(contribution.title(context) == mode.shortcutAction?.label ?? mode.label) + #expect(contribution.subtitle(context) == String( + localized: "command.rightSidebarMode.subtitle", + defaultValue: "Right Sidebar" + )) + #expect(contribution.keywords.contains("right")) + #expect(contribution.keywords.contains("sidebar")) + #expect(contribution.keywords.contains(mode.rawValue)) + #expect(contribution.when(context)) + #expect(contribution.enablement(context)) } - XCTAssertEqual(contributions.count, 3) - XCTAssertNil(contributionsByID[ContentView.commandPaletteRightSidebarModeCommandID(.feed)]) - XCTAssertNil(contributionsByID[ContentView.commandPaletteRightSidebarModeCommandID(.dock)]) + #expect(contributions.count == 4) + #expect(contributionsByID[ContentView.commandPaletteRightSidebarModeCommandID(.feed)] != nil) + #expect(contributionsByID[ContentView.commandPaletteRightSidebarModeCommandID(.dock)] == nil) + + let paneDescriptors = ContentView.commandPaletteRightSidebarToolPaneCommandDescriptors() + #expect(paneDescriptors.contains { $0.mode == .feed && $0.commandId == "palette.openFeedPane" }) } } - func testCommandPaletteRightSidebarActionsUseModeShortcutActions() { - withSavedBetaFeatureDefaults { - let defaults = UserDefaults.standard - defaults.set(true, forKey: RightSidebarBetaFeatureSettings.feedEnabledKey) - defaults.set(true, forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) + @Test("Remote Feed flag hides sidebar and pane commands") + func remoteFeedFlagHidesCommands() throws { + try withFeedFlag(false) { + let contributions = ContentView.commandPaletteRightSidebarModeCommandContributions() + #expect(!contributions.contains { + $0.commandId == ContentView.commandPaletteRightSidebarModeCommandID(.feed) + }) + #expect(!ContentView.commandPaletteRightSidebarToolPaneCommandDescriptors().contains { $0.mode == .feed }) + } + } + @Test("Right sidebar actions use configurable shortcuts") + func rightSidebarActionsUseModeShortcutActions() throws { + try withFeedFlag(true) { for mode in RightSidebarMode.allCases { - XCTAssertEqual( - ContentView.commandPaletteShortcutAction( - forCommandID: ContentView.commandPaletteRightSidebarModeCommandID(mode) - ), - mode.shortcutAction - ) + #expect(ContentView.commandPaletteShortcutAction( + forCommandID: ContentView.commandPaletteRightSidebarModeCommandID(mode) + ) == mode.shortcutAction) } } } - func testCommandPaletteUnreadActionsUseConfigurableShortcutActions() { - XCTAssertEqual( - ContentView.commandPaletteShortcutAction(forCommandID: "palette.toggleUnread"), - .toggleUnread - ) - XCTAssertEqual( - ContentView.commandPaletteShortcutAction(forCommandID: "palette.markOldestUnreadAndJumpNext"), - .markOldestUnreadAndJumpNext - ) + @Test("Unread actions use configurable shortcuts") + func unreadActionsUseConfigurableShortcuts() { + #expect(ContentView.commandPaletteShortcutAction(forCommandID: "palette.toggleUnread") == .toggleUnread) + #expect(ContentView.commandPaletteShortcutAction( + forCommandID: "palette.markOldestUnreadAndJumpNext" + ) == .markOldestUnreadAndJumpNext) } - private func withSavedBetaFeatureDefaults(_ body: () throws -> Void) rethrows { - let defaults = UserDefaults.standard - let previousFeed = defaults.object(forKey: RightSidebarBetaFeatureSettings.feedEnabledKey) - let previousDock = defaults.object(forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) - defer { - restore(previousFeed, forKey: RightSidebarBetaFeatureSettings.feedEnabledKey) - restore(previousDock, forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) - } - try body() + private func withFeedFlag(_ enabled: Bool, _ body: () throws -> T) throws -> T { + let flags = CmuxFeatureFlags.shared + let definition = try #require(CmuxFeatureFlags.allFlags.first { $0.key == "feed-ui-enabled-release" }) + let previous = flags.overrideValue(for: definition) + flags.setOverride(enabled, for: definition) + defer { flags.setOverride(previous, for: definition) } + return try body() } private func restore(_ value: Any?, forKey key: String) { - let defaults = UserDefaults.standard if let value { - defaults.set(value, forKey: key) + UserDefaults.standard.set(value, forKey: key) } else { - defaults.removeObject(forKey: key) + UserDefaults.standard.removeObject(forKey: key) } } } From 684aef922b07a0f1650470f569f2c35a6a210c56 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:37:14 -0700 Subject: [PATCH 02/49] test: keep Feed panes out of sidebar focus --- .../ShortcutAndCommandPaletteTests.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 55148e876ee9..a1c01167ca57 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -1378,9 +1378,30 @@ final class RightSidebarModeShortcutHintTests: XCTestCase { final class MainWindowFocusControllerRightSidebarHideTests: XCTestCase { private final class TestRightSidebarResponder: NSView, FeedKeyboardFocusResponder { + let isRightSidebarFeedResponder = true + + override var acceptsFirstResponder: Bool { true } + } + + private final class TestFeedPaneResponder: NSView, FeedKeyboardFocusResponder { + let isRightSidebarFeedResponder = false + override var acceptsFirstResponder: Bool { true } } + @MainActor + func testFeedPaneResponderDoesNotClaimRightSidebarFocus() { + let controller = MainWindowFocusController( + windowId: UUID(), + window: nil, + tabManager: TabManager(), + fileExplorerState: FileExplorerState() + ) + let responder = TestFeedPaneResponder(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) + + XCTAssertFalse(controller.ownsRightSidebarFocus(responder)) + } + @MainActor func testHiddenRightSidebarClearsFocusIntentWhenNoTerminalCanRestore() { let controller = MainWindowFocusController( From d6f55865ed3be9b48b271e176b491d5632198c97 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:54:58 -0700 Subject: [PATCH 03/49] Fix Feed pane interaction and navigation --- Resources/Localizable.xcstrings | 17 ---- ...FeedButtonStyleDebugWindowController.swift | 12 +-- Sources/Feed/FeedItemRow.swift | 36 +++++--- Sources/Feed/FeedKeyboardFocus.swift | 71 ++++++++++++-- Sources/Feed/FeedListView.swift | 67 ++++++++++++-- Sources/Feed/FeedPanelView.swift | 15 +++ Sources/Feed/FeedPermissionView.swift | 16 +--- .../Feed/FeedPreviewWindowController.swift | 5 +- Sources/Feed/FeedQuestionView.swift | 92 ++++++------------- Sources/Feed/FeedTelemetryView.swift | 5 +- Sources/MainWindowFocusController.swift | 5 +- Sources/MainWindowFocusTypes.swift | 13 ++- Sources/RightSidebarToolPanel.swift | 20 +++- .../ShortcutAndCommandPaletteTests.swift | 12 ++- 14 files changed, 246 insertions(+), 140 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index a9f8ee891442..01348d73f333 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -103399,23 +103399,6 @@ } } }, - "feed.question.multiSelect": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Multi-select" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "複数選択" - } - } - } - }, "feed.question.noOptions": { "extractionState": "manual", "localizations": { diff --git a/Sources/Feed/FeedButtonStyleDebugWindowController.swift b/Sources/Feed/FeedButtonStyleDebugWindowController.swift index 96873d292548..f6a3fd93ff6c 100644 --- a/Sources/Feed/FeedButtonStyleDebugWindowController.swift +++ b/Sources/Feed/FeedButtonStyleDebugWindowController.swift @@ -338,11 +338,11 @@ enum FeedButtonDebugSettings { } static var compactVerticalPadding: Double { - double(forKey: compactVerticalPaddingKey, defaultValue: 4) + double(forKey: compactVerticalPaddingKey, defaultValue: 5) } static var mediumVerticalPadding: Double { - double(forKey: mediumVerticalPaddingKey, defaultValue: 5) + double(forKey: mediumVerticalPaddingKey, defaultValue: 7) } static var glassTintOpacity: Double { @@ -870,9 +870,9 @@ private struct FeedButtonStyleDebugView: View { @AppStorage(FeedButtonDebugSettings.mediumHorizontalPaddingKey) private var mediumHorizontalPadding = 12.0 @AppStorage(FeedButtonDebugSettings.compactVerticalPaddingKey) - private var compactVerticalPadding = 4.0 + private var compactVerticalPadding = 5.0 @AppStorage(FeedButtonDebugSettings.mediumVerticalPaddingKey) - private var mediumVerticalPadding = 5.0 + private var mediumVerticalPadding = 7.0 @AppStorage(FeedButtonDebugSettings.glassTintOpacityKey) private var glassTintOpacity = 0.42 @AppStorage(FeedButtonDebugSettings.borderWidthKey) @@ -929,8 +929,8 @@ private struct FeedButtonStyleDebugView: View { mediumCornerRadius = 6.0 compactHorizontalPadding = 8.0 mediumHorizontalPadding = 12.0 - compactVerticalPadding = 4.0 - mediumVerticalPadding = 5.0 + compactVerticalPadding = 5.0 + mediumVerticalPadding = 7.0 glassTintOpacity = 0.42 borderWidth = 0.9 } diff --git a/Sources/Feed/FeedItemRow.swift b/Sources/Feed/FeedItemRow.swift index aafa9984a5ad..95f4ddfd3a95 100644 --- a/Sources/Feed/FeedItemRow.swift +++ b/Sources/Feed/FeedItemRow.swift @@ -17,6 +17,8 @@ struct FeedItemRow: View, Equatable { let stopDraftValue: FeedStopDraft @Binding var stopFocusRequest: Int let stopFocusRequestValue: Int + let placement: FeedPlacement + let focusScopeID: UUID @State private var didHandlePressSelection = false @@ -25,20 +27,29 @@ struct FeedItemRow: View, Equatable { && lhs.isSelected == rhs.isSelected && lhs.stopDraftValue == rhs.stopDraftValue && lhs.stopFocusRequestValue == rhs.stopFocusRequestValue + && lhs.placement == rhs.placement + && lhs.focusScopeID == rhs.focusScopeID } var body: some View { VStack(alignment: .leading, spacing: 10) { - chipHeader - if let context = displayContext { - FeedContextBlock(context: context, source: snapshot.source) - } else if let echo = promptEcho, !echo.isEmpty { - Text(echo) - .cmuxFont(size: 11) - .foregroundColor(.secondary) - .lineLimit(2) - .truncationMode(.tail) + Button(action: onActivate) { + VStack(alignment: .leading, spacing: 10) { + chipHeader + if let context = displayContext { + FeedContextBlock(context: context, source: snapshot.source) + } else if let echo = promptEcho, !echo.isEmpty { + Text(echo) + .cmuxFont(size: 12) + .foregroundColor(.secondary) + .lineLimit(2) + .truncationMode(.tail) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } + .buttonStyle(.plain) actionArea } .padding(.horizontal, 12) @@ -59,7 +70,6 @@ struct FeedItemRow: View, Equatable { didHandlePressSelection = false } ) - .onTapGesture(count: 2, perform: onActivate) } private var promptEcho: String? { @@ -98,7 +108,7 @@ struct FeedItemRow: View, Equatable { .foregroundColor(kindTint) .frame(width: 14, height: 14) Text(headerTitle) - .cmuxFont(size: 12, weight: .medium) + .cmuxFont(size: 13, weight: .medium) .foregroundColor(.primary.opacity(0.92)) .lineLimit(1) .truncationMode(.middle) @@ -273,6 +283,8 @@ struct FeedItemRow: View, Equatable { onActionRow: onControlAction, onBlurRow: onControlBlur, context: displayContext, + placement: placement, + focusScopeID: focusScopeID, onReply: { selections in actions.replyQuestion(snapshot.id, selections) } @@ -281,6 +293,8 @@ struct FeedItemRow: View, Equatable { StopActionArea( draft: $stopDraft, focusRequest: $stopFocusRequest, + placement: placement, + focusScopeID: focusScopeID, onFocusRow: onControlFocus, onActionRow: onControlAction, onBlurRow: onControlBlur, diff --git a/Sources/Feed/FeedKeyboardFocus.swift b/Sources/Feed/FeedKeyboardFocus.swift index 5dedc75d6a12..900ebe0b957b 100644 --- a/Sources/Feed/FeedKeyboardFocus.swift +++ b/Sources/Feed/FeedKeyboardFocus.swift @@ -19,6 +19,8 @@ struct FeedRowSurface: View { let isFocusActive: Bool let showsDivider: Bool @Binding var stopDraft: FeedStopDraft + let placement: FeedPlacement + let focusScopeID: UUID let onPressSelect: () -> Void let onControlFocus: () -> Void let onControlAction: () -> Void @@ -42,7 +44,9 @@ struct FeedRowSurface: View { stopDraft: $stopDraft, stopDraftValue: stopDraft, stopFocusRequest: $stopReplyFocusRequest, - stopFocusRequestValue: stopReplyFocusRequest + stopFocusRequestValue: stopReplyFocusRequest, + placement: placement, + focusScopeID: focusScopeID ) .equatable() if showsDivider { @@ -104,6 +108,10 @@ extension View { } struct FeedKeyboardFocusBridge: NSViewRepresentable { + let placement: FeedPlacement + let focusScopeID: UUID + let focusRequest: Int + let onHostChange: (FeedKeyboardFocusView?) -> Void let onEscape: () -> Void let onMoveSelection: (Int) -> Void let onActivateSelection: () -> Void @@ -111,18 +119,55 @@ struct FeedKeyboardFocusBridge: NSViewRepresentable { let onFocusChanged: (Bool) -> Void let onFocusSnapshotChanged: (FeedFocusSnapshot) -> Void + final class Coordinator { + var onHostChange: (FeedKeyboardFocusView?) -> Void + weak var host: FeedKeyboardFocusView? + var lastFocusRequest: Int + + init( + onHostChange: @escaping (FeedKeyboardFocusView?) -> Void, + focusRequest: Int + ) { + self.onHostChange = onHostChange + self.lastFocusRequest = focusRequest + } + + func attach(_ host: FeedKeyboardFocusView) { + guard self.host !== host else { return } + self.host = host + onHostChange(host) + } + + func detach(_ host: FeedKeyboardFocusView) { + guard self.host === host else { return } + self.host = nil + onHostChange(nil) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(onHostChange: onHostChange, focusRequest: focusRequest) + } + func makeNSView(context: Context) -> FeedKeyboardFocusView { let view = FeedKeyboardFocusView(frame: NSRect(x: 0, y: 0, width: 1, height: 1)) + view.placement = placement + view.feedFocusScopeID = focusScopeID view.onEscape = onEscape view.onMoveSelection = onMoveSelection view.onActivateSelection = onActivateSelection view.onFocusFirstItemRequested = onFocusFirstItemRequested view.onFocusChanged = onFocusChanged view.onFocusSnapshotChanged = onFocusSnapshotChanged + context.coordinator.attach(view) return view } func updateNSView(_ nsView: FeedKeyboardFocusView, context: Context) { + context.coordinator.onHostChange = onHostChange + context.coordinator.attach(nsView) + nsView.placement = placement + nsView.feedFocusScopeID = focusScopeID nsView.onEscape = onEscape nsView.onMoveSelection = onMoveSelection nsView.onActivateSelection = onActivateSelection @@ -130,10 +175,21 @@ struct FeedKeyboardFocusBridge: NSViewRepresentable { nsView.onFocusChanged = onFocusChanged nsView.onFocusSnapshotChanged = onFocusSnapshotChanged nsView.registerWithKeyboardFocusCoordinatorIfNeeded() + if !placement.usesRightSidebarFocusCoordinator, + focusRequest != context.coordinator.lastFocusRequest { + context.coordinator.lastFocusRequest = focusRequest + _ = nsView.focusHostFromCoordinator() + } + } + + static func dismantleNSView(_ nsView: FeedKeyboardFocusView, coordinator: Coordinator) { + coordinator.detach(nsView) } } final class FeedKeyboardFocusView: NSView { + var placement: FeedPlacement = .rightSidebar + var feedFocusScopeID = UUID() var onEscape: (() -> Void)? var onMoveSelection: ((Int) -> Void)? var onActivateSelection: (() -> Void)? @@ -146,15 +202,16 @@ final class FeedKeyboardFocusView: NSView { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - guard let window else { return } - AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.registerFeedHost(self) + registerWithKeyboardFocusCoordinatorIfNeeded() #if DEBUG - dlog("feed.focus.host attach window=\(ObjectIdentifier(window))") + if let window { + dlog("feed.focus.host attach window=\(ObjectIdentifier(window)) placement=\(placement)") + } #endif } func registerWithKeyboardFocusCoordinatorIfNeeded() { - guard let window else { return } + guard placement.usesRightSidebarFocusCoordinator, let window else { return } AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.registerFeedHost(self) } @@ -282,7 +339,9 @@ final class FeedKeyboardFocusView: NSView { } func ownsKeyboardFocus(_ responder: NSResponder) -> Bool { - responder === self || responder is FeedKeyboardFocusResponder + if responder === self { return true } + guard let feedResponder = responder as? FeedKeyboardFocusResponder else { return false } + return feedResponder.feedFocusScopeID == feedFocusScopeID } } diff --git a/Sources/Feed/FeedListView.swift b/Sources/Feed/FeedListView.swift index 7fb141589ccc..d6f0411bc717 100644 --- a/Sources/Feed/FeedListView.swift +++ b/Sources/Feed/FeedListView.swift @@ -7,6 +7,9 @@ import SwiftUI struct FeedListView: View { let filter: FeedPanelView.Filter let items: [WorkstreamItem] + let placement: FeedPlacement + let focusScopeID: UUID + let onFocusHostChange: (FeedKeyboardFocusView?) -> Void let hasMorePersistedItems: Bool let isLoadingOlderItems: Bool let onLoadOlderItems: () -> Void @@ -15,6 +18,7 @@ struct FeedListView: View { @State private var scrollRequest: FeedScrollRequest? @State private var scrollRequestSequence = 0 @State private var stopDrafts: [UUID: FeedStopDraft] = [:] + @State private var focusHostRequest = 0 var body: some View { let snapshots = visibleSnapshots(items) @@ -39,12 +43,22 @@ struct FeedListView: View { } .background( FeedKeyboardFocusBridge( + placement: placement, + focusScopeID: focusScopeID, + focusRequest: focusHostRequest, + onHostChange: onFocusHostChange, onEscape: { let window = activeFeedWindow() - if AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.focusTerminal() != true { + if placement.usesRightSidebarFocusCoordinator, + AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.focusTerminal() == true { + syncFeedFocusSnapshot(window: window) + return + } + if window?.firstResponder is FeedKeyboardFocusResponder + || window?.firstResponder is FeedKeyboardFocusView { window?.makeFirstResponder(nil) } - syncFeedFocusSnapshot(window: window) + focusSnapshot.isKeyboardActive = false }, onMoveSelection: { delta in moveSelection(in: focusSnapshots, delta: delta) @@ -57,13 +71,17 @@ struct FeedListView: View { }, onFocusChanged: { focused in let window = activeFeedWindow() - if !focused { + if !placement.usesRightSidebarFocusCoordinator { + focusSnapshot.isKeyboardActive = focused + } else if !focused { AppDelegate.shared?.syncKeyboardFocusAfterFirstResponderChange(in: window) + syncFeedFocusSnapshot(window: window) } - syncFeedFocusSnapshot(window: window) }, onFocusSnapshotChanged: { snapshot in - focusSnapshot = snapshot + if placement.usesRightSidebarFocusCoordinator { + focusSnapshot = snapshot + } } ) .frame(width: 1, height: 1) @@ -195,6 +213,8 @@ struct FeedListView: View { isFocusActive: focusSnapshot.isKeyboardActive && focusSnapshot.selectedItemId == snapshot.id, showsDivider: showsDivider, stopDraft: stopDraftBinding(for: snapshot.id), + placement: placement, + focusScopeID: focusScopeID, onPressSelect: { selectRow(snapshot.id, focusFeed: false) }, @@ -205,10 +225,14 @@ struct FeedListView: View { selectRow(snapshot.id, focusFeed: true) }, onControlBlur: { - syncFeedFocusSnapshot() + if !placement.usesRightSidebarFocusCoordinator { + focusSnapshot.isKeyboardActive = false + } else { + syncFeedFocusSnapshot() + } }, onActivate: { - selectRow(snapshot.id, focusFeed: true) + selectRow(snapshot.id, focusFeed: false) actions.jump(snapshot.workstreamId) } ) @@ -301,6 +325,12 @@ struct FeedListView: View { } let optimisticSnapshot = FeedFocusSnapshot(selectedItemId: id, isKeyboardActive: true) focusSnapshot = optimisticSnapshot + if !placement.usesRightSidebarFocusCoordinator { + if focusFeed { + focusHostRequest &+= 1 + } + return + } if let controller = AppDelegate.shared?.keyboardFocusCoordinator(for: window) { _ = controller.selectFeedItem(id, focusFeed: focusFeed) focusSnapshot = controller.feedFocusSnapshot() @@ -321,7 +351,12 @@ struct FeedListView: View { private func focusFirstVisibleItem(in snapshots: [FeedItemSnapshot], focusHost: Bool = true) { guard let targetId = preferredFocusItemId(in: snapshots) else { let window = activeFeedWindow() - if focusHost { + if !placement.usesRightSidebarFocusCoordinator { + if focusHost { + focusHostRequest &+= 1 + } + focusSnapshot.isKeyboardActive = focusHost + } else if focusHost { _ = AppDelegate.shared?.focusRightSidebarInActiveMainWindow( mode: .feed, focusFirstItem: false, @@ -343,6 +378,13 @@ struct FeedListView: View { private func preferredFocusItemId(in snapshots: [FeedItemSnapshot]) -> UUID? { let ids = snapshots.map(\.id) + if !placement.usesRightSidebarFocusCoordinator { + if let selectedItemId = focusSnapshot.selectedItemId, + ids.contains(selectedItemId) { + return selectedItemId + } + return ids.first + } let window = activeFeedWindow() if let controllerSelectedId = AppDelegate.shared? .keyboardFocusCoordinator(for: window)? @@ -370,6 +412,12 @@ struct FeedListView: View { targetIndex = delta >= 0 ? 0 : ids.count - 1 } let targetId = ids[targetIndex] + if !placement.usesRightSidebarFocusCoordinator { + focusSnapshot = FeedFocusSnapshot(selectedItemId: targetId, isKeyboardActive: true) + scrollRequestSequence &+= 1 + scrollRequest = FeedScrollRequest(id: targetId, sequence: scrollRequestSequence) + return + } let window = activeFeedWindow() if let controller = AppDelegate.shared?.keyboardFocusCoordinator(for: window) { _ = controller.selectFeedItem(targetId, focusFeed: false) @@ -399,7 +447,7 @@ struct FeedListView: View { } else { snapshot = snapshots[0] } - selectRow(snapshot.id, focusFeed: true) + selectRow(snapshot.id, focusFeed: false) actions.jump(snapshot.workstreamId) } @@ -408,6 +456,7 @@ struct FeedListView: View { } private func syncFeedFocusSnapshot(window: NSWindow? = nil) { + guard placement.usesRightSidebarFocusCoordinator else { return } let targetWindow = window ?? activeFeedWindow() guard let controller = AppDelegate.shared?.keyboardFocusCoordinator(for: targetWindow) else { return diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 75d56681afcc..ae5bf0cf9627 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -72,8 +72,20 @@ struct FeedPanelView: View { } } + let placement: FeedPlacement + let onFocusHostChange: (FeedKeyboardFocusView?) -> Void + @State private var filter: Filter = .actionable @State private var viewModel = FeedPanelViewModel() + @State private var focusScopeID = UUID() + + init( + placement: FeedPlacement = .rightSidebar, + onFocusHostChange: @escaping (FeedKeyboardFocusView?) -> Void = { _ in } + ) { + self.placement = placement + self.onFocusHostChange = onFocusHostChange + } var body: some View { VStack(spacing: 0) { @@ -81,6 +93,9 @@ struct FeedPanelView: View { FeedListView( filter: filter, items: viewModel.items, + placement: placement, + focusScopeID: focusScopeID, + onFocusHostChange: onFocusHostChange, hasMorePersistedItems: viewModel.hasMorePersistedItems, isLoadingOlderItems: viewModel.isLoadingOlderItems, onLoadOlderItems: viewModel.loadOlderItems diff --git a/Sources/Feed/FeedPermissionView.swift b/Sources/Feed/FeedPermissionView.swift index a14696cbc884..02b0b6167109 100644 --- a/Sources/Feed/FeedPermissionView.swift +++ b/Sources/Feed/FeedPermissionView.swift @@ -1,4 +1,3 @@ -import AppKit import CMUXAgentLaunch import CmuxFoundation import Foundation @@ -306,15 +305,6 @@ struct FeedButton: View { private func handleHover(_ hovering: Bool) { isHovered = hovering - // Only swap the cursor when the button is disabled — - // enabled buttons keep the default arrow so the Feed - // feels like the rest of the app. Pop on mouseout so a - // stale "not allowed" cursor doesn't stick. - if dimmed, hovering { - NSCursor.operationNotAllowed.push() - } else if dimmed, !hovering { - NSCursor.pop() - } } #if DEBUG @@ -417,8 +407,8 @@ struct FeedButton: View { // MARK: - Style resolution - private var labelSize: CGFloat { size == .compact ? 10 : 10.5 } - private var iconSize: CGFloat { size == .compact ? 9 : 10 } + private var labelSize: CGFloat { size == .compact ? 11 : 12 } + private var iconSize: CGFloat { size == .compact ? 10 : 11 } private var iconSpacing: CGFloat { size == .compact ? 3 : 5 } private var cornerRadius: CGFloat { #if DEBUG @@ -447,7 +437,7 @@ struct FeedButton: View { ? CGFloat(FeedButtonDebugSettings.compactVerticalPadding) : CGFloat(FeedButtonDebugSettings.mediumVerticalPadding) #else - return size == .compact ? 4 : 5 + return size == .compact ? 5 : 7 #endif } diff --git a/Sources/Feed/FeedPreviewWindowController.swift b/Sources/Feed/FeedPreviewWindowController.swift index a3f897b83694..8e72073697e9 100644 --- a/Sources/Feed/FeedPreviewWindowController.swift +++ b/Sources/Feed/FeedPreviewWindowController.swift @@ -116,6 +116,7 @@ private struct FeedPreviewCardHost: View { @State private var stopDraft = FeedStopDraft() @State private var stopFocusRequest = 0 + @State private var focusScopeID = UUID() var body: some View { FeedItemRow( @@ -133,7 +134,9 @@ private struct FeedPreviewCardHost: View { stopDraft: $stopDraft, stopDraftValue: stopDraft, stopFocusRequest: $stopFocusRequest, - stopFocusRequestValue: stopFocusRequest + stopFocusRequestValue: stopFocusRequest, + placement: .pane, + focusScopeID: focusScopeID ) } } diff --git a/Sources/Feed/FeedQuestionView.swift b/Sources/Feed/FeedQuestionView.swift index 10e5ca8b0499..63929e8be227 100644 --- a/Sources/Feed/FeedQuestionView.swift +++ b/Sources/Feed/FeedQuestionView.swift @@ -14,6 +14,8 @@ struct QuestionActionArea: View { let onActionRow: () -> Void let onBlurRow: () -> Void let context: WorkstreamContext? + let placement: FeedPlacement + let focusScopeID: UUID let onReply: ([String]) -> Void private static let skipInterviewAndPlanAnswer = "Skip interview and plan immediately" @@ -126,7 +128,7 @@ struct QuestionActionArea: View { } label: { HStack(alignment: .top, spacing: 10) { Text("\(index)") - .cmuxFont(size: 11, weight: .bold, monospacedDigit: true) + .cmuxFont(size: 12, weight: .bold, monospacedDigit: true) .foregroundColor(selected ? .white : .secondary) .frame(width: 20, height: 20) .background( @@ -135,11 +137,11 @@ struct QuestionActionArea: View { ) VStack(alignment: .leading, spacing: 2) { Text(option.label) - .cmuxFont(size: 12, weight: .semibold) + .cmuxFont(size: 13, weight: .semibold) .foregroundColor(.primary) if let description = option.description, !description.isEmpty { Text(description) - .cmuxFont(size: 11) + .cmuxFont(size: 12) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } @@ -173,10 +175,10 @@ struct QuestionActionArea: View { let customId = Self.customAnswerSelectionId let selected = selections[questionId]?.contains(customId) == true let focusKey = customAnswerFocusKey(questionId) - let font = GlobalFontMagnification.systemFont(ofSize: 12, weight: .semibold) + let font = GlobalFontMagnification.systemFont(ofSize: 13, weight: .semibold) return HStack(alignment: .top, spacing: 10) { Text("\(index)") - .cmuxFont(size: 11, weight: .bold, monospacedDigit: true) + .cmuxFont(size: 12, weight: .bold, monospacedDigit: true) .foregroundColor(selected ? .white : .secondary) .frame(width: 20, height: 20) .background( @@ -216,7 +218,6 @@ struct QuestionActionArea: View { selectCustomAnswer(questionId: questionId, multi: multi) requestCustomAnswerFocus(focusKey) } - .feedIBeamCursorOnHover(enabled: status.isPending) .disabled(!status.isPending) } @@ -224,29 +225,13 @@ struct QuestionActionArea: View { VStack(alignment: .leading, spacing: 5) { HStack(alignment: .top, spacing: 5) { Text("\(index).") - .cmuxFont(size: 11, weight: .semibold, monospacedDigit: true) + .cmuxFont(size: 12, weight: .semibold, monospacedDigit: true) .foregroundColor(.blue) Text(question.prompt) - .cmuxFont(size: 11, weight: .medium) + .cmuxFont(size: 13, weight: .medium) .foregroundColor(.primary.opacity(0.95)) .fixedSize(horizontal: false, vertical: true) } - if question.multiSelect { - HStack(spacing: 3) { - Image(systemName: "checklist") - .cmuxFont(size: 8, weight: .medium) - Text(String(localized: "feed.question.multiSelect", defaultValue: "Multi-select")) - .cmuxFont(size: 9, weight: .semibold) - .tracking(0.3) - } - .foregroundColor(.orange) - .padding(.horizontal, 4) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill(Color.orange.opacity(0.18)) - ) - } if question.options.isEmpty { Text(String(localized: "feed.question.noOptions", defaultValue: "Agent provided no options.")) @@ -275,7 +260,7 @@ struct QuestionActionArea: View { /// preset option selection for that question on submit. private func freeFormField(questionId: String, multi: Bool) -> some View { let focusKey = customAnswerFocusKey(questionId) - let font = GlobalFontMagnification.systemFont(ofSize: 11) + let font = GlobalFontMagnification.systemFont(ofSize: 12) return customAnswerField( text: customAnswerBinding(questionId: questionId, multi: multi), focusRequest: focusRequest(forCustomAnswerKey: focusKey), @@ -297,7 +282,6 @@ struct QuestionActionArea: View { .stroke(Color.primary.opacity(0.10), lineWidth: 1) ) .contentShape(Rectangle()) - .feedIBeamCursorOnHover(enabled: status.isPending) .onTapGesture { guard status.isPending else { return } onFocusRow() @@ -320,6 +304,8 @@ struct QuestionActionArea: View { defaultValue: "Type something..."), isEnabled: status.isPending, font: font, + placement: placement, + focusScopeID: focusScopeID, onFocus: onFocus, onBlur: onBlur, onSubmit: nil @@ -517,6 +503,7 @@ final class FeedInlineNativeTextView: NSTextView, FeedKeyboardFocusResponder { var onActivate: (() -> Void)? var onEscape: (() -> Void)? var onSubmit: (() -> Void)? + var feedFocusScopeID = UUID() static func blurActiveEditor() { guard let activeEditor else { return } @@ -769,6 +756,8 @@ struct FeedInlineTextField: NSViewRepresentable { let placeholder: String let isEnabled: Bool let font: NSFont + let placement: FeedPlacement + let focusScopeID: UUID let onFocus: () -> Void let onBlur: () -> Void let onSubmit: (() -> Void)? @@ -798,7 +787,12 @@ struct FeedInlineTextField: NSViewRepresentable { #if DEBUG dlog("feed.editor.blurField frBefore=\(feedDebugResponderSummary(window.firstResponder))") #endif - Task { @MainActor in + guard parent.placement.usesRightSidebarFocusCoordinator else { + window.makeFirstResponder(nil) + return + } + Task { @MainActor [weak window] in + guard let window else { return } if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( mode: .feed, focusFirstItem: false, @@ -896,6 +890,10 @@ struct FeedInlineTextField: NSViewRepresentable { } private func moveFocusToFeedHost(in window: NSWindow) { + guard placement.usesRightSidebarFocusCoordinator else { + window.makeFirstResponder(nil) + return + } if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( mode: .feed, focusFirstItem: false, @@ -908,6 +906,7 @@ struct FeedInlineTextField: NSViewRepresentable { private func configure(_ view: FeedInlineTextEditorView) { view.placeholder = placeholder + view.textView.feedFocusScopeID = focusScopeID view.apply(font: font, isEnabled: isEnabled) } @@ -927,45 +926,6 @@ struct FeedInlineTextField: NSViewRepresentable { } } -private struct FeedHoverCursorModifier: ViewModifier { - let enabled: Bool - let cursor: NSCursor - - @State private var cursorPushed = false - - func body(content: Content) -> some View { - content - .onHover { hovering in - if hovering, enabled { - pushIfNeeded() - } else { - popIfNeeded() - } - } - .onDisappear { - popIfNeeded() - } - } - - private func pushIfNeeded() { - guard !cursorPushed else { return } - cursor.push() - cursorPushed = true - } - - private func popIfNeeded() { - guard cursorPushed else { return } - NSCursor.pop() - cursorPushed = false - } -} - -extension View { - func feedIBeamCursorOnHover(enabled: Bool) -> some View { - modifier(FeedHoverCursorModifier(enabled: enabled, cursor: .iBeam)) - } -} - /// Minimal wrapping HStack that flows its children into multiple rows. private struct WrapHStack: View { let spacing: CGFloat diff --git a/Sources/Feed/FeedTelemetryView.swift b/Sources/Feed/FeedTelemetryView.swift index 0b6a2fbb1f77..d1f4052daba1 100644 --- a/Sources/Feed/FeedTelemetryView.swift +++ b/Sources/Feed/FeedTelemetryView.swift @@ -8,6 +8,8 @@ struct StopActionArea: View { @Binding var draft: FeedStopDraft @Binding var focusRequest: Int + let placement: FeedPlacement + let focusScopeID: UUID let onFocusRow: () -> Void let onActionRow: () -> Void let onBlurRow: () -> Void @@ -43,6 +45,8 @@ struct StopActionArea: View { placeholder: String(localized: "feed.stop.placeholder", defaultValue: "Reply to Claude…"), isEnabled: true, font: replyFont, + placement: placement, + focusScopeID: focusScopeID, onFocus: onFocusRow, onBlur: onBlurRow, onSubmit: sendReply @@ -63,7 +67,6 @@ struct StopActionArea: View { .stroke(Color.primary.opacity(canSend ? 0.25 : 0.10), lineWidth: 1) ) .contentShape(Rectangle()) - .feedIBeamCursorOnHover(enabled: true) .onTapGesture { onFocusRow() requestReplyFocus() diff --git a/Sources/MainWindowFocusController.swift b/Sources/MainWindowFocusController.swift index ac8183c134e9..9ae4f6df6a18 100644 --- a/Sources/MainWindowFocusController.swift +++ b/Sources/MainWindowFocusController.swift @@ -201,9 +201,6 @@ final class MainWindowFocusController { if let host = rightSidebarHost, responder === host { return true } - if responder is FeedKeyboardFocusResponder { - return true - } if fileExplorerHost?.ownsKeyboardFocus(responder) == true || fileSearchHost?.ownsKeyboardFocus(responder) == true { return true @@ -806,7 +803,7 @@ final class MainWindowFocusController { if fileSearchHost?.ownsKeyboardFocus(responder) == true { return .find } - if feedHost?.ownsKeyboardFocus(responder) == true || responder is FeedKeyboardFocusResponder { + if feedHost?.ownsKeyboardFocus(responder) == true { return .feed } if dockHost?.ownsKeyboardFocus(responder) == true { diff --git a/Sources/MainWindowFocusTypes.swift b/Sources/MainWindowFocusTypes.swift index 926303dd6fd5..85edbd10c3cb 100644 --- a/Sources/MainWindowFocusTypes.swift +++ b/Sources/MainWindowFocusTypes.swift @@ -10,7 +10,18 @@ struct FeedFocusSnapshot: Equatable { } } -protocol FeedKeyboardFocusResponder: AnyObject {} +enum FeedPlacement: Equatable, Sendable { + case rightSidebar + case pane + + var usesRightSidebarFocusCoordinator: Bool { + self == .rightSidebar + } +} + +protocol FeedKeyboardFocusResponder: AnyObject { + var feedFocusScopeID: UUID { get } +} enum MainWindowKeyboardFocusIntent: Equatable { case mainPanel(workspaceId: UUID, panelId: UUID) diff --git a/Sources/RightSidebarToolPanel.swift b/Sources/RightSidebarToolPanel.swift index b2dcc531d662..1a81aa4643d6 100644 --- a/Sources/RightSidebarToolPanel.swift +++ b/Sources/RightSidebarToolPanel.swift @@ -14,6 +14,7 @@ final class RightSidebarToolPanel: Panel, ObservableObject { private weak var workspace: Workspace? private weak var fileExplorerContainerView: FileExplorerContainerView? private weak var sessionIndexFocusAnchorView: RightSidebarToolFocusAnchorView? + private weak var feedFocusAnchorView: FeedKeyboardFocusView? private var fileExplorerStoreStorage: FileExplorerStore? private var fileExplorerStateStorage: FileExplorerState? private var sessionIndexStoreStorage: SessionIndexStore? @@ -74,6 +75,10 @@ final class RightSidebarToolPanel: Panel, ObservableObject { sessionIndexFocusAnchorView = anchor } + fileprivate func attachFeedFocusAnchor(_ anchor: FeedKeyboardFocusView?) { + feedFocusAnchorView = anchor + } + func syncWorkspaceRoot(from workspace: Workspace) { switch mode { case .files, .find: @@ -125,6 +130,7 @@ final class RightSidebarToolPanel: Panel, ObservableObject { func close() { fileExplorerContainerView = nil sessionIndexFocusAnchorView = nil + feedFocusAnchorView = nil fileExplorerStoreStorage?.applyWorkspaceRoot(.none) sessionIndexStoreStorage?.setCurrentDirectoryIfChanged(nil) workspaceObservationCancellable = nil @@ -140,7 +146,9 @@ final class RightSidebarToolPanel: Panel, ObservableObject { guard let anchor = sessionIndexFocusAnchorView, let window = anchor.window else { return } _ = window.makeFirstResponder(anchor) - case .feed, .dock, .customSidebar: + case .feed: + _ = feedFocusAnchorView?.focusHostFromCoordinator() + case .dock, .customSidebar: break } } @@ -162,7 +170,10 @@ final class RightSidebarToolPanel: Panel, ObservableObject { case .sessions: guard sessionIndexFocusAnchorView?.ownsKeyboardFocus(responder) == true else { return nil } return .panel - case .feed, .dock, .customSidebar: + case .feed: + guard feedFocusAnchorView?.ownsKeyboardFocus(responder) == true else { return nil } + return .panel + case .dock, .customSidebar: return nil } } @@ -295,7 +306,10 @@ struct RightSidebarToolPanelView: View { .frame(width: 0, height: 0) ) case .feed: - FeedPanelView() + FeedPanelView( + placement: .pane, + onFocusHostChange: panel.attachFeedFocusAnchor + ) case .dock, .customSidebar: EmptyView() } diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index a1c01167ca57..c1d150efbb3e 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -1378,13 +1378,13 @@ final class RightSidebarModeShortcutHintTests: XCTestCase { final class MainWindowFocusControllerRightSidebarHideTests: XCTestCase { private final class TestRightSidebarResponder: NSView, FeedKeyboardFocusResponder { - let isRightSidebarFeedResponder = true + let feedFocusScopeID = UUID() override var acceptsFirstResponder: Bool { true } } private final class TestFeedPaneResponder: NSView, FeedKeyboardFocusResponder { - let isRightSidebarFeedResponder = false + let feedFocusScopeID = UUID() override var acceptsFirstResponder: Bool { true } } @@ -1402,6 +1402,11 @@ final class MainWindowFocusControllerRightSidebarHideTests: XCTestCase { XCTAssertFalse(controller.ownsRightSidebarFocus(responder)) } + func testOnlySidebarFeedPlacementUsesSidebarFocusCoordinator() { + XCTAssertTrue(FeedPlacement.rightSidebar.usesRightSidebarFocusCoordinator) + XCTAssertFalse(FeedPlacement.pane.usesRightSidebarFocusCoordinator) + } + @MainActor func testHiddenRightSidebarClearsFocusIntentWhenNoTerminalCanRestore() { let controller = MainWindowFocusController( @@ -1471,6 +1476,9 @@ final class MainWindowFocusControllerRightSidebarHideTests: XCTestCase { fileExplorerState: FileExplorerState() ) let responder = TestRightSidebarResponder(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) + let feedHost = FeedKeyboardFocusView(frame: NSRect(x: 0, y: 0, width: 1, height: 1)) + feedHost.feedFocusScopeID = responder.feedFocusScopeID + controller.registerFeedHost(feedHost) let workspaceId = UUID() let panelId = UUID() From 063015c05e0366cc811a40e35c781f99c8d42d09 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:41:59 -0700 Subject: [PATCH 04/49] Align Feed pane filters with content edge --- Sources/Feed/FeedPanelView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index ae5bf0cf9627..69de389e9e0c 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -117,7 +117,9 @@ struct FeedPanelView: View { controlBarContent #endif } - .rightSidebarChromeBar() + .rightSidebarChromeBar( + leadingPadding: placement == .pane ? 0 : RightSidebarChromeMetrics.barHorizontalPadding + ) .rightSidebarChromeBottomBorder() .reportRightSidebarChromeGeometryForBonsplitUITest(role: .secondaryBar, isVisible: true, titlebarHeight: RightSidebarChromeMetrics.secondaryBarHeight) } From e70b5f8973f5f0a6d7a5a1ab3c4b29d220b91c60 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:52:39 -0700 Subject: [PATCH 05/49] Fix Feed Swift concurrency checks --- Sources/Feed/FeedListView.swift | 2 +- cmuxTests/ShortcutAndCommandPaletteTests.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Feed/FeedListView.swift b/Sources/Feed/FeedListView.swift index d6f0411bc717..6ee8098134c1 100644 --- a/Sources/Feed/FeedListView.swift +++ b/Sources/Feed/FeedListView.swift @@ -37,7 +37,7 @@ struct FeedListView: View { ) } } - .onChange(of: scrollRequest) { request in + .onChange(of: scrollRequest) { _, request in guard let request else { return } proxy.scrollTo(request.id, anchor: .top) } diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index c1d150efbb3e..2680de29c582 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -1178,6 +1178,7 @@ final class ShortcutHintModifierPolicyTests: XCTestCase { } +@MainActor final class RightSidebarModeShortcutHintTests: XCTestCase { private let touchedShortcutActions: [KeyboardShortcutSettings.Action] = [ .focusRightSidebar, From dfe9628ae73bfdab015bb9d228a5e9bca32e8753 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:36:58 -0700 Subject: [PATCH 06/49] Polish Feed menu gating and alignment --- Sources/Feed/FeedPanelView.swift | 4 +++- cmuxTests/NewWorkspaceMenuModelTests.swift | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 69de389e9e0c..bf924946efe7 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -118,7 +118,9 @@ struct FeedPanelView: View { #endif } .rightSidebarChromeBar( - leadingPadding: placement == .pane ? 0 : RightSidebarChromeMetrics.barHorizontalPadding + leadingPadding: placement == .pane + ? HeaderChromeControlMetrics.titlebarControlsLeadingPadding + : RightSidebarChromeMetrics.barHorizontalPadding ) .rightSidebarChromeBottomBorder() .reportRightSidebarChromeGeometryForBonsplitUITest(role: .secondaryBar, isVisible: true, titlebarHeight: RightSidebarChromeMetrics.secondaryBarHeight) diff --git a/cmuxTests/NewWorkspaceMenuModelTests.swift b/cmuxTests/NewWorkspaceMenuModelTests.swift index 975ad4c0e842..deb7654a3251 100644 --- a/cmuxTests/NewWorkspaceMenuModelTests.swift +++ b/cmuxTests/NewWorkspaceMenuModelTests.swift @@ -31,6 +31,25 @@ struct NewWorkspaceMenuModelTests { } } + @Test func remoteDisabledFeedIsAbsentFromNewWorkspaceMenu() { + let model = NewWorkspaceMenuModel.build( + newWorkspaceContextMenuItems: [], + agentChatAction: nil, + feedSectionEnabled: false, + cloudSectionEnabled: false, + templateNames: [], + loadedActions: [], + newWorkspaceActionID: nil, + deletable: { _ in false }, + sectionOrder: .customFirst + ) + + #expect(!model.sections.contains { section in + if case .feed = section { return true } + return false + }) + } + @MainActor private func loadStore(globalJSON: String) throws -> (store: CmuxConfigStore, root: URL) { From 047d23761c7ff687fdd8a9091072f947e999ab19 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:38:34 -0700 Subject: [PATCH 07/49] Harden Feed concurrency and rollout gates --- .../Workstream/WorkstreamPersistence.swift | 5 +- Resources/Localizable.xcstrings | 97 ++- ...ppDelegate+NotificationDeliverySeams.swift | 10 +- Sources/AppDelegate.swift | 68 ++- Sources/FeatureFlags.swift | 554 +++++++++--------- Sources/Feed/FeedActivitySnapshotGroups.swift | 7 + Sources/Feed/FeedBlockingCallBridge.swift | 26 + Sources/Feed/FeedBlockingWaiterRegistry.swift | 85 +-- Sources/Feed/FeedCoordinator+Attention.swift | 70 ++- .../Feed/FeedCoordinator+Notifications.swift | 67 +-- Sources/Feed/FeedCoordinator+Socket.swift | 72 +-- Sources/Feed/FeedCoordinator.swift | 432 +++++++------- Sources/Feed/FeedCoordinatorState.swift | 14 - Sources/Feed/FeedExitPlanView.swift | 2 +- Sources/Feed/FeedItemRow.swift | 32 +- Sources/Feed/FeedItemSnapshot.swift | 59 +- Sources/Feed/FeedJumpResolver.swift | 71 +-- Sources/Feed/FeedKeyboardFocus.swift | 3 +- Sources/Feed/FeedListView.swift | 112 +--- .../Feed/FeedNotificationPolicyContext.swift | 143 +++-- .../Feed/FeedNotificationPolicySnapshot.swift | 7 + Sources/Feed/FeedPanelView.swift | 2 +- Sources/Feed/FeedPanelViewModel.swift | 49 +- Sources/Feed/FeedPermissionView.swift | 1 + Sources/Feed/FeedPresentationSnapshot.swift | 10 + Sources/Feed/FeedQuestionView.swift | 44 +- Sources/Feed/FeedSocketEncoding.swift | 45 +- Sources/Feed/FeedTelemetryView.swift | 70 ++- Sources/RightSidebarToolPanel.swift | 12 +- ...erminalController+ControlFeedContext.swift | 8 +- Sources/TerminalController.swift | 45 +- Sources/TerminalNotificationPolicy.swift | 14 +- Sources/Workspace.swift | 5 +- cmux.xcodeproj/project.pbxproj | 16 + cmuxTests/FeedCoordinatorTests.swift | 202 +++---- 35 files changed, 1342 insertions(+), 1117 deletions(-) create mode 100644 Sources/Feed/FeedActivitySnapshotGroups.swift create mode 100644 Sources/Feed/FeedBlockingCallBridge.swift create mode 100644 Sources/Feed/FeedNotificationPolicySnapshot.swift create mode 100644 Sources/Feed/FeedPresentationSnapshot.swift diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift index b0543c589038..74e509496500 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift @@ -199,7 +199,10 @@ private extension WorkstreamItem { } } -private extension WorkstreamPayload { +/// Redaction used by persistence and read-only diagnostic projections. +public extension WorkstreamPayload { + /// Returns a payload whose tool input and result fields are safe to persist + /// or expose through read-only diagnostic APIs. func redactedForPersistence() -> WorkstreamPayload { switch self { case .permissionRequest(let requestId, let toolName, let toolInputJSON, let pattern): diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 79622cebf374..efb2dc4926e4 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -103428,13 +103428,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "%@ plan ready" + "value": "Plan ready" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@のプラン準備完了" + "value": "プランの確認" } } } @@ -103547,13 +103547,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "%@ permission" + "value": "Permission needed" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@の権限確認" + "value": "権限の確認" } } } @@ -103598,13 +103598,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "%@ question" + "value": "Question" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@からの質問" + "value": "質問" } } } @@ -104448,6 +104448,55 @@ } } }, + "feed.time.days.one": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "1d" } }, + "ja": { "stringUnit": { "state": "translated", "value": "1日" } } + } + }, + "feed.time.days.other": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%lldd" } }, + "ja": { "stringUnit": { "state": "translated", "value": "%lld日" } } + } + }, + "feed.time.hours.one": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "1h" } }, + "ja": { "stringUnit": { "state": "translated", "value": "1時間" } } + } + }, + "feed.time.hours.other": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%lldh" } }, + "ja": { "stringUnit": { "state": "translated", "value": "%lld時間" } } + } + }, + "feed.time.minutes.one": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "1m" } }, + "ja": { "stringUnit": { "state": "translated", "value": "1分" } } + } + }, + "feed.time.minutes.other": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%lldm" } }, + "ja": { "stringUnit": { "state": "translated", "value": "%lld分" } } + } + }, + "feed.time.underMinute": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "<1m" } }, + "ja": { "stringUnit": { "state": "translated", "value": "1分未満" } } + } + }, "feed.todos.collapse": { "extractionState": "manual", "localizations": { @@ -104465,7 +104514,14 @@ } } }, - "feed.todos.moreCompleted": { + "feed.todos.moreCompleted.one": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "... +1 completed" } }, + "ja": { "stringUnit": { "state": "translated", "value": "... 完了が1件" } } + } + }, + "feed.todos.moreCompleted.other": { "extractionState": "manual", "localizations": { "en": { @@ -104482,7 +104538,14 @@ } } }, - "feed.todos.summary.done": { + "feed.todos.summary.done.one": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "1 done" } }, + "ja": { "stringUnit": { "state": "translated", "value": "完了1件" } } + } + }, + "feed.todos.summary.done.other": { "extractionState": "manual", "localizations": { "en": { @@ -104499,7 +104562,14 @@ } } }, - "feed.todos.summary.inProgress": { + "feed.todos.summary.inProgress.one": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "1 in progress" } }, + "ja": { "stringUnit": { "state": "translated", "value": "進行中1件" } } + } + }, + "feed.todos.summary.inProgress.other": { "extractionState": "manual", "localizations": { "en": { @@ -104516,7 +104586,14 @@ } } }, - "feed.todos.summary.open": { + "feed.todos.summary.open.one": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "1 open" } }, + "ja": { "stringUnit": { "state": "translated", "value": "未完了1件" } } + } + }, + "feed.todos.summary.open.other": { "extractionState": "manual", "localizations": { "en": { diff --git a/Sources/AppDelegate+NotificationDeliverySeams.swift b/Sources/AppDelegate+NotificationDeliverySeams.swift index 610319eaf161..699e6a6fc13c 100644 --- a/Sources/AppDelegate+NotificationDeliverySeams.swift +++ b/Sources/AppDelegate+NotificationDeliverySeams.swift @@ -30,10 +30,12 @@ final class NotificationDeliverySeamAdapter: NotificationFeedReplying, Notificat extension AppDelegate { func notificationDeliveryDeliverFeedReply(requestId: String, decision: NotificationFeedDecision) { - FeedCoordinator.shared.deliverReply( - requestId: requestId, - decision: Self.workstreamDecision(from: decision) - ) + Task { + await FeedCoordinator.shared.deliverReply( + requestId: requestId, + decision: Self.workstreamDecision(from: decision) + ) + } } func notificationDeliveryPermissionCapabilities(requestId: String) -> NotificationFeedPermissionCapabilities? { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 88d3069a77e8..46e2315f7040 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1328,18 +1328,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent name: .reactGrabDidCopySelection, object: nil ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleFeedRequestFocus(_:)), - name: .feedRequestFocus, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleFeedRequestSendText(_:)), - name: .feedRequestSendText, - object: nil - ) #if DEBUG // UI tests run on a shared VM user profile, so persisted shortcuts can drift and make @@ -1415,6 +1403,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent StartupBreadcrumbLog.append("appDelegate.didFinish.posthog.complete") } if !isRunningUnderXCTest { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleFeedFeatureFlagChange(_:)), + name: .cmuxFeatureFlagsDidChange, + object: CmuxFeatureFlags.shared + ) CmuxFeatureFlags.shared.start() } @@ -9329,11 +9323,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent pasteboard.setString(payload, forType: .string) } - @objc private func handleFeedRequestFocus(_ notification: Notification) { - guard let workspaceId = notification.userInfo?["workspaceId"] as? String, - let surfaceId = notification.userInfo?["surfaceId"] as? String - else { return } - + @discardableResult + func routeFeedFocus(workspaceId: String, surfaceId: String) -> Bool { // Invoke the existing V2 commands so the Feed-layer focus request // goes through the same code path as a socket-initiated focus. // Serialize through JSON so we reuse the v2 command parser. @@ -9355,13 +9346,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // cmd+shift+H / Flash Focused Panel) so the user's eye is // pulled to the terminal content the Feed jumped to. invoke("surface.trigger_flash", ["surface_id": surfaceId]) + return true } - @objc private func handleFeedRequestSendText(_ notification: Notification) { - guard let surfaceId = notification.userInfo?["surfaceId"] as? String, - let text = notification.userInfo?["text"] as? String, - !text.isEmpty - else { return } + @discardableResult + func routeFeedText(surfaceId: String, text: String) -> Bool { + guard !text.isEmpty else { return false } let controller = TerminalController.shared let invoke: (String, [String: Any]) -> Void = { method, params in @@ -9381,6 +9371,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "surface_id": surfaceId, "text": text + "\r", ]) + return true + } + + @objc private func handleFeedFeatureFlagChange(_ notification: Notification) { + _ = notification + guard !CmuxFeatureFlags.shared.isFeedUIEnabled else { return } + reconcileDisabledFeedSurfaces() + } + + private func reconcileDisabledFeedSurfaces() { + var visitedManagers: Set = [] + for context in mainWindowContexts.values { + let manager = context.tabManager + guard visitedManagers.insert(ObjectIdentifier(manager)).inserted else { continue } + let feedOnlyWorkspaces = manager.tabs.filter { workspace in + !workspace.panels.isEmpty && workspace.panels.values.allSatisfy { panel in + (panel as? RightSidebarToolPanel)?.mode == .feed + } + } + if manager.tabs.count == feedOnlyWorkspaces.count, !feedOnlyWorkspaces.isEmpty { + _ = manager.addWorkspace() + } + for workspace in feedOnlyWorkspaces { + manager.closeWorkspace(workspace, recordHistory: false) + } + for workspace in manager.tabs { + let feedPanelIDs = workspace.panels.compactMap { panelID, panel in + (panel as? RightSidebarToolPanel)?.mode == .feed ? panelID : nil + } + for panelID in feedPanelIDs { + _ = workspace.closePanel(panelID, force: true) + } + } + } } @objc private func handleReactGrabDidCopySelection(_ notification: Notification) { diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index a91740e202b7..63170caa9713 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -3,12 +3,12 @@ import Observation import PostHog struct CmuxFeatureFlagDefinition: Identifiable, Equatable { - var id: String { key } + var id: String { key } - let key: String - let title: String - let flagDescription: String - let defaultWhenUnavailable: Bool + let key: String + let title: String + let flagDescription: String + let defaultWhenUnavailable: Bool } /// PostHog-backed runtime feature flags for the macOS app (PostHog project @@ -29,306 +29,314 @@ struct CmuxFeatureFlagDefinition: Identifiable, Equatable { @MainActor @Observable final class CmuxFeatureFlags { - static let shared = CmuxFeatureFlags() + static let shared = CmuxFeatureFlags() - #if DEBUG + #if DEBUG private static let proUpgradeUIDefault = true - #else + #else private static let proUpgradeUIDefault = false - #endif + #endif - private static let mobileConnectButtonDefault = true + private static let mobileConnectButtonDefault = true - #if DEBUG + #if DEBUG private static let cloudVMUIDefault = true - #else + #else private static let cloudVMUIDefault = false - #endif - private static let agentChatUIDefault = false - private static let sidebarWorkspaceAgentSpinnerDefault = false - private static let feedUIDefault = true - - private static let overrideKeyPrefix = "cmux.flags.override." - - // Order is load-bearing for the typed accessors below. A keyed lookup would - // repeat flag-key literals and violate the feature-flag lint's single - // evaluation-site rule. - static let allFlags: [CmuxFeatureFlagDefinition] = { - [ - // FLAG(key: pro-upgrade-ui-enabled-release, owner: lawrencecchen, - // reviewBy: 2026-10-01, defaultWhenUnavailable: false) - // Shows the Pro upgrade entrypoints (sidebar badge, Settings Account - // card, palette command, Help menu item). Release builds hide them until - // the PostHog flag is enabled; DEBUG keeps them visible for dogfood. - CmuxFeatureFlagDefinition( - key: "pro-upgrade-ui-enabled-release", - title: String(localized: "featureFlags.proUpgrade.title", defaultValue: "Pro upgrade UI"), - flagDescription: String( - localized: "featureFlags.proUpgrade.description", - defaultValue: "Shows Pro upgrade entrypoints in the sidebar, Settings, command palette, and Help menu." - ), - defaultWhenUnavailable: CmuxFeatureFlags.proUpgradeUIDefault - ), - - // FLAG(key: mobile-connect-button-enabled-release, owner: lawrencecchen, - // reviewBy: 2026-10-01, defaultWhenUnavailable: true) - // Shows the top-right iPhone button that opens the Mobile Connect - // (phone pairing) window. Default keeps it visible when flags are - // unavailable; the window it opens ships in every build. - CmuxFeatureFlagDefinition( - key: "mobile-connect-button-enabled-release", - title: String(localized: "featureFlags.mobileConnect.title", defaultValue: "Mobile Connect button"), - flagDescription: String( - localized: "featureFlags.mobileConnect.description", - defaultValue: "Shows the iPhone button that opens the Mobile Connect pairing window." - ), - defaultWhenUnavailable: CmuxFeatureFlags.mobileConnectButtonDefault - ), - - // FLAG(key: cloud-vm-ui-enabled-release, owner: lawrencecchen, - // reviewBy: 2026-10-01, defaultWhenUnavailable: false) - // Shows the Cloud VM entrypoints: the new-workspace dropdown section - // (Open/Fork/Checkpoint/Restore/Advanced), the caret's direct Cloud - // VM menu, and the command-palette Cloud VM commands. Release builds - // hide them until the PostHog flag is enabled; DEBUG keeps them - // visible for dogfood. - CmuxFeatureFlagDefinition( - key: "cloud-vm-ui-enabled-release", - title: String(localized: "featureFlags.cloudVM.title", defaultValue: "Cloud VM UI"), - flagDescription: String( - localized: "featureFlags.cloudVM.description", - defaultValue: "Shows Cloud VM entrypoints in the new-workspace dropdown and command palette." - ), - defaultWhenUnavailable: CmuxFeatureFlags.cloudVMUIDefault - ), - - // FLAG(key: agent-chat-ui-enabled-release, owner: lawrencecchen, - // reviewBy: 2026-10-01, defaultWhenUnavailable: false) - // Shows the Agent Chat entrypoints: the new-workspace dropdown item, - // command-palette command, surface-tab-bar button, and shared action - // executor. Hidden by default until the sidecar UX is ready to ship. - CmuxFeatureFlagDefinition( - key: "agent-chat-ui-enabled-release", - title: String(localized: "featureFlags.agentChat.title", defaultValue: "Agent Chat UI"), - flagDescription: String( - localized: "featureFlags.agentChat.description", - defaultValue: "Shows Agent Chat entrypoints in the new-workspace dropdown, command palette, and surface tab bar." - ), - defaultWhenUnavailable: CmuxFeatureFlags.agentChatUIDefault - ), - - // FLAG(key: sidebar-workspace-agent-spinner-experiment, owner: lawrencecchen, - // reviewBy: 2026-10-01, defaultWhenUnavailable: false) - // Shows the coding-agent activity spinner in workspace rows. Hidden - // by default while multi-agent lifecycle edge cases are investigated. - CmuxFeatureFlagDefinition( - key: "sidebar-workspace-agent-spinner-experiment", - title: String( - localized: "featureFlags.sidebarWorkspaceAgentSpinner.title", - defaultValue: "Workspace agent spinner" - ), - flagDescription: String( - localized: "featureFlags.sidebarWorkspaceAgentSpinner.description", - defaultValue: "Shows a spinner in workspace rows while coding agents are running." - ), - defaultWhenUnavailable: CmuxFeatureFlags.sidebarWorkspaceAgentSpinnerDefault - ), - - // FLAG(key: feed-ui-enabled-release, owner: lawrencecchen, - // reviewBy: 2026-10-01, defaultWhenUnavailable: true) - // Shows Feed in the right sidebar, new-workspace menu, and command - // palette. The default ships Feed to everyone while retaining a - // remote kill switch for operational regressions. - CmuxFeatureFlagDefinition( - key: "feed-ui-enabled-release", - title: String(localized: "featureFlags.feed.title", defaultValue: "Feed UI"), - flagDescription: String( - localized: "featureFlags.feed.description", - defaultValue: "Shows Feed in the right sidebar, new-workspace menu, and command palette." - ), - defaultWhenUnavailable: CmuxFeatureFlags.feedUIDefault - ), - ] - }() - - var isProUpgradeUIEnabled: Bool { - effectiveValue(for: Self.allFlags[0]) + #endif + private static let agentChatUIDefault = false + private static let sidebarWorkspaceAgentSpinnerDefault = false + private static let feedUIDefault = true + + private static let overrideKeyPrefix = "cmux.flags.override." + + // FLAG(key: pro-upgrade-ui-enabled-release, owner: lawrencecchen, + // reviewBy: 2026-10-01, defaultWhenUnavailable: false) + // Shows the Pro upgrade entrypoints (sidebar badge, Settings Account + // card, palette command, Help menu item). Release builds hide them until + // the PostHog flag is enabled; DEBUG keeps them visible for dogfood. + private static let proUpgradeDefinition = CmuxFeatureFlagDefinition( + key: "pro-upgrade-ui-enabled-release", + title: String(localized: "featureFlags.proUpgrade.title", defaultValue: "Pro upgrade UI"), + flagDescription: String( + localized: "featureFlags.proUpgrade.description", + defaultValue: + "Shows Pro upgrade entrypoints in the sidebar, Settings, command palette, and Help menu." + ), + defaultWhenUnavailable: CmuxFeatureFlags.proUpgradeUIDefault + ) + + // FLAG(key: mobile-connect-button-enabled-release, owner: lawrencecchen, + // reviewBy: 2026-10-01, defaultWhenUnavailable: true) + // Shows the top-right iPhone button that opens the Mobile Connect + // (phone pairing) window. Default keeps it visible when flags are + // unavailable; the window it opens ships in every build. + private static let mobileConnectDefinition = CmuxFeatureFlagDefinition( + key: "mobile-connect-button-enabled-release", + title: String( + localized: "featureFlags.mobileConnect.title", defaultValue: "Mobile Connect button"), + flagDescription: String( + localized: "featureFlags.mobileConnect.description", + defaultValue: "Shows the iPhone button that opens the Mobile Connect pairing window." + ), + defaultWhenUnavailable: CmuxFeatureFlags.mobileConnectButtonDefault + ) + + // FLAG(key: cloud-vm-ui-enabled-release, owner: lawrencecchen, + // reviewBy: 2026-10-01, defaultWhenUnavailable: false) + // Shows the Cloud VM entrypoints: the new-workspace dropdown section + // (Open/Fork/Checkpoint/Restore/Advanced), the caret's direct Cloud + // VM menu, and the command-palette Cloud VM commands. Release builds + // hide them until the PostHog flag is enabled; DEBUG keeps them + // visible for dogfood. + private static let cloudVMDefinition = CmuxFeatureFlagDefinition( + key: "cloud-vm-ui-enabled-release", + title: String(localized: "featureFlags.cloudVM.title", defaultValue: "Cloud VM UI"), + flagDescription: String( + localized: "featureFlags.cloudVM.description", + defaultValue: "Shows Cloud VM entrypoints in the new-workspace dropdown and command palette." + ), + defaultWhenUnavailable: CmuxFeatureFlags.cloudVMUIDefault + ) + + // FLAG(key: agent-chat-ui-enabled-release, owner: lawrencecchen, + // reviewBy: 2026-10-01, defaultWhenUnavailable: false) + // Shows the Agent Chat entrypoints: the new-workspace dropdown item, + // command-palette command, surface-tab-bar button, and shared action + // executor. Hidden by default until the sidecar UX is ready to ship. + private static let agentChatDefinition = CmuxFeatureFlagDefinition( + key: "agent-chat-ui-enabled-release", + title: String(localized: "featureFlags.agentChat.title", defaultValue: "Agent Chat UI"), + flagDescription: String( + localized: "featureFlags.agentChat.description", + defaultValue: + "Shows Agent Chat entrypoints in the new-workspace dropdown, command palette, and surface tab bar." + ), + defaultWhenUnavailable: CmuxFeatureFlags.agentChatUIDefault + ) + + // FLAG(key: sidebar-workspace-agent-spinner-experiment, owner: lawrencecchen, + // reviewBy: 2026-10-01, defaultWhenUnavailable: false) + // Shows the coding-agent activity spinner in workspace rows. Hidden + // by default while multi-agent lifecycle edge cases are investigated. + private static let sidebarWorkspaceAgentSpinnerDefinition = CmuxFeatureFlagDefinition( + key: "sidebar-workspace-agent-spinner-experiment", + title: String( + localized: "featureFlags.sidebarWorkspaceAgentSpinner.title", + defaultValue: "Workspace agent spinner" + ), + flagDescription: String( + localized: "featureFlags.sidebarWorkspaceAgentSpinner.description", + defaultValue: "Shows a spinner in workspace rows while coding agents are running." + ), + defaultWhenUnavailable: CmuxFeatureFlags.sidebarWorkspaceAgentSpinnerDefault + ) + + // FLAG(key: feed-ui-enabled-release, owner: lawrencecchen, + // reviewBy: 2026-10-01, defaultWhenUnavailable: true) + // Shows Feed in the right sidebar, new-workspace menu, and command + // palette. The default ships Feed to everyone while retaining a + // remote kill switch for operational regressions. + private static let feedDefinition = CmuxFeatureFlagDefinition( + key: "feed-ui-enabled-release", + title: String(localized: "featureFlags.feed.title", defaultValue: "Feed UI"), + flagDescription: String( + localized: "featureFlags.feed.description", + defaultValue: "Shows Feed in the right sidebar, new-workspace menu, and command palette." + ), + defaultWhenUnavailable: CmuxFeatureFlags.feedUIDefault + ) + + static let allFlags: [CmuxFeatureFlagDefinition] = { + [ + proUpgradeDefinition, + mobileConnectDefinition, + cloudVMDefinition, + agentChatDefinition, + sidebarWorkspaceAgentSpinnerDefinition, + feedDefinition, + ] + }() + + var isProUpgradeUIEnabled: Bool { + effectiveValue(for: Self.proUpgradeDefinition) + } + + var isMobileConnectButtonEnabled: Bool { + effectiveValue(for: Self.mobileConnectDefinition) + } + + var isCloudVMUIEnabled: Bool { + effectiveValue(for: Self.cloudVMDefinition) + } + + var isAgentChatUIEnabled: Bool { + effectiveValue(for: Self.agentChatDefinition) + } + + var isSidebarWorkspaceAgentSpinnerEnabled: Bool { + effectiveValue(for: Self.sidebarWorkspaceAgentSpinnerDefinition) + } + + var isFeedUIEnabled: Bool { + effectiveValue(for: Self.feedDefinition) + } + + @ObservationIgnored + private let defaults: UserDefaults + @ObservationIgnored + private let remoteFlagValueProvider: (String) -> Any? + @ObservationIgnored + private var flagsObserver: (any NSObjectProtocol)? + + private var localOverridesByKey: [String: Bool] = [:] + private var remoteValuesByKey: [String: Bool] = [:] + private var effectiveValuesByKey: [String: Bool] = [:] + + init( + defaults: UserDefaults = .standard, + remoteFlagValueProvider: @escaping (String) -> Any? = { PostHogSDK.shared.getFeatureFlag($0) } + ) { + self.defaults = defaults + self.remoteFlagValueProvider = remoteFlagValueProvider + localOverridesByKey = Self.allFlags.reduce(into: [:]) { values, definition in + if let value = Self.storedOverrideValue(for: definition.key, defaults: defaults) { + values[definition.key] = value + } } - - var isMobileConnectButtonEnabled: Bool { - effectiveValue(for: Self.allFlags[1]) - } - - var isCloudVMUIEnabled: Bool { - effectiveValue(for: Self.allFlags[2]) - } - - var isAgentChatUIEnabled: Bool { - effectiveValue(for: Self.allFlags[3]) - } - - var isSidebarWorkspaceAgentSpinnerEnabled: Bool { - effectiveValue(for: Self.allFlags[4]) - } - - var isFeedUIEnabled: Bool { - effectiveValue(for: Self.allFlags[5]) + recomputeEffectiveValues() + } + + /// Called once from AppDelegate after PostHog analytics starts. Safe when + /// the SDK never sets up — flags then keep their defaults. + func start() { + guard flagsObserver == nil else { return } + flagsObserver = NotificationCenter.default.addObserver( + forName: PostHogSDK.didReceiveFeatureFlags, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.applyLoadedFlags() + } } - - @ObservationIgnored - private let defaults: UserDefaults - @ObservationIgnored - private let remoteFlagValueProvider: (String) -> Any? - @ObservationIgnored - private var flagsObserver: (any NSObjectProtocol)? - - private var localOverridesByKey: [String: Bool] = [:] - private var remoteValuesByKey: [String: Bool] = [:] - private var effectiveValuesByKey: [String: Bool] = [:] - - init( - defaults: UserDefaults = .standard, - remoteFlagValueProvider: @escaping (String) -> Any? = { PostHogSDK.shared.getFeatureFlag($0) } - ) { - self.defaults = defaults - self.remoteFlagValueProvider = remoteFlagValueProvider - localOverridesByKey = Self.allFlags.reduce(into: [:]) { values, definition in - if let value = Self.storedOverrideValue(for: definition.key, defaults: defaults) { - values[definition.key] = value - } - } - recomputeEffectiveValues() + PostHogSDK.shared.reloadFeatureFlags() + } + + func effectiveValue(for definition: CmuxFeatureFlagDefinition) -> Bool { + effectiveValuesByKey[definition.key] ?? definition.defaultWhenUnavailable + } + + func overrideValue(for definition: CmuxFeatureFlagDefinition) -> Bool? { + localOverridesByKey[definition.key] + } + + func remoteValue(for definition: CmuxFeatureFlagDefinition) -> Bool? { + remoteValuesByKey[definition.key] + } + + func setOverride(_ value: Bool?, for definition: CmuxFeatureFlagDefinition) { + let previousEffectiveValues = effectiveValuesByKey + if let value { + localOverridesByKey[definition.key] = value + defaults.set(value, forKey: Self.overrideDefaultsKey(for: definition.key)) + } else { + localOverridesByKey.removeValue(forKey: definition.key) + defaults.removeObject(forKey: Self.overrideDefaultsKey(for: definition.key)) } - - /// Called once from AppDelegate after PostHog analytics starts. Safe when - /// the SDK never sets up — flags then keep their defaults. - func start() { - guard flagsObserver == nil else { return } - flagsObserver = NotificationCenter.default.addObserver( - forName: PostHogSDK.didReceiveFeatureFlags, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor in - self?.applyLoadedFlags() - } - } - PostHogSDK.shared.reloadFeatureFlags() + recomputeEffectiveValues() + postChangeIfNeeded(previousEffectiveValues: previousEffectiveValues) + } + + func clearAllOverrides() { + let previousEffectiveValues = effectiveValuesByKey + var clearedAnyOverride = false + for definition in Self.allFlags { + if localOverridesByKey.removeValue(forKey: definition.key) != nil { + clearedAnyOverride = true + } + defaults.removeObject(forKey: Self.overrideDefaultsKey(for: definition.key)) } - - func effectiveValue(for definition: CmuxFeatureFlagDefinition) -> Bool { - effectiveValuesByKey[definition.key] ?? definition.defaultWhenUnavailable + guard clearedAnyOverride else { return } + recomputeEffectiveValues() + postChangeIfNeeded(previousEffectiveValues: previousEffectiveValues) + } + + func applyLoadedFlags() { + let previousEffectiveValues = effectiveValuesByKey + remoteValuesByKey = Self.allFlags.reduce(into: [:]) { values, definition in + if let value = Self.coerceBoolFlagValue(remoteFlagValueProvider(definition.key)) { + values[definition.key] = value + } } + recomputeEffectiveValues() + postChangeIfNeeded(previousEffectiveValues: previousEffectiveValues) + } - func overrideValue(for definition: CmuxFeatureFlagDefinition) -> Bool? { + private func recomputeEffectiveValues() { + effectiveValuesByKey = Self.allFlags.reduce(into: [:]) { values, definition in + values[definition.key] = localOverridesByKey[definition.key] + ?? remoteValuesByKey[definition.key] + ?? definition.defaultWhenUnavailable } + } - func remoteValue(for definition: CmuxFeatureFlagDefinition) -> Bool? { - remoteValuesByKey[definition.key] + private func postChangeIfNeeded(previousEffectiveValues: [String: Bool]) { + if Self.allFlags.contains(where: { definition in + previousEffectiveValues[definition.key] != effectiveValuesByKey[definition.key] + }) { + NotificationCenter.default.post(name: .cmuxFeatureFlagsDidChange, object: self) } + } - func setOverride(_ value: Bool?, for definition: CmuxFeatureFlagDefinition) { - let previousEffectiveValues = effectiveValuesByKey - if let value { - localOverridesByKey[definition.key] = value - defaults.set(value, forKey: Self.overrideDefaultsKey(for: definition.key)) - } else { - localOverridesByKey.removeValue(forKey: definition.key) - defaults.removeObject(forKey: Self.overrideDefaultsKey(for: definition.key)) - } - recomputeEffectiveValues() - postChangeIfNeeded(previousEffectiveValues: previousEffectiveValues) - } + private static func overrideDefaultsKey(for key: String) -> String { + overrideKeyPrefix + key + } - func clearAllOverrides() { - let previousEffectiveValues = effectiveValuesByKey - var clearedAnyOverride = false - for definition in Self.allFlags { - if localOverridesByKey.removeValue(forKey: definition.key) != nil { - clearedAnyOverride = true - } - defaults.removeObject(forKey: Self.overrideDefaultsKey(for: definition.key)) - } - guard clearedAnyOverride else { return } - recomputeEffectiveValues() - postChangeIfNeeded(previousEffectiveValues: previousEffectiveValues) + private static func storedOverrideValue(for key: String, defaults: UserDefaults) -> Bool? { + guard let value = defaults.object(forKey: overrideDefaultsKey(for: key)) else { + return nil } - - func applyLoadedFlags() { - let previousEffectiveValues = effectiveValuesByKey - remoteValuesByKey = Self.allFlags.reduce(into: [:]) { values, definition in - if let value = Self.coerceBoolFlagValue(remoteFlagValueProvider(definition.key)) { - values[definition.key] = value - } - } - recomputeEffectiveValues() - postChangeIfNeeded(previousEffectiveValues: previousEffectiveValues) + if let boolValue = value as? Bool { + return boolValue } - - private func recomputeEffectiveValues() { - effectiveValuesByKey = Self.allFlags.reduce(into: [:]) { values, definition in - values[definition.key] = localOverridesByKey[definition.key] - ?? remoteValuesByKey[definition.key] - ?? definition.defaultWhenUnavailable - } + if let numberValue = value as? NSNumber { + return numberValue.boolValue } + return nil + } - private func postChangeIfNeeded(previousEffectiveValues: [String: Bool]) { - if Self.allFlags.contains(where: { definition in - previousEffectiveValues[definition.key] != effectiveValuesByKey[definition.key] - }) { - NotificationCenter.default.post(name: .cmuxFeatureFlagsDidChange, object: self) - } - } + nonisolated static func coerceBoolFlagValue(_ value: Any?, default fallback: Bool) -> Bool { + coerceBoolFlagValue(value) ?? fallback + } - private static func overrideDefaultsKey(for key: String) -> String { - overrideKeyPrefix + key - } + nonisolated static func coerceBoolFlagValue(_ value: Any?) -> Bool? { + guard let value else { return nil } - private static func storedOverrideValue(for key: String, defaults: UserDefaults) -> Bool? { - guard let value = defaults.object(forKey: overrideDefaultsKey(for: key)) else { - return nil - } - if let boolValue = value as? Bool { - return boolValue - } - if let numberValue = value as? NSNumber { - return numberValue.boolValue - } - return nil + if let boolValue = value as? Bool { + return boolValue } - nonisolated static func coerceBoolFlagValue(_ value: Any?, default fallback: Bool) -> Bool { - coerceBoolFlagValue(value) ?? fallback + if let numberValue = value as? NSNumber { + return numberValue.boolValue } - nonisolated static func coerceBoolFlagValue(_ value: Any?) -> Bool? { - guard let value else { return nil } - - if let boolValue = value as? Bool { - return boolValue - } - - if let numberValue = value as? NSNumber { - return numberValue.boolValue - } - - if let stringValue = value as? String { - switch stringValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "true": - return true - case "false": - return false - default: - return nil - } - } - + if let stringValue = value as? String { + switch stringValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "true": + return true + case "false": + return false + default: return nil + } } + + return nil + } } extension Notification.Name { - static let cmuxFeatureFlagsDidChange = Notification.Name("cmuxFeatureFlagsDidChange") + static let cmuxFeatureFlagsDidChange = Notification.Name("cmuxFeatureFlagsDidChange") } diff --git a/Sources/Feed/FeedActivitySnapshotGroups.swift b/Sources/Feed/FeedActivitySnapshotGroups.swift new file mode 100644 index 000000000000..2ba80bbe9afe --- /dev/null +++ b/Sources/Feed/FeedActivitySnapshotGroups.swift @@ -0,0 +1,7 @@ +/// Stable and historical Feed rows prepared outside SwiftUI rendering. +struct FeedActivitySnapshotGroups: Equatable { + let stable: [FeedItemSnapshot] + let history: [FeedItemSnapshot] + + var ordered: [FeedItemSnapshot] { stable + history } +} diff --git a/Sources/Feed/FeedBlockingCallBridge.swift b/Sources/Feed/FeedBlockingCallBridge.swift new file mode 100644 index 000000000000..b4786b092af7 --- /dev/null +++ b/Sources/Feed/FeedBlockingCallBridge.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Single-writer completion bridge for a synchronous control-socket worker. +/// +/// The worker waits on the dispatch group while an async task owns Feed state. +/// `DispatchGroup.leave()` publishes the result before the waiting worker reads +/// it, so no separate mutable-state lock is needed. +final class FeedBlockingCallBridge: @unchecked Sendable { + private let completion = DispatchGroup() + nonisolated(unsafe) private var value: Value? + + init() { + completion.enter() + } + + func finish(with value: Value) { + self.value = value + completion.leave() + } + + func wait() -> Value { + completion.wait() + precondition(value != nil, "Feed blocking call completed without a result") + return value! + } +} diff --git a/Sources/Feed/FeedBlockingWaiterRegistry.swift b/Sources/Feed/FeedBlockingWaiterRegistry.swift index 21cbdc6e4d7b..fdfa159762de 100644 --- a/Sources/Feed/FeedBlockingWaiterRegistry.swift +++ b/Sources/Feed/FeedBlockingWaiterRegistry.swift @@ -1,58 +1,73 @@ -import Foundation import CMUXAgentLaunch -import os +import Foundation -/// Owns the synchronous request/reply bridge required by the control socket. +/// Actor-owned request/reply state for blocking Feed socket calls. /// -/// The socket handler is synchronous and must return the eventual agent -/// decision on the same worker thread. The semaphore parks only that worker; -/// the unfair lock protects short compare-and-update operations and never -/// guards ongoing Feed domain state. -final class FeedBlockingWaiterRegistry: @unchecked Sendable { +/// Each request receives one buffered decision stream. Delivery is +/// first-writer-wins, and timeout removal finishes the stream so no suspended +/// consumer or continuation outlives its socket request. +actor FeedBlockingWaiterRegistry { struct PendingWaiter { - let semaphore: DispatchSemaphore + let continuation: AsyncStream.Continuation var decision: WorkstreamDecision? + var itemID: UUID? var attentionTarget: FeedCoordinator.AttentionTarget? } - // Safety: every waiter-table access is serialized by this lock and every - // critical section is bounded to an in-memory dictionary mutation. - private let lock = OSAllocatedUnfairLock(initialState: [String: PendingWaiter]()) + private var waiters: [String: PendingWaiter] = [:] - func register(requestID: String) -> DispatchSemaphore { - let semaphore = DispatchSemaphore(value: 0) - lock.withLock { waiters in - waiters[requestID] = PendingWaiter(semaphore: semaphore) - } - return semaphore + func register(requestID: String) -> AsyncStream? { + guard waiters[requestID] == nil else { return nil } + let (stream, continuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + waiters[requestID] = PendingWaiter( + continuation: continuation, + decision: nil, + itemID: nil, + attentionTarget: nil + ) + return stream } - func setAttentionTarget(_ target: FeedCoordinator.AttentionTarget, requestID: String) { - lock.withLock { waiters in - waiters[requestID]?.attentionTarget = target + /// Records the UI state created for a still-pending waiter. Returns false + /// when the request resolved or timed out while MainActor was ingesting it. + func recordIngest( + itemID: UUID?, + attentionTarget: FeedCoordinator.AttentionTarget?, + requestID: String + ) -> (registered: Bool, earlyDecision: WorkstreamDecision?) { + guard var waiter = waiters[requestID] else { + return (false, nil) } + waiter.itemID = itemID + waiter.attentionTarget = attentionTarget + waiters[requestID] = waiter + return (true, waiter.decision) } - func deliver(_ decision: WorkstreamDecision, requestID: String) -> FeedCoordinator.AttentionTarget? { - let delivery = lock.withLock { waiters -> (DispatchSemaphore, FeedCoordinator.AttentionTarget?)? in - guard var waiter = waiters[requestID] else { return nil } - waiter.decision = decision - waiters[requestID] = waiter - return (waiter.semaphore, waiter.attentionTarget) + func deliver( + _ decision: WorkstreamDecision, + requestID: String + ) -> (accepted: Bool, itemID: UUID?, attentionTarget: FeedCoordinator.AttentionTarget?) { + guard var waiter = waiters[requestID], waiter.decision == nil else { + return (false, nil, nil) } - delivery?.0.signal() - return delivery?.1 + waiter.decision = decision + waiters[requestID] = waiter + waiter.continuation.yield(decision) + waiter.continuation.finish() + return (true, waiter.itemID, waiter.attentionTarget) } func remove(requestID: String) -> PendingWaiter? { - lock.withLock { waiters in - waiters.removeValue(forKey: requestID) - } + let waiter = waiters.removeValue(forKey: requestID) + waiter?.continuation.finish() + return waiter } func isAwaitingDecision(requestID: String) -> Bool { - lock.withLock { waiters in - waiters[requestID]?.decision == nil - } + guard let waiter = waiters[requestID] else { return false } + return waiter.decision == nil } } diff --git a/Sources/Feed/FeedCoordinator+Attention.swift b/Sources/Feed/FeedCoordinator+Attention.swift index b5bee3b0c77a..aad80c143030 100644 --- a/Sources/Feed/FeedCoordinator+Attention.swift +++ b/Sources/Feed/FeedCoordinator+Attention.swift @@ -41,7 +41,7 @@ extension FeedCoordinator { /// same agent/panel don't clear each other's needs-input badge. struct AttentionTarget: Hashable, Sendable { let workspaceId: UUID - let panelId: UUID? + let panelId: UUID let statusKey: String } @@ -81,13 +81,6 @@ extension FeedCoordinator { ) -> AttentionTarget? { guard Self.isBlockingDecisionEvent(event.hookEventName) else { return nil } - #if DEBUG - if let observer = FeedCoordinatorTestHooks.attentionSurfaceObserver { - observer(event) - return nil - } - #endif - guard let resolved else { #if DEBUG cmuxDebugLog( @@ -108,12 +101,17 @@ extension FeedCoordinator { return nil } - let panelId = Self.resolvePanelId(surfaceId: resolved.surfaceId, tab: tab) ?? tab.focusedPanelId - let statusKey = Self.lifecycleStatusKey(forSource: event.source) + guard let registered = Self.registeredAgentTarget( + source: event.source, + surfaceId: resolved.surfaceId, + tab: tab + ) else { + return nil + } let target = AttentionTarget( workspaceId: resolved.workspaceId, - panelId: panelId, - statusKey: statusKey + panelId: registered.panelID, + statusKey: registered.statusKey ) let attentionState = pendingAttentionStates[target] ?? AttentionOverlayState(workspace: tab) attentionState.workspace = tab @@ -121,9 +119,13 @@ extension FeedCoordinator { pendingAttentionStates[target] = attentionState // Needs-input lifecycle drives the sidebar badge + hibernation state. - tab.setAgentLifecycle(key: statusKey, panelId: panelId, lifecycle: .needsInput) - tab.statusEntries[statusKey] = SidebarStatusEntry( - key: statusKey, + tab.setAgentLifecycle( + key: registered.statusKey, + panelId: registered.panelID, + lifecycle: .needsInput + ) + tab.statusEntries[registered.statusKey] = SidebarStatusEntry( + key: registered.statusKey, value: Self.needsInputStatusValue, icon: "bell.fill", color: "#4C8DFF", @@ -161,9 +163,12 @@ extension FeedCoordinator { // Lifecycle is per-panel, so clearing this panel's needs-input is // safe even if another panel still needs input. - if let panelId = target.panelId, - tab.agentLifecycleStatesByPanelId[panelId]?[target.statusKey] == .needsInput { - tab.setAgentLifecycle(key: target.statusKey, panelId: panelId, lifecycle: .running) + if tab.agentLifecycleStatesByPanelId[target.panelId]?[target.statusKey] == .needsInput { + tab.setAgentLifecycle( + key: target.statusKey, + panelId: target.panelId, + lifecycle: .running + ) } // The status entry is workspace-level (keyed only by statusKey), so it @@ -187,12 +192,12 @@ extension FeedCoordinator { /// session store when the event omits a parseable id. The surface comes /// from the session store only when its workspace matches the resolved /// workspace, so a stale entry can't point the panel elsewhere. - static func resolveAttentionTarget( + func resolveAttentionTarget( event: WorkstreamEvent ) -> (workspaceId: UUID, surfaceId: UUID?)? { let sessionMatch: (workspaceId: UUID, surfaceId: UUID?)? = { - guard let parsed = FeedJumpResolver.parse(event.sessionId), - let resolved = FeedJumpResolver.lookup(agent: parsed.agent, sessionId: parsed.sessionId), + guard let parsed = jumpResolver.parse(event.sessionId), + let resolved = jumpResolver.lookup(agent: parsed.agent, sessionId: parsed.sessionId), let workspaceId = UUID(uuidString: resolved.workspaceId) else { return nil } return (workspaceId, UUID(uuidString: resolved.surfaceId)) @@ -219,4 +224,27 @@ extension FeedCoordinator { if tab.panels[surfaceId] != nil { return surfaceId } return tab.panelIdFromSurfaceId(TabID(uuid: surfaceId)) } + + /// Resolves lifecycle ownership only from agent runtime registrations. + /// A Feed event without a unique registered panel fails closed instead of + /// borrowing the workspace's currently focused panel. + @MainActor + static func registeredAgentTarget( + source: String, + surfaceId: UUID?, + tab: Workspace + ) -> (panelID: UUID, statusKey: String)? { + let statusKey = lifecycleStatusKey(forSource: source) + let registeredPanelIDs = Set(tab.agentPIDPanelIdsByKey.compactMap { key, panelID in + tab.agentStatusKey(forAgentPIDKey: key) == statusKey ? panelID : nil + }) + if let surfacePanelID = resolvePanelId(surfaceId: surfaceId, tab: tab), + registeredPanelIDs.contains(surfacePanelID) { + return (surfacePanelID, statusKey) + } + guard registeredPanelIDs.count == 1, let panelID = registeredPanelIDs.first else { + return nil + } + return (panelID, statusKey) + } } diff --git a/Sources/Feed/FeedCoordinator+Notifications.swift b/Sources/Feed/FeedCoordinator+Notifications.swift index ae7cd8a5a8ca..79843754c4f2 100644 --- a/Sources/Feed/FeedCoordinator+Notifications.swift +++ b/Sources/Feed/FeedCoordinator+Notifications.swift @@ -13,28 +13,17 @@ extension FeedCoordinator { /// queue, policy hooks, or notification center catches up. func postNotificationIfStillAwaiting(event: WorkstreamEvent, requestId: String) { Task { @MainActor [weak self] in - guard let self, self.isAwaitingDecision(requestId: requestId) else { + guard let self, await self.isAwaitingDecision(requestId: requestId) else { return } - #if DEBUG - let isAppActive = FeedCoordinatorTestHooks.isAppActiveOverride?() ?? NSApp.isActive - #else let isAppActive = NSApp.isActive - #endif // Don't pester users while the app is already up front. if isAppActive { return } - #if DEBUG - if let observer = FeedCoordinatorTestHooks.notificationPostObserver { - observer(event, requestId) - return - } - #endif - let categoryId: String let title: String let body: String @@ -43,7 +32,7 @@ extension FeedCoordinator { categoryId = Self.permissionNotificationCategoryId(for: event) title = String( localized: "feed.notification.permission.title", - defaultValue: "\(event.source.capitalized) permission" + defaultValue: "Permission needed" ) body = event.toolName.map { String( @@ -58,7 +47,7 @@ extension FeedCoordinator { categoryId = "CMUXFeedExitPlan" title = String( localized: "feed.notification.exitPlan.title", - defaultValue: "\(event.source.capitalized) plan ready" + defaultValue: "Plan ready" ) body = String( localized: "feed.notification.exitPlan.body", @@ -68,7 +57,7 @@ extension FeedCoordinator { categoryId = "CMUXFeedQuestion" title = String( localized: "feed.notification.question.title", - defaultValue: "\(event.source.capitalized) question" + defaultValue: "Question" ) body = String( localized: "feed.notification.question.body", @@ -78,13 +67,13 @@ extension FeedCoordinator { return } - let policyContext = FeedNotificationPolicyContext.make( + let policyContext = await FeedNotificationPolicyContext.make( event: event, title: title, body: body ) - let deliverDefault = { [weak self] in - self?.deliverFeedNotificationIfStillAwaiting( + let deliverDefault = { @MainActor [weak self] in + await self?.deliverFeedNotificationIfStillAwaiting( requestId: requestId, event: event, categoryId: categoryId, @@ -96,7 +85,7 @@ extension FeedCoordinator { } guard !policyContext.hooks.isEmpty else { - deliverDefault() + await deliverDefault() return } @@ -104,9 +93,9 @@ extension FeedCoordinator { policyContext.hooks, globalConfigPath: policyContext.globalConfigPath ) - guard self.isAwaitingDecision(requestId: requestId) else { return } + guard await self.isAwaitingDecision(requestId: requestId) else { return } guard !authorizedHooks.isEmpty else { - deliverDefault() + await deliverDefault() return } @@ -114,11 +103,11 @@ extension FeedCoordinator { envelope: policyContext.envelope, hooks: authorizedHooks ) - guard self.isAwaitingDecision(requestId: requestId) else { return } + guard await self.isAwaitingDecision(requestId: requestId) else { return } switch result { case .success(let envelope): let payload = envelope.notification - self.deliverFeedNotificationIfStillAwaiting( + await self.deliverFeedNotificationIfStillAwaiting( requestId: requestId, event: event, categoryId: categoryId, @@ -128,7 +117,7 @@ extension FeedCoordinator { effects: envelope.effects ) case .failure(let failure): - deliverDefault() + await deliverDefault() TerminalNotificationStore.shared.reportNotificationHookFailure(failure) } } @@ -164,13 +153,13 @@ extension FeedCoordinator { subtitle: String, body: String, effects: TerminalNotificationPolicyEffects - ) { - guard isAwaitingDecision(requestId: requestId), + ) async { + guard await isAwaitingDecision(requestId: requestId), effects.desktop || effects.sound || effects.command else { return } if !effects.desktop { - runFallbackEffectsIfStillAwaiting( + await runFallbackEffectsIfStillAwaiting( requestId: requestId, title: title, subtitle: subtitle, @@ -201,10 +190,10 @@ extension FeedCoordinator { let center = UNUserNotificationCenter.current() center.getNotificationSettings { settings in Task { @MainActor [weak self] in - guard let self, self.isAwaitingDecision(requestId: requestId) else { return } + guard let self, await self.isAwaitingDecision(requestId: requestId) else { return } switch settings.authorizationStatus { case .authorized, .provisional: - self.addNotificationIfStillAwaiting( + await self.addNotificationIfStillAwaiting( center: center, request: request, requestId: requestId, @@ -218,9 +207,9 @@ extension FeedCoordinator { } catch { requestFailed = true } - guard self.isAwaitingDecision(requestId: requestId) else { return } + guard await self.isAwaitingDecision(requestId: requestId) else { return } if granted { - self.addNotificationIfStillAwaiting( + await self.addNotificationIfStillAwaiting( center: center, request: request, requestId: requestId, @@ -231,7 +220,7 @@ extension FeedCoordinator { // the prompt just now: honor the fresh denial on this // very notification. A request error is not a user // decision, so the fallback stays audible (fail-open). - self.runFallbackEffectsIfStillAwaiting( + await self.runFallbackEffectsIfStillAwaiting( requestId: requestId, title: title, subtitle: subtitle, @@ -244,7 +233,7 @@ extension FeedCoordinator { ) } default: - self.runFallbackEffectsIfStillAwaiting( + await self.runFallbackEffectsIfStillAwaiting( requestId: requestId, title: title, subtitle: subtitle, @@ -268,8 +257,8 @@ extension FeedCoordinator { request: UNNotificationRequest, requestId: String, effects: TerminalNotificationPolicyEffects - ) { - guard isAwaitingDecision(requestId: requestId) else { return } + ) async { + guard await isAwaitingDecision(requestId: requestId) else { return } let title = request.content.title let subtitle = request.content.subtitle let body = request.content.body @@ -277,12 +266,12 @@ extension FeedCoordinator { let didFail = error != nil Task { @MainActor [weak self] in guard let self else { return } - if !self.isAwaitingDecision(requestId: requestId) { + if !(await self.isAwaitingDecision(requestId: requestId)) { self.cancelNotification(requestId: requestId) return } if didFail { - self.runFallbackEffectsIfStillAwaiting( + await self.runFallbackEffectsIfStillAwaiting( requestId: requestId, title: title, subtitle: subtitle, @@ -311,8 +300,8 @@ extension FeedCoordinator { body: String, effects: TerminalNotificationPolicyEffects, runCommand: Bool - ) { - guard isAwaitingDecision(requestId: requestId) else { return } + ) async { + guard await isAwaitingDecision(requestId: requestId) else { return } NativeNotificationDeliveryHooks.runLocalFeedback( title: title, subtitle: subtitle, diff --git a/Sources/Feed/FeedCoordinator+Socket.swift b/Sources/Feed/FeedCoordinator+Socket.swift index 672005b71b0d..10bcfbb2bf87 100644 --- a/Sources/Feed/FeedCoordinator+Socket.swift +++ b/Sources/Feed/FeedCoordinator+Socket.swift @@ -4,68 +4,50 @@ import Foundation // MARK: - Socket-layer helpers extension FeedCoordinator { - /// Thread-safe snapshot of the store's items; hops to main to read - /// the observable state (only if called off-main). + /// Main-actor snapshot used by app and typed control contexts. + @MainActor func snapshot(pendingOnly: Bool) -> [WorkstreamItem] { - let body: @Sendable () -> [WorkstreamItem] = { - MainActor.assumeIsolated { - guard let store = FeedCoordinator.shared.store else { return [] } - return pendingOnly ? store.pending : store.items - } - } - if Thread.isMainThread { - return body() - } - return DispatchQueue.main.sync(execute: body) + guard let store else { return [] } + return pendingOnly ? store.pending : store.items } - /// Parses `workstreamId` in the form `-` and - /// looks up the matching hook-session entry in - /// `~/.cmuxterm/-hook-sessions.json` (written by - /// `cmux -hook session-start`). Returns `true` if a match - /// was found so the UI can gate the jump gesture. + /// Synchronous socket-only availability probe. Socket handlers execute on + /// their worker queue, so this path never performs file I/O on MainActor. /// - /// Actual focus (workspace.select + surface.focus) is scheduled via - /// `FeedJumpResolver.focusIfPossible` on the main actor. func resolvePossibleSurface(for workstreamId: String) -> Bool { - guard let parsed = FeedJumpResolver.parse(workstreamId) else { - return false - } - return FeedJumpResolver.lookup(agent: parsed.agent, sessionId: parsed.sessionId) != nil + jumpResolver.resolve(workstreamId) != nil } /// Fires a best-effort focus for the given `workstreamId`. Returns /// `true` if a target was found and the focus commands were /// dispatched. Runs on the main actor because the focus commands /// touch AppKit state. - @MainActor - func focusIfPossible(workstreamId: String) -> Bool { - guard let parsed = FeedJumpResolver.parse(workstreamId), - let target = FeedJumpResolver.lookup( - agent: parsed.agent, sessionId: parsed.sessionId - ) - else { return false } - FeedJumpResolver.focus(workspaceId: target.workspaceId, surfaceId: target.surfaceId) - return true + func focusIfPossible(workstreamId: String) async -> Bool { + guard let target = await jumpResolver.resolveOffMain(workstreamId) else { + return false + } + return await MainActor.run { + AppDelegate.shared?.routeFeedFocus( + workspaceId: target.workspaceId, + surfaceId: target.surfaceId + ) ?? false + } } /// Resolves `workstreamId` to a `(workspace, surface)` pair and /// types the user's `text` into that surface, followed by Return. /// Used by Stop-kind cards so the user can reply to Claude from /// the Feed without switching focus to the terminal. - @MainActor @discardableResult - func sendTextToWorkstream(workstreamId: String, text: String) -> Bool { - guard let parsed = FeedJumpResolver.parse(workstreamId), - let target = FeedJumpResolver.lookup( - agent: parsed.agent, sessionId: parsed.sessionId - ) - else { return false } - FeedJumpResolver.sendText( - workspaceId: target.workspaceId, - surfaceId: target.surfaceId, - text: text - ) - return true + func sendTextToWorkstream(workstreamId: String, text: String) async -> Bool { + guard let target = await jumpResolver.resolveOffMain(workstreamId) else { + return false + } + return await MainActor.run { + AppDelegate.shared?.routeFeedText( + surfaceId: target.surfaceId, + text: text + ) ?? false + } } } diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 5ada7d456f8c..23d74d095839 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -1,10 +1,10 @@ import AppKit import Bonsplit import CMUXAgentLaunch -import Foundation -@preconcurrency import UserNotifications import CmuxSettings import CmuxSidebar +import Foundation +@preconcurrency import UserNotifications /// App-level coordinator that owns the shared `WorkstreamStore` and /// mediates between the socket thread (which processes `feed.*` V2 @@ -18,250 +18,248 @@ import CmuxSidebar // Safety: mutable UI state is MainActor-isolated, while the only worker-thread // state lives in `FeedBlockingWaiterRegistry` behind its documented lock. final class FeedCoordinator: @unchecked Sendable { - static let shared = FeedCoordinator() - static let storeInstalledNotification = Notification.Name("cmux.feed.storeInstalled") + static let shared = FeedCoordinator() + static let storeInstalledNotification = Notification.Name("cmux.feed.storeInstalled") - // The store runs on the main actor. The coordinator is not isolated, - // so it hops to main explicitly when touching the store. - @MainActor private(set) var store: WorkstreamStore! + // The store runs on the main actor. The coordinator is not isolated, + // so it hops to main explicitly when touching the store. + @MainActor private(set) var store: WorkstreamStore! - private let waiterRegistry = FeedBlockingWaiterRegistry() + private let waiterRegistry = FeedBlockingWaiterRegistry() + let jumpResolver: FeedJumpResolver + let socketEncoder = FeedSocketEncoder() + private let timeoutClock: ContinuousClock - /// One kqueue-backed DispatchSource per distinct agent PID we've - /// ever seen. The kernel fires `.exit` the instant the process - /// dies (or immediately if it's already dead). When that fires - /// we mark every pending item for that PID as `.expired` and - /// cancel the source. Keyed by PID so the same agent spawning - /// multiple prompts only installs one watcher. - @MainActor private var pidWatchers: [Int: DispatchSourceProcess] = [:] - private let pidWatcherQueue = DispatchQueue( - label: "cmux.feed.pidWatcher", qos: .utility - ) + /// One kqueue-backed DispatchSource per distinct agent PID we've + /// ever seen. The kernel fires `.exit` the instant the process + /// dies (or immediately if it's already dead). When that fires + /// we mark every pending item for that PID as `.expired` and + /// cancel the source. Keyed by PID so the same agent spawning + /// multiple prompts only installs one watcher. + @MainActor private var pidWatchers: [Int: DispatchSourceProcess] = [:] + private let pidWatcherQueue = DispatchQueue( + label: "cmux.feed.pidWatcher", qos: .utility + ) - /// In-flight blocking decisions whose needs-input overlay is currently lit, - /// keyed by ``AttentionTarget``. Each state keeps the workspace object that - /// was mutated when surfacing attention, so cleanup does not depend on - /// resolving a live window route after the decision has already ended. - /// Main-actor isolated: read/written only from the `@MainActor` attention - /// methods. - @MainActor var pendingAttentionStates: [AttentionTarget: AttentionOverlayState] = [:] + /// In-flight blocking decisions whose needs-input overlay is currently lit, + /// keyed by ``AttentionTarget``. Each state keeps the workspace object that + /// was mutated when surfacing attention, so cleanup does not depend on + /// resolving a live window route after the decision has already ended. + /// Main-actor isolated: read/written only from the `@MainActor` attention + /// methods. + @MainActor var pendingAttentionStates: [AttentionTarget: AttentionOverlayState] = [:] - private init() {} + private init( + jumpResolver: FeedJumpResolver = FeedJumpResolver(), + timeoutClock: ContinuousClock = ContinuousClock() + ) { + self.jumpResolver = jumpResolver + self.timeoutClock = timeoutClock + } - /// Must be called once at app launch to install the store. - @MainActor - func install(store: WorkstreamStore) { - self.store = store - NotificationCenter.default.post(name: Self.storeInstalledNotification, object: self) - // Catch any pending items that were restored from disk whose - // agent is already gone. After this, live tracking is - // kqueue-driven — no polling. - store.expireAbandonedItems() - for ppid in store.pending.compactMap(\.ppid) { - armPidWatcher(ppid: ppid) - } + /// Must be called once at app launch to install the store. + @MainActor + func install(store: WorkstreamStore) { + self.store = store + NotificationCenter.default.post(name: Self.storeInstalledNotification, object: self) + // Catch any pending items that were restored from disk whose + // agent is already gone. After this, live tracking is + // kqueue-driven — no polling. + store.expireAbandonedItems() + for ppid in store.pending.compactMap(\.ppid) { + armPidWatcher(ppid: ppid) } + } - /// Installs a one-shot kqueue watcher for `ppid`. The handler - /// fires the moment the kernel observes process exit (or - /// immediately if `ppid` is already dead), marks every pending - /// item for that PID as `.expired`, and cancels the source. - /// Idempotent: subsequent calls with the same PID no-op. - @MainActor - func armPidWatcher(ppid: Int) { - guard ppid > 0, pidWatchers[ppid] == nil else { return } - let src = DispatchSource.makeProcessSource( - identifier: pid_t(ppid), - eventMask: .exit, - queue: pidWatcherQueue - ) - src.setEventHandler { [weak self] in - Task { @MainActor in - guard let self else { return } - self.store?.expireItems(forPpid: ppid) - self.pidWatchers[ppid]?.cancel() - self.pidWatchers.removeValue(forKey: ppid) - } - } - pidWatchers[ppid] = src - src.resume() + /// Installs a one-shot kqueue watcher for `ppid`. The handler + /// fires the moment the kernel observes process exit (or + /// immediately if `ppid` is already dead), marks every pending + /// item for that PID as `.expired`, and cancels the source. + /// Idempotent: subsequent calls with the same PID no-op. + @MainActor + func armPidWatcher(ppid: Int) { + guard ppid > 0, pidWatchers[ppid] == nil else { return } + let src = DispatchSource.makeProcessSource( + identifier: pid_t(ppid), + eventMask: .exit, + queue: pidWatcherQueue + ) + src.setEventHandler { [weak self] in + Task { @MainActor in + guard let self else { return } + self.store?.expireItems(forPpid: ppid) + self.pidWatchers[ppid]?.cancel() + self.pidWatchers.removeValue(forKey: ppid) + } } + pidWatchers[ppid] = src + src.resume() + } - /// Ingests a wire-frame event and, when `waitTimeout` > 0, blocks the - /// current (non-main) thread until the item is resolved or the - /// timeout elapses. - func ingestBlocking( - event: WorkstreamEvent, - waitTimeout: TimeInterval - ) -> IngestBlockingResult { - guard let requestId = event.requestId, waitTimeout > 0 else { - Task { @MainActor in - FeedCoordinator.shared.store.ingest(event) - if let ppid = event.ppid, ppid > 0 { - FeedCoordinator.shared.armPidWatcher(ppid: ppid) - } - } - return .acknowledged(itemId: nil) + /// Ingests a wire-frame event and, when `waitTimeout` > 0, blocks the + /// current (non-main) thread until the item is resolved or the + /// timeout elapses. + func ingestBlocking( + event: WorkstreamEvent, + waitTimeout: TimeInterval + ) async -> IngestBlockingResult { + guard let requestId = event.requestId, waitTimeout > 0 else { + Task { @MainActor in + FeedCoordinator.shared.store.ingest(event) + if let ppid = event.ppid, ppid > 0 { + FeedCoordinator.shared.armPidWatcher(ppid: ppid) } + } + return .acknowledged(itemId: nil) + } - // Register the waiter before the store sees the event so a very - // fast reply can't slip through. - let semaphore = waiterRegistry.register(requestID: requestId) + // Register the waiter before the store sees the event so a very + // fast reply can't slip through. + guard let decisions = await waiterRegistry.register(requestID: requestId) else { + return .timedOut(itemId: nil) + } - // Hop to main to actually insert the item + install the - // kqueue watcher for the agent's PID. The watcher handler - // caps the pending lifetime to the agent process lifetime - // — no polling, no leaked cards when the agent is killed. - let resolvedAttentionTarget = Self.isBlockingDecisionEvent(event.hookEventName) - ? Self.resolveAttentionTarget(event: event) - : nil - let itemID: UUID? = DispatchQueue.main.sync { - MainActor.assumeIsolated { - FeedCoordinator.shared.store.ingest(event) - let itemID = FeedCoordinator.shared.store.items.last?.id - if let ppid = event.ppid, ppid > 0 { - FeedCoordinator.shared.armPidWatcher(ppid: ppid) - } - // Surface in-app attention (needs-input status + bell + - // workspace elevation) for the blocking decision. This fires - // regardless of app focus, unlike the desktop banner below, - // so the pending decision is visible in the sidebar even - // while the user is in another workspace of the same window. - // The target is resolved before entering this main-thread - // section so hook-session disk I/O never extends the UI - // critical section. - // The target is recorded on the waiter here — inside the - // ingest `main.sync`, before the card can render and a reply - // can fire — so the overlay is cleared exactly once when the - // decision concludes (no race with `deliverReply`). - if let target = FeedCoordinator.shared.surfaceBlockingDecisionAttention( - event: event, - resolved: resolvedAttentionTarget - ) { - FeedCoordinator.shared.waiterRegistry.setAttentionTarget( - target, - requestID: requestId - ) - } - #if DEBUG - FeedCoordinatorTestHooks.afterBlockingEventIngested?(event, requestId) - #endif - return itemID - } + // Hop to main to actually insert the item + install the + // kqueue watcher for the agent's PID. The watcher handler + // caps the pending lifetime to the agent process lifetime + // — no polling, no leaked cards when the agent is killed. + let resolvedAttentionTarget = + Self.isBlockingDecisionEvent(event.hookEventName) + ? resolveAttentionTarget(event: event) + : nil + let ingestResult: (itemID: UUID?, attentionTarget: AttentionTarget?) = await MainActor.run { + self.store.ingest(event) + let itemID = self.store.items.last?.id + if let ppid = event.ppid, ppid > 0 { + self.armPidWatcher(ppid: ppid) + } + let attentionTarget = self.surfaceBlockingDecisionAttention( + event: event, + resolved: resolvedAttentionTarget + ) + return (itemID, attentionTarget) + } + let registration = await waiterRegistry.recordIngest( + itemID: ingestResult.itemID, + attentionTarget: ingestResult.attentionTarget, + requestID: requestId + ) + guard registration.registered else { + await MainActor.run { + self.concludeBlockingDecisionAttentionIfPresent(ingestResult.attentionTarget) + if let itemID = ingestResult.itemID { + self.store?.markExpired(itemID) } - - // If this is a blocking actionable event and the app window isn't - // focused, post a native notification banner with inline action - // buttons so the user can respond without switching windows. - postNotificationIfStillAwaiting(event: event, requestId: requestId) - - let deadline: DispatchTime = .now() + waitTimeout - let waitResult = semaphore.wait(timeout: deadline) - - let waiter = waiterRegistry.remove(requestID: requestId) - - switch waitResult { - case .success: - if let decision = waiter?.decision { - // `deliverReply` concludes the attention overlay on resolve. - return .resolved(itemId: itemID, decision: decision) - } - cancelNotification(requestId: requestId) - concludeAttentionOnMain(waiter?.attentionTarget) - expireTimedOutItem(itemID) - return .timedOut(itemId: itemID) - case .timedOut: - cancelNotification(requestId: requestId) - concludeAttentionOnMain(waiter?.attentionTarget) - expireTimedOutItem(itemID) - return .timedOut(itemId: itemID) + } + return .timedOut(itemId: ingestResult.itemID) + } + if let earlyDecision = registration.earlyDecision { + _ = await waiterRegistry.remove(requestID: requestId) + await MainActor.run { + if let itemID = ingestResult.itemID { + self.store?.markResolved(itemID, decision: earlyDecision) } + self.concludeBlockingDecisionAttentionIfPresent(ingestResult.attentionTarget) + } + return .resolved(itemId: ingestResult.itemID, decision: earlyDecision) } - /// Concludes an attention overlay (if any) on the main actor, hopping if - /// called from the socket worker thread. - private func concludeAttentionOnMain(_ target: AttentionTarget?) { - guard let target else { return } - let conclude: @Sendable () -> Void = { [target] in - MainActor.assumeIsolated { - FeedCoordinator.shared.concludeBlockingDecisionAttention(target) - } - } - if Thread.isMainThread { - conclude() - } else { - Task { @MainActor in conclude() } + // If this blocking actionable event is still pending and the app is + // inactive, offer the same decision through a native banner. + postNotificationIfStillAwaiting(event: event, requestId: requestId) + + let deliveredDecision = await withTaskGroup(of: WorkstreamDecision?.self) { group in + group.addTask { + for await decision in decisions { + return decision } + return nil + } + group.addTask { + try? await self.timeoutClock.sleep(for: .seconds(waitTimeout)) + return nil + } + let first = await group.next() ?? nil + group.cancelAll() + return first } - /// Called by the `feed.*.reply` handlers. Marks the corresponding - /// item resolved on the main-actor store and wakes any waiter. - func deliverReply(requestId: String, decision: WorkstreamDecision) { - let attentionTarget = waiterRegistry.deliver(decision, requestID: requestId) + let waiter = await waiterRegistry.remove(requestID: requestId) - // The user decided: conclude the needs-input overlay so the agent's - // running/idle state shows through (refcounted so an overlapping - // decision on the same panel keeps it lit until it too concludes). - concludeAttentionOnMain(attentionTarget) + if let decision = deliveredDecision ?? waiter?.decision { + // A decision that wins at the timeout boundary remains terminal + // even when Dispatch reports the timeout result first. + return .resolved(itemId: waiter?.itemID, decision: decision) + } - let resolve: @Sendable () -> Void = { [requestId, decision] in - MainActor.assumeIsolated { - let store = FeedCoordinator.shared.store - guard let store else { return } - if let itemId = Self.findItemId(for: requestId, in: store.items) { - store.markResolved(itemId, decision: decision) - } - } - } - if Thread.isMainThread { - resolve() - } else { - Task { @MainActor in resolve() } - } + cancelNotification(requestId: requestId) + concludeAttentionOnMain(waiter?.attentionTarget) + expireTimedOutItem(waiter?.itemID) + return .timedOut(itemId: waiter?.itemID) + } - cancelNotification(requestId: requestId) + /// Concludes an attention overlay (if any) on the main actor, hopping if + /// called from the socket worker thread. + private func concludeAttentionOnMain(_ target: AttentionTarget?) { + guard let target else { return } + let conclude: @Sendable () -> Void = { [target] in + MainActor.assumeIsolated { + FeedCoordinator.shared.concludeBlockingDecisionAttention(target) + } } - - func isAwaitingDecision(requestId: String) -> Bool { - waiterRegistry.isAwaitingDecision(requestID: requestId) + if Thread.isMainThread { + conclude() + } else { + Task { @MainActor in conclude() } } + } - private static func findItemId( - for requestId: String, - in items: [WorkstreamItem] - ) -> UUID? { - for item in items.reversed() { - switch item.payload { - case .permissionRequest(let rid, _, _, _) where rid == requestId: - return item.id - case .exitPlan(let rid, _, _) where rid == requestId: - return item.id - case .question(let rid, _) where rid == requestId: - return item.id - default: - continue - } - } - return nil + @MainActor + private func concludeBlockingDecisionAttentionIfPresent(_ target: AttentionTarget?) { + guard let target else { return } + concludeBlockingDecisionAttention(target) + } + + /// Called by the `feed.*.reply` handlers. Marks the corresponding + /// item resolved on the main-actor store and wakes any waiter. + @discardableResult + func deliverReply(requestId: String, decision: WorkstreamDecision) async -> Bool { + let delivery = await waiterRegistry.deliver( + decision, + requestID: requestId + ) + guard delivery.accepted else { + return false } - private func expireTimedOutItem(_ itemId: UUID?) { - guard let itemId else { return } - let expire: @Sendable () -> Void = { [itemId] in - MainActor.assumeIsolated { - FeedCoordinator.shared.store?.markExpired(itemId) - } - } - if Thread.isMainThread { - expire() - } else { - DispatchQueue.main.sync(execute: expire) - } + // The user decided: conclude the needs-input overlay so the agent's + // running/idle state shows through (refcounted so an overlapping + // decision on the same panel keeps it lit until it too concludes). + concludeAttentionOnMain(delivery.attentionTarget) + + await MainActor.run { + guard let store, let itemID = delivery.itemID else { return } + store.markResolved(itemID, decision: decision) } - enum IngestBlockingResult { - case acknowledged(itemId: UUID?) - case resolved(itemId: UUID?, decision: WorkstreamDecision) - case timedOut(itemId: UUID?) + cancelNotification(requestId: requestId) + return true + } + + func isAwaitingDecision(requestId: String) async -> Bool { + await waiterRegistry.isAwaitingDecision(requestID: requestId) + } + + private func expireTimedOutItem(_ itemId: UUID?) { + guard let itemId else { return } + Task { @MainActor [weak self] in + self?.store?.markExpired(itemId) } + } + + enum IngestBlockingResult: Sendable { + case acknowledged(itemId: UUID?) + case resolved(itemId: UUID?, decision: WorkstreamDecision) + case timedOut(itemId: UUID?) + } } diff --git a/Sources/Feed/FeedCoordinatorState.swift b/Sources/Feed/FeedCoordinatorState.swift index 56cc92e1e5b3..50c8d6f143e5 100644 --- a/Sources/Feed/FeedCoordinatorState.swift +++ b/Sources/Feed/FeedCoordinatorState.swift @@ -11,17 +11,3 @@ final class AttentionOverlayState { self.workspace = workspace } } - -#if DEBUG -@MainActor -enum FeedCoordinatorTestHooks { - static var afterBlockingEventIngested: (@Sendable (WorkstreamEvent, String) -> Void)? - static var isAppActiveOverride: (@Sendable () -> Bool)? - static var notificationPostObserver: (@Sendable (WorkstreamEvent, String) -> Void)? - /// Fires when a blocking decision event requests in-app attention - /// surfacing (needs-input status + bell + elevation). When set, the - /// production surfacing is short-circuited so tests can assert the - /// request without a live `TabManager`. - static var attentionSurfaceObserver: (@Sendable (WorkstreamEvent) -> Void)? -} -#endif diff --git a/Sources/Feed/FeedExitPlanView.swift b/Sources/Feed/FeedExitPlanView.swift index 97ffeaae424c..70880ddfc1cc 100644 --- a/Sources/Feed/FeedExitPlanView.swift +++ b/Sources/Feed/FeedExitPlanView.swift @@ -366,7 +366,7 @@ private struct PlanBodyView: View { continue } if line.hasSuffix(":") && line.count <= 40 - && !line.contains(" ") == false && line.split(separator: " ").count <= 4 + && line.split(separator: " ").count <= 4 { flushParagraph(); flushNumbered(); flushBulleted() out.append(.heading(line)) diff --git a/Sources/Feed/FeedItemRow.swift b/Sources/Feed/FeedItemRow.swift index 95f4ddfd3a95..4b4e00eb8159 100644 --- a/Sources/Feed/FeedItemRow.swift +++ b/Sources/Feed/FeedItemRow.swift @@ -204,10 +204,25 @@ struct FeedItemRow: View, Equatable { private func relativeTimeChip(_ date: Date) -> String { let interval = Date().timeIntervalSince(date) - if interval < 60 { return "<1m" } - if interval < 3600 { return "\(Int(interval / 60))m" } - if interval < 86_400 { return "\(Int(interval / 3600))h" } - return "\(Int(interval / 86_400))d" + if interval < 60 { + return String(localized: "feed.time.underMinute", defaultValue: "<1m") + } + if interval < 3600 { + let count = Int(interval / 60) + return count == 1 + ? String(localized: "feed.time.minutes.one", defaultValue: "1m") + : String(localized: "feed.time.minutes.other", defaultValue: "\(count)m") + } + if interval < 86_400 { + let count = Int(interval / 3600) + return count == 1 + ? String(localized: "feed.time.hours.one", defaultValue: "1h") + : String(localized: "feed.time.hours.other", defaultValue: "\(count)h") + } + let count = Int(interval / 86_400) + return count == 1 + ? String(localized: "feed.time.days.one", defaultValue: "1d") + : String(localized: "feed.time.days.other", defaultValue: "\(count)d") } private var kindLabel: String { @@ -257,7 +272,8 @@ struct FeedItemRow: View, Equatable { status: snapshot.status, onActionRow: onControlAction, onApprove: { mode in - actions.approvePermission(snapshot.id, mode) + guard let requestID = snapshot.requestID else { return } + actions.approvePermission(requestID, mode) } ) case .exitPlan(_, let plan, _): @@ -270,7 +286,8 @@ struct FeedItemRow: View, Equatable { onActionRow: onControlAction, onBlurRow: onControlBlur, onApprove: { mode, feedback in - actions.approveExitPlan(snapshot.id, mode, feedback) + guard let requestID = snapshot.requestID else { return } + actions.approveExitPlan(requestID, mode, feedback) } ) case .question(_, let questions): @@ -286,7 +303,8 @@ struct FeedItemRow: View, Equatable { placement: placement, focusScopeID: focusScopeID, onReply: { selections in - actions.replyQuestion(snapshot.id, selections) + guard let requestID = snapshot.requestID else { return } + actions.replyQuestion(requestID, selections) } ) case .stop: diff --git a/Sources/Feed/FeedItemSnapshot.swift b/Sources/Feed/FeedItemSnapshot.swift index 0fc9a174df3a..5f31073c7cf5 100644 --- a/Sources/Feed/FeedItemSnapshot.swift +++ b/Sources/Feed/FeedItemSnapshot.swift @@ -11,6 +11,7 @@ struct FeedItemSnapshot: Equatable { let createdAt: Date let status: WorkstreamStatus let payload: WorkstreamPayload + let requestID: String? let context: WorkstreamContext? /// Most recent user-prompt text in the same workstream, attached /// by the list view so every card can show a "You: …" echo for @@ -27,6 +28,14 @@ struct FeedItemSnapshot: Equatable { self.createdAt = item.createdAt self.status = item.status self.payload = item.payload + switch item.payload { + case .permissionRequest(let requestID, _, _, _), + .exitPlan(let requestID, _, _), + .question(let requestID, _): + self.requestID = requestID + default: + self.requestID = nil + } self.context = item.context self.userPromptEcho = userPromptEcho } @@ -34,9 +43,9 @@ struct FeedItemSnapshot: Equatable { /// Closure bundle; binds to `FeedCoordinator` by default. struct FeedRowActions { - let approvePermission: (UUID, WorkstreamPermissionMode) -> Void - let replyQuestion: (UUID, [String]) -> Void - let approveExitPlan: (UUID, WorkstreamExitPlanMode, String?) -> Void + let approvePermission: (String, WorkstreamPermissionMode) -> Void + let replyQuestion: (String, [String]) -> Void + let approveExitPlan: (String, WorkstreamExitPlanMode, String?) -> Void let jump: (String) -> Void /// Types the user's reply into the agent's terminal surface and /// presses Return. Used by Stop-kind cards so the user can nudge @@ -45,38 +54,38 @@ struct FeedRowActions { static func bound() -> FeedRowActions { FeedRowActions( - approvePermission: { itemId, mode in - Task { @MainActor in - FeedCoordinator.shared.deliverReply( - requestId: Self.requestId(for: itemId) ?? itemId.uuidString, + approvePermission: { requestID, mode in + Task { + await FeedCoordinator.shared.deliverReply( + requestId: requestID, decision: .permission(mode) ) } }, - replyQuestion: { itemId, selections in - Task { @MainActor in - FeedCoordinator.shared.deliverReply( - requestId: Self.requestId(for: itemId) ?? itemId.uuidString, + replyQuestion: { requestID, selections in + Task { + await FeedCoordinator.shared.deliverReply( + requestId: requestID, decision: .question(selections: selections) ) } }, - approveExitPlan: { itemId, mode, feedback in - Task { @MainActor in - FeedCoordinator.shared.deliverReply( - requestId: Self.requestId(for: itemId) ?? itemId.uuidString, + approveExitPlan: { requestID, mode, feedback in + Task { + await FeedCoordinator.shared.deliverReply( + requestId: requestID, decision: .exitPlan(mode, feedback: feedback) ) } }, jump: { workstreamId in - Task { @MainActor in - _ = FeedCoordinator.shared.focusIfPossible(workstreamId: workstreamId) + Task { + _ = await FeedCoordinator.shared.focusIfPossible(workstreamId: workstreamId) } }, sendText: { workstreamId, text in - Task { @MainActor in - FeedCoordinator.shared.sendTextToWorkstream( + Task { + await FeedCoordinator.shared.sendTextToWorkstream( workstreamId: workstreamId, text: text ) @@ -85,18 +94,6 @@ struct FeedRowActions { ) } - @MainActor - private static func requestId(for itemId: UUID) -> String? { - guard let store = FeedCoordinator.shared.store else { return nil } - return store.items.first(where: { $0.id == itemId }).flatMap { item in - switch item.payload { - case .permissionRequest(let rid, _, _, _): return rid - case .exitPlan(let rid, _, _): return rid - case .question(let rid, _): return rid - default: return nil - } - } - } } // MARK: - Row (matches SessionIndexView row aesthetic) diff --git a/Sources/Feed/FeedJumpResolver.swift b/Sources/Feed/FeedJumpResolver.swift index a9e697e1a597..91f35c0a5d01 100644 --- a/Sources/Feed/FeedJumpResolver.swift +++ b/Sources/Feed/FeedJumpResolver.swift @@ -1,15 +1,23 @@ import Foundation -/// Reads the per-agent hook session stores (`~/.cmuxterm/-hook-sessions.json`) -/// to map a feed `workstream_id` back to a cmux `(workspaceId, surfaceId)` pair. -/// The schema is the same one written by `cmux -hook session-start`. -enum FeedJumpResolver { - struct Target: Equatable { +/// Resolves Feed workstream identifiers through the per-agent hook-session +/// stores written by `cmux -hook session-start`. +final class FeedJumpResolver: @unchecked Sendable { + struct Target: Equatable, Sendable { let workspaceId: String let surfaceId: String } - static func parse(_ workstreamId: String) -> (agent: String, sessionId: String)? { + private let sessionsDirectory: URL + + init( + sessionsDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cmuxterm", isDirectory: true) + ) { + self.sessionsDirectory = sessionsDirectory + } + + func parse(_ workstreamId: String) -> (agent: String, sessionId: String)? { guard let dash = workstreamId.firstIndex(of: "-") else { return nil } let agent = String(workstreamId[.. Target? { - let home = FileManager.default.homeDirectoryForCurrentUser - let file = home - .appendingPathComponent(".cmuxterm", isDirectory: true) + func lookup(agent: String, sessionId: String) -> Target? { + let file = sessionsDirectory .appendingPathComponent("\(agent)-hook-sessions.json", isDirectory: false) guard let data = try? Data(contentsOf: file), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] @@ -41,40 +47,19 @@ enum FeedJumpResolver { return Target(workspaceId: workspaceId, surfaceId: surfaceId) } - /// Dispatches a workspace-select + surface-focus intent. Posts - /// through the existing cmux notification pathway so we don't need - /// to bind directly to the TerminalController V2 handlers from the - /// Feed layer. - @MainActor - static func focus(workspaceId: String, surfaceId: String) { - NotificationCenter.default.post( - name: .feedRequestFocus, - object: nil, - userInfo: [ - "workspaceId": workspaceId, - "surfaceId": surfaceId, - ] - ) + func resolve(_ workstreamId: String) -> Target? { + guard let parsed = parse(workstreamId) else { return nil } + return lookup(agent: parsed.agent, sessionId: parsed.sessionId) } - /// Dispatches a surface.send_text intent for the agent's terminal. - /// The observer in AppDelegate translates it into the V2 socket - /// call so the Feed stays decoupled from TerminalController. - @MainActor - static func sendText(workspaceId: String, surfaceId: String, text: String) { - NotificationCenter.default.post( - name: .feedRequestSendText, - object: nil, - userInfo: [ - "workspaceId": workspaceId, - "surfaceId": surfaceId, - "text": text, - ] - ) + #if compiler(>=6.2) + @concurrent + #else + @Sendable + #endif + nonisolated func resolveOffMain(_ workstreamId: String) async -> Target? { + await Task.detached(priority: .userInitiated) { [self] in + resolve(workstreamId) + }.value } } - -extension Notification.Name { - static let feedRequestFocus = Notification.Name("cmux.feedRequestFocus") - static let feedRequestSendText = Notification.Name("cmux.feedRequestSendText") -} diff --git a/Sources/Feed/FeedKeyboardFocus.swift b/Sources/Feed/FeedKeyboardFocus.swift index 900ebe0b957b..aa266b272e86 100644 --- a/Sources/Feed/FeedKeyboardFocus.swift +++ b/Sources/Feed/FeedKeyboardFocus.swift @@ -240,9 +240,8 @@ final class FeedKeyboardFocusView: NSView { override func keyDown(with event: NSEvent) { #if DEBUG - let chars = event.charactersIgnoringModifiers ?? "" dlog( - "feed.focus.host keyDown key=\(event.keyCode) chars=\(chars) " + + "feed.focus.host keyDown key=\(event.keyCode) modifiers=\(event.modifierFlags.rawValue) " + "fr=\(feedDebugResponderSummary(window?.firstResponder))" ) #endif diff --git a/Sources/Feed/FeedListView.swift b/Sources/Feed/FeedListView.swift index 6ee8098134c1..aadb9f0aef62 100644 --- a/Sources/Feed/FeedListView.swift +++ b/Sources/Feed/FeedListView.swift @@ -6,7 +6,7 @@ import SwiftUI struct FeedListView: View { let filter: FeedPanelView.Filter - let items: [WorkstreamItem] + let presentation: FeedPresentationSnapshot let placement: FeedPlacement let focusScopeID: UUID let onFocusHostChange: (FeedKeyboardFocusView?) -> Void @@ -19,10 +19,13 @@ struct FeedListView: View { @State private var scrollRequestSequence = 0 @State private var stopDrafts: [UUID: FeedStopDraft] = [:] @State private var focusHostRequest = 0 + @State private var focusHostReference = FeedFocusHostReference() var body: some View { - let snapshots = visibleSnapshots(items) - let activityGroups = filter == .activity ? activitySnapshotGroups(snapshots) : nil + let snapshots = filter == .actionable + ? presentation.actionable + : presentation.activity.ordered + let activityGroups = filter == .activity ? presentation.activity : nil let focusSnapshots = activityGroups?.ordered ?? snapshots let rowActions = FeedRowActions.bound() ScrollViewReader { proxy in @@ -46,7 +49,10 @@ struct FeedListView: View { placement: placement, focusScopeID: focusScopeID, focusRequest: focusHostRequest, - onHostChange: onFocusHostChange, + onHostChange: { host in + focusHostReference.host = host + onFocusHostChange(host) + }, onEscape: { let window = activeFeedWindow() if placement.usesRightSidebarFocusCoordinator, @@ -92,7 +98,7 @@ struct FeedListView: View { @ViewBuilder private func contentBody( snapshots: [FeedItemSnapshot], - activityGroups: ActivitySnapshotGroups?, + activityGroups: FeedActivitySnapshotGroups?, actions: FeedRowActions ) -> some View { switch filter { @@ -103,7 +109,7 @@ struct FeedListView: View { ) case .activity: activityScrollSurface( - groups: activityGroups ?? activitySnapshotGroups(snapshots), + groups: activityGroups ?? FeedActivitySnapshotGroups(stable: [], history: []), actions: actions, showsLoadMore: hasMorePersistedItems ) @@ -131,7 +137,7 @@ struct FeedListView: View { } private func activityScrollSurface( - groups: ActivitySnapshotGroups, + groups: FeedActivitySnapshotGroups, actions: FeedRowActions, showsLoadMore: Bool ) -> some View { @@ -180,27 +186,6 @@ struct FeedListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - private struct ActivitySnapshotGroups { - let stable: [FeedItemSnapshot] - let history: [FeedItemSnapshot] - let ordered: [FeedItemSnapshot] - } - - private func activitySnapshotGroups(_ snapshots: [FeedItemSnapshot]) -> ActivitySnapshotGroups { - var stable: [FeedItemSnapshot] = [] - var history: [FeedItemSnapshot] = [] - stable.reserveCapacity(snapshots.count) - history.reserveCapacity(snapshots.count) - for snapshot in snapshots { - if prefersStableSurface(snapshot) { - stable.append(snapshot) - } else { - history.append(snapshot) - } - } - return ActivitySnapshotGroups(stable: stable, history: history, ordered: stable + history) - } - private func rowSurface( snapshot: FeedItemSnapshot, actions: FeedRowActions, @@ -252,60 +237,6 @@ struct FeedListView: View { ) } - /// Walks the full items list (not just the filtered visible set), - /// ordered by createdAt, and records the most recent user-prompt - /// text per workstreamId. Rows consult this dict to show a - /// "You: …" echo line at the top of their card. - private static func lastPromptByWorkstream(_ items: [WorkstreamItem]) -> [String: String] { - var out: [String: String] = [:] - for item in items { - if case .userPrompt(let text) = item.payload, !text.isEmpty { - out[item.workstreamId] = text - } - } - return out - } - - private func filtered(_ items: [WorkstreamItem]) -> [WorkstreamItem] { - let base: [WorkstreamItem] - switch filter { - case .actionable: - base = items.filter { $0.kind.isActionable } - case .activity: - // Actionable kinds + todos + stop. Tool use, user prompts, - // assistant messages, session markers, and raw - // notifications are intentionally excluded — they're too - // noisy for a sidebar and already visible in the agent's - // terminal or the cmux notification system. Stop events - // render a "reply to Claude" textbox so the user can - // nudge Claude without switching focus to the terminal. - base = items.filter { item in - item.kind.isActionable - || item.kind == .todos - || item.kind == .stop - } - } - // Newest first. Status isn't a sort key — resolved items stay - // in the chronological slot where they arrived so the user's - // mental map of "this was the second request I got" doesn't - // get shuffled when they answer it. - return Array(base.reversed()) - } - - private func visibleSnapshots(_ items: [WorkstreamItem]) -> [FeedItemSnapshot] { - let lastPromptByWorkstream = Self.lastPromptByWorkstream(items) - return filtered(items).map { item in - FeedItemSnapshot( - item: item, - userPromptEcho: lastPromptByWorkstream[item.workstreamId] - ) - } - } - - private func prefersStableSurface(_ snapshot: FeedItemSnapshot) -> Bool { - snapshot.status.isPending || snapshot.kind == .stop - } - private var shouldShowActivityHistoryLoader: Bool { filter == .activity && hasMorePersistedItems } @@ -321,9 +252,15 @@ struct FeedListView: View { ) #endif if focusFeed { - FeedInlineNativeTextView.blurActiveEditor() + FeedInlineNativeTextView.blurActiveEditor(in: window) } - let optimisticSnapshot = FeedFocusSnapshot(selectedItemId: id, isKeyboardActive: true) + let ownsKeyboardFocus = focusHostReference.host.flatMap { host in + host.window?.firstResponder.map(host.ownsKeyboardFocus(_:)) + } ?? false + let optimisticSnapshot = FeedFocusSnapshot( + selectedItemId: id, + isKeyboardActive: focusFeed || ownsKeyboardFocus + ) focusSnapshot = optimisticSnapshot if !placement.usesRightSidebarFocusCoordinator { if focusFeed { @@ -452,7 +389,7 @@ struct FeedListView: View { } private func activeFeedWindow() -> NSWindow? { - NSApp.keyWindow ?? NSApp.mainWindow + focusHostReference.host?.window } private func syncFeedFocusSnapshot(window: NSWindow? = nil) { @@ -494,6 +431,11 @@ struct FeedListView: View { } } +@MainActor +private final class FeedFocusHostReference { + weak var host: FeedKeyboardFocusView? +} + private struct FeedScrollRequest: Equatable { let id: UUID let sequence: Int diff --git a/Sources/Feed/FeedNotificationPolicyContext.swift b/Sources/Feed/FeedNotificationPolicyContext.swift index 498232711949..e9631f316bc8 100644 --- a/Sources/Feed/FeedNotificationPolicyContext.swift +++ b/Sources/Feed/FeedNotificationPolicyContext.swift @@ -2,69 +2,96 @@ import CMUXAgentLaunch import CmuxSettings import Foundation -struct FeedNotificationPolicyContext { - let envelope: TerminalNotificationPolicyEnvelope - let hooks: [CmuxResolvedNotificationHook] - let globalConfigPath: String? +struct FeedNotificationPolicyContext: Sendable { + let envelope: TerminalNotificationPolicyEnvelope + let hooks: [CmuxResolvedNotificationHook] + let globalConfigPath: String? } extension FeedNotificationPolicyContext { - @MainActor - static func make( - event: WorkstreamEvent, - title: String, - body: String - ) -> FeedNotificationPolicyContext { - let appDelegate = AppDelegate.shared - let workspaceID = event.workspaceId.flatMap(UUID.init(uuidString:)) - let context = workspaceID.flatMap { appDelegate?.contextContainingTabId($0) } - ?? appDelegate?.mainWindowContexts.values.first(where: { $0.cmuxConfigStore != nil }) - let workspace = workspaceID.flatMap { id in - context?.tabManager.tabs.first(where: { $0.id == id }) - } - let cwd = normalizedCWD(event.cwd) - ?? workspace?.surfaceTabBarDirectory - ?? workspace?.currentDirectory - ?? FileManager.default.homeDirectoryForCurrentUser.path - var effects = TerminalNotificationPolicyEffects() - effects.desktop = true - effects.record = false - effects.markUnread = false - effects.reorderWorkspace = false - effects.sound = false - effects.command = false - effects.paneFlash = false + static func make( + event: WorkstreamEvent, + title: String, + body: String + ) async -> FeedNotificationPolicyContext { + let snapshot: FeedNotificationPolicySnapshot = await MainActor.run { + Self.snapshot(event: event, title: title, body: body) + } + return FeedNotificationPolicyContext( + envelope: snapshot.envelope, + hooks: snapshot.hooks, + globalConfigPath: snapshot.globalConfigPath + ) + } - return FeedNotificationPolicyContext( - envelope: TerminalNotificationPolicyEnvelope( - notification: TerminalNotificationPolicyPayload( - workspaceId: event.workspaceId ?? event.sessionId, - surfaceId: nil, - title: title, - subtitle: "", - body: body - ), - context: TerminalNotificationPolicyContext( - cwd: cwd, - configPath: nil, - hookId: nil, - appFocused: AppFocusState.isAppFocused(), - focusedPanel: false - ), - effects: effects - ), - hooks: context?.cmuxConfigStore?.notificationHooks( - startingFrom: workspace?.isRemoteWorkspace == true - ? nil - : (normalizedCWD(event.cwd) ?? workspace?.surfaceTabBarDirectory) - ) ?? [], - globalConfigPath: context?.cmuxConfigStore?.globalConfigPath - ) + @MainActor + private static func snapshot( + event: WorkstreamEvent, + title: String, + body: String + ) -> FeedNotificationPolicySnapshot { + let appDelegate = AppDelegate.shared + let workspaceID = event.workspaceId.flatMap(UUID.init(uuidString:)) + let context = workspaceID.flatMap { appDelegate?.contextContainingTabId($0) } + let workspace = workspaceID.flatMap { id in + context?.tabManager.tabs.first(where: { $0.id == id }) } + let cwd = + normalizedCWD(event.cwd) + ?? workspace?.surfaceTabBarDirectory + ?? workspace?.currentDirectory + ?? FileManager.default.homeDirectoryForCurrentUser.path + var effects = TerminalNotificationPolicyEffects() + effects.desktop = true + effects.record = false + effects.markUnread = false + effects.reorderWorkspace = false + effects.sound = false + effects.command = false + effects.paneFlash = false - private static func normalizedCWD(_ cwd: String?) -> String? { - guard let cwd else { return nil } - let trimmed = cwd.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + let workspaceIdentity = workspaceID?.uuidString ?? "" + let configStore = workspace == nil ? nil : context?.cmuxConfigStore + let globalConfigPath = configStore?.globalConfigPath + let hooks: [CmuxResolvedNotificationHook] + if context?.tabManager.selectedTabId == workspaceID { + hooks = configStore?.notificationHooks ?? [] + } else if let globalConfigPath { + let normalizedGlobalPath = URL(fileURLWithPath: globalConfigPath).standardizedFileURL.path + hooks = + configStore?.notificationHooks.filter { + guard let sourcePath = $0.sourcePath else { return false } + return URL(fileURLWithPath: sourcePath).standardizedFileURL.path == normalizedGlobalPath + } ?? [] + } else { + hooks = [] } + return FeedNotificationPolicySnapshot( + envelope: TerminalNotificationPolicyEnvelope( + notification: TerminalNotificationPolicyPayload( + workspaceId: workspaceIdentity, + surfaceId: nil, + title: title, + subtitle: "", + body: body + ), + context: TerminalNotificationPolicyContext( + cwd: cwd, + configPath: nil, + hookId: nil, + appFocused: AppFocusState.isAppFocused(), + focusedPanel: false + ), + effects: effects + ), + hooks: hooks, + globalConfigPath: globalConfigPath + ) + } + + private static func normalizedCWD(_ cwd: String?) -> String? { + guard let cwd else { return nil } + let trimmed = cwd.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } } diff --git a/Sources/Feed/FeedNotificationPolicySnapshot.swift b/Sources/Feed/FeedNotificationPolicySnapshot.swift new file mode 100644 index 000000000000..de5f63878f48 --- /dev/null +++ b/Sources/Feed/FeedNotificationPolicySnapshot.swift @@ -0,0 +1,7 @@ +import Foundation + +struct FeedNotificationPolicySnapshot: Sendable { + let envelope: TerminalNotificationPolicyEnvelope + let hooks: [CmuxResolvedNotificationHook] + let globalConfigPath: String? +} diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index bf924946efe7..a6c4fa65c5f4 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -92,7 +92,7 @@ struct FeedPanelView: View { controlBar FeedListView( filter: filter, - items: viewModel.items, + presentation: viewModel.presentation, placement: placement, focusScopeID: focusScopeID, onFocusHostChange: onFocusHostChange, diff --git a/Sources/Feed/FeedPanelViewModel.swift b/Sources/Feed/FeedPanelViewModel.swift index 2f5c7519f556..9a69bda79bba 100644 --- a/Sources/Feed/FeedPanelViewModel.swift +++ b/Sources/Feed/FeedPanelViewModel.swift @@ -9,6 +9,7 @@ import SwiftUI @Observable final class FeedPanelViewModel { private(set) var items: [WorkstreamItem] = [] + private(set) var presentation = FeedPresentationSnapshot.empty private(set) var hasMorePersistedItems = false private(set) var isLoadingOlderItems = false @@ -21,14 +22,14 @@ final class FeedPanelViewModel { notificationCenter: NotificationCenter = .default ) { self.coordinator = coordinator - arm() storeInstallTask = Task { @MainActor [weak self, weak coordinator] in - guard let coordinator else { return } + guard let self, let coordinator else { return } + self.arm() for await _ in notificationCenter.notifications( named: FeedCoordinator.storeInstalledNotification, object: coordinator ) { - guard !Task.isCancelled, let self else { return } + guard !Task.isCancelled else { return } self.arm() } } @@ -43,6 +44,7 @@ final class FeedPanelViewModel { guard let store = coordinator.store else { return } withObservationTracking { items = store.items + presentation = Self.makePresentation(items: store.items) hasMorePersistedItems = store.hasMorePersistedItems isLoadingOlderItems = store.isLoadingOlderItems } onChange: { [weak self] in @@ -52,6 +54,47 @@ final class FeedPanelViewModel { } } + private static func makePresentation(items: [WorkstreamItem]) -> FeedPresentationSnapshot { + var lastPromptByWorkstream: [String: String] = [:] + for item in items { + if case .userPrompt(let text) = item.payload, !text.isEmpty { + lastPromptByWorkstream[item.workstreamId] = text + } + } + + var actionable: [FeedItemSnapshot] = [] + var activityStable: [FeedItemSnapshot] = [] + var activityHistory: [FeedItemSnapshot] = [] + actionable.reserveCapacity(items.count) + activityStable.reserveCapacity(items.count) + activityHistory.reserveCapacity(items.count) + + for item in items.reversed() { + let snapshot = FeedItemSnapshot( + item: item, + userPromptEcho: lastPromptByWorkstream[item.workstreamId] + ) + if item.kind.isActionable { + actionable.append(snapshot) + } + guard item.kind.isActionable || item.kind == .todos || item.kind == .stop else { + continue + } + if snapshot.status.isPending || snapshot.kind == .stop { + activityStable.append(snapshot) + } else { + activityHistory.append(snapshot) + } + } + return FeedPresentationSnapshot( + actionable: actionable, + activity: FeedActivitySnapshotGroups( + stable: activityStable, + history: activityHistory + ) + ) + } + func loadOlderItems() { guard !isLoadingOlderItems, hasMorePersistedItems, diff --git a/Sources/Feed/FeedPermissionView.swift b/Sources/Feed/FeedPermissionView.swift index 02b0b6167109..146730e02a0c 100644 --- a/Sources/Feed/FeedPermissionView.swift +++ b/Sources/Feed/FeedPermissionView.swift @@ -261,6 +261,7 @@ struct FeedButton: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .disabled(dimmed) .onHover { hovering in handleHover(hovering) } diff --git a/Sources/Feed/FeedPresentationSnapshot.swift b/Sources/Feed/FeedPresentationSnapshot.swift new file mode 100644 index 000000000000..7803e8f0303e --- /dev/null +++ b/Sources/Feed/FeedPresentationSnapshot.swift @@ -0,0 +1,10 @@ +/// Immutable presentation data consumed by the Feed list. +struct FeedPresentationSnapshot: Equatable { + static let empty = FeedPresentationSnapshot( + actionable: [], + activity: FeedActivitySnapshotGroups(stable: [], history: []) + ) + + let actionable: [FeedItemSnapshot] + let activity: FeedActivitySnapshotGroups +} diff --git a/Sources/Feed/FeedQuestionView.swift b/Sources/Feed/FeedQuestionView.swift index 63929e8be227..e10449cc7600 100644 --- a/Sources/Feed/FeedQuestionView.swift +++ b/Sources/Feed/FeedQuestionView.swift @@ -430,29 +430,10 @@ struct QuestionActionArea: View { } private var isPlanAskUserQuestion: Bool { - guard source == .claude else { return false } - if let mode = context?.permissionMode { - return mode.caseInsensitiveCompare("plan") == .orderedSame + guard source == .claude, let mode = context?.permissionMode else { + return false } - return questionTextLooksLikePlanInterview - } - - private var questionTextLooksLikePlanInterview: Bool { - let fragments: [String?] = questions.flatMap { q in - let questionFragments: [String?] = [q.header, q.prompt] - let optionFragments: [String?] = q.options.flatMap { option in - [option.label, option.description] - } - return questionFragments + optionFragments - } - let text = ([context?.lastUserMessage, context?.assistantPreamble] + fragments) - .compactMap { $0 } - .joined(separator: " ") - .lowercased() - return text.contains("plan mode") - || text.contains("make a plan") - || text.contains("plan-only") - || text.contains("plan immediately") + return mode.caseInsensitiveCompare("plan") == .orderedSame } private var submitCTA: some View { @@ -505,7 +486,7 @@ final class FeedInlineNativeTextView: NSTextView, FeedKeyboardFocusResponder { var onSubmit: (() -> Void)? var feedFocusScopeID = UUID() - static func blurActiveEditor() { + static func blurActiveEditor(in hostWindow: NSWindow?) { guard let activeEditor else { return } guard let window = activeEditor.window else { if Self.activeEditor === activeEditor { @@ -513,6 +494,7 @@ final class FeedInlineNativeTextView: NSTextView, FeedKeyboardFocusResponder { } return } + guard window === hostWindow else { return } guard window.firstResponder === activeEditor else { if Self.activeEditor === activeEditor { Self.activeEditor = nil @@ -762,6 +744,7 @@ struct FeedInlineTextField: NSViewRepresentable { let onBlur: () -> Void let onSubmit: (() -> Void)? + @MainActor final class Coordinator: NSObject, NSTextViewDelegate { var parent: FeedInlineTextField var isProgrammaticMutation = false @@ -791,15 +774,12 @@ struct FeedInlineTextField: NSViewRepresentable { window.makeFirstResponder(nil) return } - Task { @MainActor [weak window] in - guard let window else { return } - if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( - mode: .feed, - focusFirstItem: false, - preferredWindow: window - ) != true { - window.makeFirstResponder(nil) - } + if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( + mode: .feed, + focusFirstItem: false, + preferredWindow: window + ) != true { + window.makeFirstResponder(nil) } } diff --git a/Sources/Feed/FeedSocketEncoding.swift b/Sources/Feed/FeedSocketEncoding.swift index d34cef35a58d..a8c98fd82b02 100644 --- a/Sources/Feed/FeedSocketEncoding.swift +++ b/Sources/Feed/FeedSocketEncoding.swift @@ -2,11 +2,12 @@ import CMUXAgentLaunch import Foundation /// JSON-shape helpers used by the V2 `feed.*` socket handlers. -enum FeedSocketEncoding { - private static let primaryTextLimit = 8_000 - private static let secondaryTextLimit = 2_000 +final class FeedSocketEncoder: @unchecked Sendable { + private let primaryTextLimit = 8_000 + private let secondaryTextLimit = 2_000 + private let dateFormatStyle = Date.ISO8601FormatStyle() - static func payload(for result: FeedCoordinator.IngestBlockingResult) -> [String: Any] { + func payload(for result: FeedCoordinator.IngestBlockingResult) -> [String: Any] { switch result { case .acknowledged(let itemId): var dict: [String: Any] = ["status": "acknowledged"] @@ -26,7 +27,7 @@ enum FeedSocketEncoding { } } - static func decisionDict(_ decision: WorkstreamDecision) -> [String: Any] { + func decisionDict(_ decision: WorkstreamDecision) -> [String: Any] { switch decision { case .permission(let mode): return ["kind": "permission", "mode": mode.rawValue] @@ -41,26 +42,26 @@ enum FeedSocketEncoding { } } - private static func limitedText(_ value: String, limit: Int) -> (text: String, truncated: Bool) { + private func limitedText(_ value: String, limit: Int) -> (text: String, truncated: Bool) { guard value.count > limit else { return (value, false) } let end = value.index(value.startIndex, offsetBy: max(limit - 3, 0)) return (String(value[.. [String: Any] { + private func questionDict(_ question: WorkstreamQuestionPrompt) -> [String: Any] { var dict: [String: Any] = [ "id": question.id, "multi_select": question.multiSelect, @@ -82,15 +83,17 @@ enum FeedSocketEncoding { return dict } - static func itemDict(_ item: WorkstreamItem) -> [String: Any] { - let isoFormatter = ISO8601DateFormatter() + func itemDict(_ item: WorkstreamItem) -> [String: Any] { + let formatDate: (Date) -> String = { [dateFormatStyle] date in + date.formatted(dateFormatStyle) + } var dict: [String: Any] = [ "id": item.id.uuidString, "workstream_id": item.workstreamId, "source": item.source.rawValue, "kind": item.kind.rawValue, - "created_at": isoFormatter.string(from: item.createdAt), - "updated_at": isoFormatter.string(from: item.updatedAt), + "created_at": formatDate(item.createdAt), + "updated_at": formatDate(item.updatedAt), ] if let cwd = item.cwd { dict["cwd"] = cwd } if let title = item.title { dict["title"] = title } @@ -100,20 +103,26 @@ enum FeedSocketEncoding { case .resolved(let decision, let at): dict["status"] = "resolved" dict["decision"] = decisionDict(decision) - dict["resolved_at"] = isoFormatter.string(from: at) + dict["resolved_at"] = formatDate(at) case .expired(let at): dict["status"] = "expired" - dict["resolved_at"] = isoFormatter.string(from: at) + dict["resolved_at"] = formatDate(at) case .telemetry: dict["status"] = "telemetry" } - switch item.payload { + let rawPayload = item.payload + switch rawPayload.redactedForPersistence() { case .permissionRequest(let requestId, let toolName, let toolInputJSON, let pattern): dict["request_id"] = requestId dict["tool_name"] = toolName + let rawToolInputJSON: String = if case .permissionRequest(_, _, let rawInput, _) = rawPayload { + rawInput + } else { + toolInputJSON + } if let capabilityJSON = FeedPermissionActionPolicy.codexCapabilityToolInputJSON( source: item.source, - toolInputJSON: toolInputJSON + toolInputJSON: rawToolInputJSON ) { dict["tool_input_capabilities"] = capabilityJSON } diff --git a/Sources/Feed/FeedTelemetryView.swift b/Sources/Feed/FeedTelemetryView.swift index d1f4052daba1..7901b7053bd5 100644 --- a/Sources/Feed/FeedTelemetryView.swift +++ b/Sources/Feed/FeedTelemetryView.swift @@ -148,12 +148,28 @@ struct TelemetryActionArea: View { private struct TodoListBody: View { let todos: [WorkstreamTaskTodo] + private let done: [WorkstreamTaskTodo] + private let inProgress: [WorkstreamTaskTodo] + private let pending: [WorkstreamTaskTodo] @State private var expanded = false - private var done: [WorkstreamTaskTodo] { todos.filter { $0.state == .completed } } - private var inProgress: [WorkstreamTaskTodo] { todos.filter { $0.state == .inProgress } } - private var pending: [WorkstreamTaskTodo] { todos.filter { $0.state == .pending } } + init(todos: [WorkstreamTaskTodo]) { + self.todos = todos + var done: [WorkstreamTaskTodo] = [] + var inProgress: [WorkstreamTaskTodo] = [] + var pending: [WorkstreamTaskTodo] = [] + for todo in todos { + switch todo.state { + case .completed: done.append(todo) + case .inProgress: inProgress.append(todo) + case .pending: pending.append(todo) + } + } + self.done = done + self.inProgress = inProgress + self.pending = pending + } private var visibleDone: [WorkstreamTaskTodo] { expanded ? done : Array(done.prefix(2)) @@ -177,10 +193,7 @@ private struct TodoListBody: View { Button { expanded.toggle() } label: { - Text(String( - localized: "feed.todos.moreCompleted", - defaultValue: "... +\(done.count - visibleDone.count) completed" - )) + Text(moreCompletedLabel(done.count - visibleDone.count)) .cmuxFont(size: 11) .foregroundColor(.secondary.opacity(0.8)) .padding(.leading, 22) @@ -204,17 +217,54 @@ private struct TodoListBody: View { let d = done.count, ip = inProgress.count, p = pending.count var parts: [String] = [] if d > 0 { - parts.append(String(localized: "feed.todos.summary.done", defaultValue: "\(d) done")) + parts.append(doneLabel(d)) } if ip > 0 { - parts.append(String(localized: "feed.todos.summary.inProgress", defaultValue: "\(ip) in progress")) + parts.append(inProgressLabel(ip)) } if p > 0 { - parts.append(String(localized: "feed.todos.summary.open", defaultValue: "\(p) open")) + parts.append(openLabel(p)) } return "(" + parts.joined(separator: ", ") + ")" } + private func moreCompletedLabel(_ count: Int) -> String { + if count == 1 { + return String(localized: "feed.todos.moreCompleted.one", defaultValue: "... +1 completed") + } + return String( + localized: "feed.todos.moreCompleted.other", + defaultValue: "... +\(count) completed" + ) + } + + private func doneLabel(_ count: Int) -> String { + if count == 1 { + return String(localized: "feed.todos.summary.done.one", defaultValue: "1 done") + } + return String(localized: "feed.todos.summary.done.other", defaultValue: "\(count) done") + } + + private func inProgressLabel(_ count: Int) -> String { + if count == 1 { + return String( + localized: "feed.todos.summary.inProgress.one", + defaultValue: "1 in progress" + ) + } + return String( + localized: "feed.todos.summary.inProgress.other", + defaultValue: "\(count) in progress" + ) + } + + private func openLabel(_ count: Int) -> String { + if count == 1 { + return String(localized: "feed.todos.summary.open.one", defaultValue: "1 open") + } + return String(localized: "feed.todos.summary.open.other", defaultValue: "\(count) open") + } + @ViewBuilder private func row(_ todo: WorkstreamTaskTodo) -> some View { HStack(alignment: .top, spacing: 6) { diff --git a/Sources/RightSidebarToolPanel.swift b/Sources/RightSidebarToolPanel.swift index 1a81aa4643d6..039d267e8513 100644 --- a/Sources/RightSidebarToolPanel.swift +++ b/Sources/RightSidebarToolPanel.swift @@ -15,6 +15,7 @@ final class RightSidebarToolPanel: Panel, ObservableObject { private weak var fileExplorerContainerView: FileExplorerContainerView? private weak var sessionIndexFocusAnchorView: RightSidebarToolFocusAnchorView? private weak var feedFocusAnchorView: FeedKeyboardFocusView? + private var pendingFeedFocus = false private var fileExplorerStoreStorage: FileExplorerStore? private var fileExplorerStateStorage: FileExplorerState? private var sessionIndexStoreStorage: SessionIndexStore? @@ -77,6 +78,9 @@ final class RightSidebarToolPanel: Panel, ObservableObject { fileprivate func attachFeedFocusAnchor(_ anchor: FeedKeyboardFocusView?) { feedFocusAnchorView = anchor + guard pendingFeedFocus, let anchor, isFocusedInWorkspace else { return } + pendingFeedFocus = false + _ = anchor.focusHostFromCoordinator() } func syncWorkspaceRoot(from workspace: Workspace) { @@ -131,6 +135,7 @@ final class RightSidebarToolPanel: Panel, ObservableObject { fileExplorerContainerView = nil sessionIndexFocusAnchorView = nil feedFocusAnchorView = nil + pendingFeedFocus = false fileExplorerStoreStorage?.applyWorkspaceRoot(.none) sessionIndexStoreStorage?.setCurrentDirectoryIfChanged(nil) workspaceObservationCancellable = nil @@ -147,7 +152,12 @@ final class RightSidebarToolPanel: Panel, ObservableObject { let window = anchor.window else { return } _ = window.makeFirstResponder(anchor) case .feed: - _ = feedFocusAnchorView?.focusHostFromCoordinator() + guard let feedFocusAnchorView else { + pendingFeedFocus = true + return + } + pendingFeedFocus = false + _ = feedFocusAnchorView.focusHostFromCoordinator() case .dock, .customSidebar: break } diff --git a/Sources/TerminalController+ControlFeedContext.swift b/Sources/TerminalController+ControlFeedContext.swift index b0bd8d84751c..b32d88a6324c 100644 --- a/Sources/TerminalController+ControlFeedContext.swift +++ b/Sources/TerminalController+ControlFeedContext.swift @@ -5,7 +5,7 @@ import Foundation /// former `v2FeedJump` / `v2FeedList` dispatchers. Both ran on the main actor /// already (they were not `nonisolated`), so there is no per-read `v2MainSync` /// hop to shed; the work is the same `FeedCoordinator.shared` reads the legacy -/// bodies performed, with the per-item encoding (`FeedSocketEncoding.itemDict`) +/// bodies performed, with the per-item encoding (`FeedSocketEncoder.itemDict`) /// bridged to `JSONValue` so the wire bytes match exactly. /// /// Only the MAIN-ACTOR feed methods move here. The worker-lane feed methods @@ -18,10 +18,12 @@ extension TerminalController: ControlFeedContext { func controlFeedSnapshotItems(pendingOnly: Bool) -> [JSONValue] { FeedCoordinator.shared.snapshot(pendingOnly: pendingOnly).map { item in - // `FeedSocketEncoding.itemDict` only ever produces valid JSON + // `FeedSocketEncoder.itemDict` only ever produces valid JSON // (strings, bools, arrays, nested dicts), so the bridge never fails; // the empty-object fallback exists solely to keep the map total. - JSONValue(foundationObject: FeedSocketEncoding.itemDict(item)) ?? .object([:]) + JSONValue( + foundationObject: FeedCoordinator.shared.socketEncoder.itemDict(item) + ) ?? .object([:]) } } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 6c0a57d60034..27b112bd6eb7 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5610,16 +5610,20 @@ class TerminalController { v2ApplyIMessageModeSideEffects(for: event) Task { @MainActor in self.agentChatTranscriptService?.noteHookEvent(event) } - let result = FeedCoordinator.shared.ingestBlocking( - event: event, - waitTimeout: waitTimeout - ) + let bridge = FeedBlockingCallBridge() + Task { + bridge.finish(with: await FeedCoordinator.shared.ingestBlocking( + event: event, + waitTimeout: waitTimeout + )) + } + let result = bridge.wait() CmuxEventBus.shared.publishWorkstreamEvent( event, phase: "completed", - result: FeedSocketEncoding.payload(for: result) + result: FeedCoordinator.shared.socketEncoder.payload(for: result) ) - return .ok(FeedSocketEncoding.payload(for: result)) + return .ok(FeedCoordinator.shared.socketEncoder.payload(for: result)) } private nonisolated func v2ApplyIMessageModeSideEffects(for event: WorkstreamEvent) { @@ -5674,11 +5678,10 @@ class TerminalController { data: nil ) } - FeedCoordinator.shared.deliverReply( + return .ok(["delivered": v2DeliverFeedReply( requestId: requestId, decision: .permission(mode) - ) - return .ok(["delivered": true]) + )]) } private nonisolated func v2FeedQuestionReply(params: [String: Any]) -> V2CallResult { @@ -5696,11 +5699,10 @@ class TerminalController { data: nil ) } - FeedCoordinator.shared.deliverReply( + return .ok(["delivered": v2DeliverFeedReply( requestId: requestId, decision: .question(selections: selections) - ) - return .ok(["delivered": true]) + )]) } private nonisolated func v2FeedExitPlanReply(params: [String: Any]) -> V2CallResult { @@ -5721,11 +5723,24 @@ class TerminalController { ) } let feedback = params["feedback"] as? String - FeedCoordinator.shared.deliverReply( + return .ok(["delivered": v2DeliverFeedReply( requestId: requestId, decision: .exitPlan(mode, feedback: feedback) - ) - return .ok(["delivered": true]) + )]) + } + + private nonisolated func v2DeliverFeedReply( + requestId: String, + decision: WorkstreamDecision + ) -> Bool { + let bridge = FeedBlockingCallBridge() + Task { + bridge.finish(with: await FeedCoordinator.shared.deliverReply( + requestId: requestId, + decision: decision + )) + } + return bridge.wait() } // MARK: - V2 Browser Methods diff --git a/Sources/TerminalNotificationPolicy.swift b/Sources/TerminalNotificationPolicy.swift index d01373cb5074..827d9dcd911b 100644 --- a/Sources/TerminalNotificationPolicy.swift +++ b/Sources/TerminalNotificationPolicy.swift @@ -228,7 +228,12 @@ struct TerminalNotificationPolicyFailure: Error, Sendable, Hashable { enum TerminalNotificationPolicyEngine { private static let maxOutputBytes = 1_048_576 - static func evaluate( + #if compiler(>=6.2) + @concurrent + #else + @Sendable + #endif + nonisolated static func evaluate( request: TerminalNotificationPolicyRequest, hooks: [CmuxResolvedNotificationHook] ) async -> Result { @@ -252,7 +257,12 @@ enum TerminalNotificationPolicyEngine { return await evaluate(envelope: initialEnvelope, hooks: hooks) } - static func evaluate( + #if compiler(>=6.2) + @concurrent + #else + @Sendable + #endif + nonisolated static func evaluate( envelope initialEnvelope: TerminalNotificationPolicyEnvelope, hooks: [CmuxResolvedNotificationHook] ) async -> Result { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 07b57cc292a5..36f0fb4dde20 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1627,6 +1627,7 @@ extension Workspace { case .rightSidebarTool: guard let mode = snapshot.rightSidebarTool?.mode, mode.canOpenAsPane, + mode.isAvailable(), let toolPanel = newRightSidebarToolSurface( inPane: paneId, mode: mode, @@ -8373,7 +8374,7 @@ final class Workspace: Identifiable, ObservableObject { mode: RightSidebarMode, focus: Bool = true ) -> RightSidebarToolPanel? { - guard mode.canOpenAsPane else { return nil } + guard mode.canOpenAsPane, mode.isAvailable() else { return nil } for (existingId, panel) in panels { guard let toolPanel = panel as? RightSidebarToolPanel, toolPanel.mode == mode else { @@ -8394,7 +8395,7 @@ final class Workspace: Identifiable, ObservableObject { focus: Bool? = nil, targetIndex: Int? = nil ) -> RightSidebarToolPanel? { - guard mode.canOpenAsPane else { return nil } + guard mode.canOpenAsPane, mode.isAvailable() else { return nil } let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 9ab16f6e7eae..d586e1b7a100 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -822,6 +822,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources E50320010000000000000001 /* ExtensionWorktreeSpawnArgsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50320010000000000000002 /* ExtensionWorktreeSpawnArgsTests.swift */; }; C0DEA7710000000000000007 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA7710000000000000008 /* FeatureFlags.swift */; }; FEED0A00000000000000F007 /* feed-tui in Resources */ = {isa = PBXBuildFile; fileRef = FEED0A00000000000000F006 /* feed-tui */; }; + FEE200000000000000000013 /* FeedActivitySnapshotGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000013 /* FeedActivitySnapshotGroups.swift */; }; + FEE200000000000000000012 /* FeedBlockingCallBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000012 /* FeedBlockingCallBridge.swift */; }; FEE200000000000000000002 /* FeedBlockingWaiterRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000002 /* FeedBlockingWaiterRegistry.swift */; }; FEED0000000000000000F00D /* FeedButtonStyleDebugWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00C /* FeedButtonStyleDebugWindowController.swift */; }; FEE200000000000000000003 /* FeedCoordinator+Attention.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000003 /* FeedCoordinator+Attention.swift */; }; @@ -840,11 +842,13 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources FEE20000000000000000000B /* FeedKeyboardFocus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000B /* FeedKeyboardFocus.swift */; }; FEE20000000000000000000A /* FeedListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000A /* FeedListView.swift */; }; FEE200000000000000000008 /* FeedNotificationPolicyContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000008 /* FeedNotificationPolicyContext.swift */; }; + FEE200000000000000000015 /* FeedNotificationPolicySnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000015 /* FeedNotificationPolicySnapshot.swift */; }; FEE200000000000000000001 /* FeedOpeningCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000001 /* FeedOpeningCoordinator.swift */; }; FEED0000000000000000F005 /* FeedPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F004 /* FeedPanelView.swift */; }; FEED0000000000000000F011 /* FeedPanelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F010 /* FeedPanelViewModel.swift */; }; FEED0000000000000000F013 /* FeedPermissionActionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */; }; FEE20000000000000000000E /* FeedPermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000E /* FeedPermissionView.swift */; }; + FEE200000000000000000014 /* FeedPresentationSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000014 /* FeedPresentationSnapshot.swift */; }; FEED0000000000000000F00B /* FeedPreviewWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00A /* FeedPreviewWindowController.swift */; }; FEE200000000000000000010 /* FeedQuestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000010 /* FeedQuestionView.swift */; }; FEED0000000000000000F009 /* FeedSidebarUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F008 /* FeedSidebarUITests.swift */; }; @@ -2878,6 +2882,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E50320010000000000000002 /* ExtensionWorktreeSpawnArgsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionWorktreeSpawnArgsTests.swift; sourceTree = ""; }; C0DEA7710000000000000008 /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; FEED0A00000000000000F006 /* feed-tui */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; path = "feed-tui"; sourceTree = ""; }; + FEE100000000000000000013 /* FeedActivitySnapshotGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedActivitySnapshotGroups.swift; sourceTree = ""; }; + FEE100000000000000000012 /* FeedBlockingCallBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedBlockingCallBridge.swift; sourceTree = ""; }; FEE100000000000000000002 /* FeedBlockingWaiterRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedBlockingWaiterRegistry.swift; sourceTree = ""; }; FEED0000000000000000F00C /* FeedButtonStyleDebugWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedButtonStyleDebugWindowController.swift; sourceTree = ""; }; FEE100000000000000000003 /* FeedCoordinator+Attention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedCoordinator+Attention.swift"; sourceTree = ""; }; @@ -2895,11 +2901,13 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEE10000000000000000000B /* FeedKeyboardFocus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedKeyboardFocus.swift; sourceTree = ""; }; FEE10000000000000000000A /* FeedListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListView.swift; sourceTree = ""; }; FEE100000000000000000008 /* FeedNotificationPolicyContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedNotificationPolicyContext.swift; sourceTree = ""; }; + FEE100000000000000000015 /* FeedNotificationPolicySnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedNotificationPolicySnapshot.swift; sourceTree = ""; }; FEE100000000000000000001 /* FeedOpeningCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedOpeningCoordinator.swift; sourceTree = ""; }; FEED0000000000000000F004 /* FeedPanelView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPanelView.swift; sourceTree = ""; }; FEED0000000000000000F010 /* FeedPanelViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPanelViewModel.swift; sourceTree = ""; }; FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPermissionActionPolicy.swift; sourceTree = ""; }; FEE10000000000000000000E /* FeedPermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPermissionView.swift; sourceTree = ""; }; + FEE100000000000000000014 /* FeedPresentationSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPresentationSnapshot.swift; sourceTree = ""; }; FEED0000000000000000F00A /* FeedPreviewWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPreviewWindowController.swift; sourceTree = ""; }; FEE100000000000000000010 /* FeedQuestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedQuestionView.swift; sourceTree = ""; }; FEED0000000000000000F008 /* FeedSidebarUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSidebarUITests.swift; sourceTree = ""; }; @@ -6256,6 +6264,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEE100000000000000000005 /* FeedCoordinator+Socket.swift */, FEE100000000000000000006 /* FeedCoordinatorState.swift */, FEE100000000000000000002 /* FeedBlockingWaiterRegistry.swift */, + FEE100000000000000000012 /* FeedBlockingCallBridge.swift */, + FEE100000000000000000013 /* FeedActivitySnapshotGroups.swift */, + FEE100000000000000000014 /* FeedPresentationSnapshot.swift */, + FEE100000000000000000015 /* FeedNotificationPolicySnapshot.swift */, FEE100000000000000000007 /* FeedJumpResolver.swift */, FEE100000000000000000008 /* FeedNotificationPolicyContext.swift */, FEE100000000000000000009 /* FeedSocketEncoding.swift */, @@ -7179,6 +7191,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E4C001000000000000000001 /* ExtensionSidebarWorkspaceRowView.swift in Sources */, E4C001000000000000000003 /* ExtensionWorktreePrototype.swift in Sources */, C0DEA7710000000000000007 /* FeatureFlags.swift in Sources */, + FEE200000000000000000013 /* FeedActivitySnapshotGroups.swift in Sources */, + FEE200000000000000000012 /* FeedBlockingCallBridge.swift in Sources */, FEE200000000000000000002 /* FeedBlockingWaiterRegistry.swift in Sources */, FEED0000000000000000F00D /* FeedButtonStyleDebugWindowController.swift in Sources */, FEE200000000000000000003 /* FeedCoordinator+Attention.swift in Sources */, @@ -7193,11 +7207,13 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEE20000000000000000000B /* FeedKeyboardFocus.swift in Sources */, FEE20000000000000000000A /* FeedListView.swift in Sources */, FEE200000000000000000008 /* FeedNotificationPolicyContext.swift in Sources */, + FEE200000000000000000015 /* FeedNotificationPolicySnapshot.swift in Sources */, FEE200000000000000000001 /* FeedOpeningCoordinator.swift in Sources */, FEED0000000000000000F005 /* FeedPanelView.swift in Sources */, FEED0000000000000000F011 /* FeedPanelViewModel.swift in Sources */, FEED0000000000000000F013 /* FeedPermissionActionPolicy.swift in Sources */, FEE20000000000000000000E /* FeedPermissionView.swift in Sources */, + FEE200000000000000000014 /* FeedPresentationSnapshot.swift in Sources */, FEED0000000000000000F00B /* FeedPreviewWindowController.swift in Sources */, FEE200000000000000000010 /* FeedQuestionView.swift in Sources */, FEE200000000000000000009 /* FeedSocketEncoding.swift in Sources */, diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index b6a695bd88f9..d74fe0661441 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -38,6 +38,42 @@ struct FeedCoordinatorTests { #expect(workspace.focusedPanelId == panel.id) } + @MainActor + @Test func disabledFeedCannotRestoreOrCreatePaneSurface() throws { + let flags = CmuxFeatureFlags.shared + let definition = try #require( + CmuxFeatureFlags.allFlags.first { $0.key == "feed-ui-enabled-release" } + ) + let previous = flags.overrideValue(for: definition) + flags.setOverride(false, for: definition) + defer { flags.setOverride(previous, for: definition) } + + let workspace = Workspace() + let paneID = try #require(workspace.bonsplitController.focusedPaneId) + #expect( + workspace.newRightSidebarToolSurface( + inPane: paneID, + mode: .feed, + focus: false + ) == nil + ) + } + + @Test func blockingWaiterAcceptsOnlyFirstDecision() async throws { + let registry = FeedBlockingWaiterRegistry() + let requestID = "first-writer-wins" + let decisions = try #require(await registry.register(requestID: requestID)) + + let first = await registry.deliver(.permission(.once), requestID: requestID) + let second = await registry.deliver(.permission(.deny), requestID: requestID) + var iterator = decisions.makeAsyncIterator() + + #expect(first.accepted) + #expect(!second.accepted) + #expect(await iterator.next() == .permission(.once)) + #expect(await registry.remove(requestID: requestID)?.decision == .permission(.once)) + } + @Test func codexTeamsResolvesExplicitWorkingDirectoryFlags() { let base = "/tmp/cmux-base" @@ -171,7 +207,7 @@ struct FeedCoordinatorTests { ) ) - let dict = FeedSocketEncoding.itemDict(item) + let dict = FeedCoordinator.shared.socketEncoder.itemDict(item) let displayToolInput = try #require(dict["tool_input"] as? String) let capabilityToolInput = try #require(dict["tool_input_capabilities"] as? String) @@ -473,8 +509,8 @@ struct FeedCoordinatorTests { let done = DispatchSemaphore(value: 0) let resultBox = IngestResultBox() - DispatchQueue.global(qos: .userInitiated).async { - resultBox.value = FeedCoordinator.shared.ingestBlocking( + Task { + resultBox.value = await FeedCoordinator.shared.ingestBlocking( event: event, waitTimeout: 0.05 ) @@ -497,28 +533,12 @@ struct FeedCoordinatorTests { } } - @Test func blockingIngestSkipsNotificationWhenPermissionResolvesBeforeDisplay() async { + @Test func blockingIngestResolvesAfterMainActorIngest() async { let requestId = "auto-allow-request" - let notifications = NotificationRequestRecorder() - - defer { - Self.resetFeedCoordinatorTestHooks() - } await MainActor.run { let store = WorkstreamStore(ringCapacity: 10) FeedCoordinator.shared.install(store: store) - FeedCoordinatorTestHooks.afterBlockingEventIngested = { _, ingestedRequestId in - guard ingestedRequestId == requestId else { return } - FeedCoordinator.shared.deliverReply( - requestId: ingestedRequestId, - decision: .permission(.once) - ) - } - FeedCoordinatorTestHooks.isAppActiveOverride = { false } - FeedCoordinatorTestHooks.notificationPostObserver = { _, postedRequestId in - notifications.record(postedRequestId) - } } let event = WorkstreamEvent( @@ -534,14 +554,32 @@ struct FeedCoordinatorTests { let done = DispatchSemaphore(value: 0) let resultBox = IngestResultBox() - DispatchQueue.global(qos: .userInitiated).async { - resultBox.value = FeedCoordinator.shared.ingestBlocking( + Task { + resultBox.value = await FeedCoordinator.shared.ingestBlocking( event: event, waitTimeout: 1 ) done.signal() } + var didIngest = false + for _ in 0..<500 where !didIngest { + didIngest = await MainActor.run { + FeedCoordinator.shared.store.items.contains { item in + if case .permissionRequest(let itemRequestID, _, _, _) = item.payload { + return itemRequestID == requestId + } + return false + } + } + if !didIngest { await Task.yield() } + } + #expect(didIngest, "blocking event should reach the main-actor store") + await FeedCoordinator.shared.deliverReply( + requestId: requestId, + decision: .permission(.once) + ) + #expect(done.wait(timeout: .now() + 2) == .success) await MainActor.run {} @@ -559,64 +597,33 @@ struct FeedCoordinatorTests { return } - #expect( - notifications.requestIds.isEmpty, - "auto-allowed permission requests should not post native notifications" - ) } - @Test func blockingIngestSurfacesNeedsInputAttentionForPermissionRequest() async { - defer { - Self.resetFeedCoordinatorTestHooks() - } - - let attention = AttentionSurfaceRecorder() - let requestId = "needs-input-attention-request" - - await MainActor.run { - let store = WorkstreamStore(ringCapacity: 10) - FeedCoordinator.shared.install(store: store) - FeedCoordinatorTestHooks.attentionSurfaceObserver = { event in - attention.record(event) - } - // Resolve the blocking wait as soon as the item is ingested so - // the worker thread does not park for the full timeout. - FeedCoordinatorTestHooks.afterBlockingEventIngested = { _, ingestedRequestId in - guard ingestedRequestId == requestId else { return } - FeedCoordinator.shared.deliverReply( - requestId: ingestedRequestId, - decision: .permission(.once) - ) - } - } + @MainActor + @Test func attentionUsesRegisteredAgentPanelAndFailsClosedWithoutOne() throws { + let manager = TabManager() + let workspace = manager.addWorkspace() + let panelID = try #require(workspace.focusedPanelId) - let event = WorkstreamEvent( - sessionId: "claude-needs-input-test", - hookEventName: .permissionRequest, - source: "claude", - cwd: "/tmp", - toolName: "Bash", - toolInputJSON: #"{"command":"true"}"#, - requestId: requestId + #expect( + FeedCoordinator.registeredAgentTarget( + source: "claude", + surfaceId: panelID, + tab: workspace + ) == nil ) - let done = DispatchSemaphore(value: 0) - let resultBox = IngestResultBox() - DispatchQueue.global(qos: .userInitiated).async { - resultBox.value = FeedCoordinator.shared.ingestBlocking( - event: event, - waitTimeout: 1 + workspace.agentPIDPanelIdsByKey["claude_code"] = panelID + workspace.agentPIDKeysByPanelId[panelID, default: []].insert("claude_code") + let target = try #require( + FeedCoordinator.registeredAgentTarget( + source: "claude", + surfaceId: panelID, + tab: workspace ) - done.signal() - } - #expect(done.wait(timeout: .now() + 2) == .success) - await MainActor.run {} - - #expect( - attention.events.count == 1, - "a blocking PermissionRequest must request in-app needs-input attention surfacing" ) - #expect(attention.events.first?.hookEventName == .permissionRequest) + #expect(target.panelID == panelID) + #expect(target.statusKey == "claude_code") } @Test func blockingDecisionEventPredicateCoversEveryDecisionKind() { @@ -640,57 +647,8 @@ struct FeedCoordinatorTests { #expect(FeedCoordinator.lifecycleStatusKey(forSource: "opencode") == "opencode") } - private static func resetFeedCoordinatorTestHooks() { - let reset: @Sendable () -> Void = { - MainActor.assumeIsolated { - FeedCoordinatorTestHooks.afterBlockingEventIngested = nil - FeedCoordinatorTestHooks.isAppActiveOverride = nil - FeedCoordinatorTestHooks.notificationPostObserver = nil - FeedCoordinatorTestHooks.attentionSurfaceObserver = nil - } - } - if Thread.isMainThread { - reset() - } else { - DispatchQueue.main.sync(execute: reset) - } - } } private final class IngestResultBox: @unchecked Sendable { var value: FeedCoordinator.IngestBlockingResult? } - -private final class AttentionSurfaceRecorder: @unchecked Sendable { - private let lock = NSLock() - private var recordedEvents: [WorkstreamEvent] = [] - - var events: [WorkstreamEvent] { - lock.lock() - defer { lock.unlock() } - return recordedEvents - } - - func record(_ event: WorkstreamEvent) { - lock.lock() - recordedEvents.append(event) - lock.unlock() - } -} - -private final class NotificationRequestRecorder: @unchecked Sendable { - private let lock = NSLock() - private var recordedRequestIds: [String] = [] - - var requestIds: [String] { - lock.lock() - defer { lock.unlock() } - return recordedRequestIds - } - - func record(_ requestId: String) { - lock.lock() - recordedRequestIds.append(requestId) - lock.unlock() - } -} From e28efddb70ba84bfad5744a9a4de72236a0ede5a Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:50:12 -0700 Subject: [PATCH 08/49] Wire merged workspace todo regression test --- cmux.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 011c3a858043..768f5aca4142 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2019,6 +2019,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 5732A0075732A0075732A007 /* WorkspaceTitlebarModeLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5732B0075732B0075732B007 /* WorkspaceTitlebarModeLayer.swift */; }; 440AC5D24915463C9670F510 /* WorkspaceTitleProvenanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E241BE1D74A7459C8D9C16BC /* WorkspaceTitleProvenanceTests.swift */; }; 7D7D087CAACD917D8163F844 /* WorkspaceTodoFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE3CB8DEEC6E12B7D04D9B92 /* WorkspaceTodoFeature.swift */; }; + E9F3A2B1C4D567890ABCDEF0 /* WorkspaceTodoNotificationRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F3A2B1C4D567890ABCDEF1 /* WorkspaceTodoNotificationRegressionTests.swift */; }; 249969F7EAC03A99E20C753B /* WorkspaceTodoPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28F2A069A4931E9403474975 /* WorkspaceTodoPanel.swift */; }; 78F17D618B657B6CF929E327 /* WorkspaceTodoPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8311EFDE1BC83CBAB1F21B6F /* WorkspaceTodoPanelView.swift */; }; 41ECF85FFD13D233A0AC75A6 /* WorkspaceTodoSidebarModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F007352E96022D7EFBE73C9 /* WorkspaceTodoSidebarModelTests.swift */; }; @@ -4029,6 +4030,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5732B0075732B0075732B007 /* WorkspaceTitlebarModeLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTitlebarModeLayer.swift; sourceTree = ""; }; E241BE1D74A7459C8D9C16BC /* WorkspaceTitleProvenanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTitleProvenanceTests.swift; sourceTree = ""; }; EE3CB8DEEC6E12B7D04D9B92 /* WorkspaceTodoFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTodoFeature.swift; sourceTree = ""; }; + E9F3A2B1C4D567890ABCDEF1 /* WorkspaceTodoNotificationRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTodoNotificationRegressionTests.swift; sourceTree = ""; }; 28F2A069A4931E9403474975 /* WorkspaceTodoPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/WorkspaceTodoPanel.swift; sourceTree = ""; }; 8311EFDE1BC83CBAB1F21B6F /* WorkspaceTodoPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/WorkspaceTodoPanelView.swift; sourceTree = ""; }; 9F007352E96022D7EFBE73C9 /* WorkspaceTodoSidebarModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTodoSidebarModelTests.swift; sourceTree = ""; }; @@ -5975,6 +5977,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, DCE71A0F7E02A07A155241E4 /* SidebarWorkspaceRowStatusGlyphRemovalTests.swift */, 9C5C9824B98FFC44A2C44F47 /* WorkspaceTodoSnapshotTests.swift */, + E9F3A2B1C4D567890ABCDEF1 /* WorkspaceTodoNotificationRegressionTests.swift */, 9F007352E96022D7EFBE73C9 /* WorkspaceTodoSidebarModelTests.swift */, 5B0C00010000000000000004 /* SidebarResizerOcclusionResolverTests.swift */, 5B0C00010000000000000006 /* PortalDividerCursorOcclusionTests.swift */, @@ -8610,6 +8613,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 6047C0DE6047C0DE60470001 /* WorkspaceTerminalTabWorkingDirectoryTests.swift in Sources */, 634AA6233A5BBF781FE83E89 /* WorkspaceTerminalWorkingDirectoryFallbackTests.swift in Sources */, 440AC5D24915463C9670F510 /* WorkspaceTitleProvenanceTests.swift in Sources */, + E9F3A2B1C4D567890ABCDEF0 /* WorkspaceTodoNotificationRegressionTests.swift in Sources */, 41ECF85FFD13D233A0AC75A6 /* WorkspaceTodoSidebarModelTests.swift in Sources */, 5B38DFE53BE108F4DA2BDBB7 /* WorkspaceTodoSnapshotTests.swift in Sources */, 6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */, From 2de82a67db9af2f5285d5bd00f5a0061b8346b7e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:57:07 -0700 Subject: [PATCH 09/49] Fix Feed notification policy and lifecycle --- .../Feed/FeedCoordinator+Notifications.swift | 3 +- Sources/Feed/FeedCoordinator.swift | 1 + .../Feed/FeedNotificationPolicyContext.swift | 37 ++++++++++--------- .../Feed/FeedNotificationPolicySnapshot.swift | 2 +- Sources/Feed/FeedPanelViewModel.swift | 6 +-- .../Cells/SidebarWorkspaceRowSlotViews.swift | 2 +- 6 files changed, 27 insertions(+), 24 deletions(-) diff --git a/Sources/Feed/FeedCoordinator+Notifications.swift b/Sources/Feed/FeedCoordinator+Notifications.swift index 79843754c4f2..d5b9fe1eb692 100644 --- a/Sources/Feed/FeedCoordinator+Notifications.swift +++ b/Sources/Feed/FeedCoordinator+Notifications.swift @@ -70,7 +70,8 @@ extension FeedCoordinator { let policyContext = await FeedNotificationPolicyContext.make( event: event, title: title, - body: body + body: body, + hookCache: notificationHookCache ) let deliverDefault = { @MainActor [weak self] in await self?.deliverFeedNotificationIfStillAwaiting( diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 23d74d095839..6edd78e10db4 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -26,6 +26,7 @@ final class FeedCoordinator: @unchecked Sendable { @MainActor private(set) var store: WorkstreamStore! private let waiterRegistry = FeedBlockingWaiterRegistry() + let notificationHookCache = CmuxNotificationHookCache() let jumpResolver: FeedJumpResolver let socketEncoder = FeedSocketEncoder() private let timeoutClock: ContinuousClock diff --git a/Sources/Feed/FeedNotificationPolicyContext.swift b/Sources/Feed/FeedNotificationPolicyContext.swift index e9631f316bc8..01fc1f8cdf04 100644 --- a/Sources/Feed/FeedNotificationPolicyContext.swift +++ b/Sources/Feed/FeedNotificationPolicyContext.swift @@ -12,14 +12,24 @@ extension FeedNotificationPolicyContext { static func make( event: WorkstreamEvent, title: String, - body: String + body: String, + hookCache: CmuxNotificationHookCache ) async -> FeedNotificationPolicyContext { let snapshot: FeedNotificationPolicySnapshot = await MainActor.run { Self.snapshot(event: event, title: title, body: body) } + let hooks: [CmuxResolvedNotificationHook] + if let globalConfigPath = snapshot.globalConfigPath { + hooks = await hookCache.hooks( + startingFrom: snapshot.hookSearchDirectory, + globalConfigPath: globalConfigPath + ) + } else { + hooks = [] + } return FeedNotificationPolicyContext( envelope: snapshot.envelope, - hooks: snapshot.hooks, + hooks: hooks, globalConfigPath: snapshot.globalConfigPath ) } @@ -51,20 +61,11 @@ extension FeedNotificationPolicyContext { effects.paneFlash = false let workspaceIdentity = workspaceID?.uuidString ?? "" - let configStore = workspace == nil ? nil : context?.cmuxConfigStore - let globalConfigPath = configStore?.globalConfigPath - let hooks: [CmuxResolvedNotificationHook] - if context?.tabManager.selectedTabId == workspaceID { - hooks = configStore?.notificationHooks ?? [] - } else if let globalConfigPath { - let normalizedGlobalPath = URL(fileURLWithPath: globalConfigPath).standardizedFileURL.path - hooks = - configStore?.notificationHooks.filter { - guard let sourcePath = $0.sourcePath else { return false } - return URL(fileURLWithPath: sourcePath).standardizedFileURL.path == normalizedGlobalPath - } ?? [] - } else { - hooks = [] + let globalConfigPath = workspace == nil ? nil : context?.cmuxConfigStore?.globalConfigPath + let hookSearchDirectory = workspace.flatMap { workspace in + workspace.isRemoteWorkspace + ? nil + : (normalizedCWD(event.cwd) ?? workspace.surfaceTabBarDirectory) } return FeedNotificationPolicySnapshot( envelope: TerminalNotificationPolicyEnvelope( @@ -84,8 +85,8 @@ extension FeedNotificationPolicyContext { ), effects: effects ), - hooks: hooks, - globalConfigPath: globalConfigPath + globalConfigPath: globalConfigPath, + hookSearchDirectory: hookSearchDirectory ) } diff --git a/Sources/Feed/FeedNotificationPolicySnapshot.swift b/Sources/Feed/FeedNotificationPolicySnapshot.swift index de5f63878f48..a670f40685d8 100644 --- a/Sources/Feed/FeedNotificationPolicySnapshot.swift +++ b/Sources/Feed/FeedNotificationPolicySnapshot.swift @@ -2,6 +2,6 @@ import Foundation struct FeedNotificationPolicySnapshot: Sendable { let envelope: TerminalNotificationPolicyEnvelope - let hooks: [CmuxResolvedNotificationHook] let globalConfigPath: String? + let hookSearchDirectory: String? } diff --git a/Sources/Feed/FeedPanelViewModel.swift b/Sources/Feed/FeedPanelViewModel.swift index 9a69bda79bba..bd9a04b4bfe2 100644 --- a/Sources/Feed/FeedPanelViewModel.swift +++ b/Sources/Feed/FeedPanelViewModel.swift @@ -23,14 +23,14 @@ final class FeedPanelViewModel { ) { self.coordinator = coordinator storeInstallTask = Task { @MainActor [weak self, weak coordinator] in - guard let self, let coordinator else { return } - self.arm() + guard let coordinator else { return } + self?.arm() for await _ in notificationCenter.notifications( named: FeedCoordinator.storeInstalledNotification, object: coordinator ) { guard !Task.isCancelled else { return } - self.arm() + self?.arm() } } } diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift index 659d7dc50925..a7ccb3f42023 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift @@ -98,7 +98,7 @@ final class SidebarRowPullRequestIconView: NSView { // transparent backing draws nothing (no destination pixels). let tinted = NSImage(size: image.size, flipped: false) { drawRect in image.draw(in: drawRect, from: .zero, operation: .sourceOver, fraction: 1) - color.set() + self.color.set() drawRect.fill(using: .sourceAtop) return true } From bb2d6236bb6cd22dc63be7e67dda128e0a354ea9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:42:00 -0700 Subject: [PATCH 10/49] Use async notification center APIs for Feed --- .../Feed/FeedCoordinator+Notifications.swift | 165 +++++++++--------- 1 file changed, 79 insertions(+), 86 deletions(-) diff --git a/Sources/Feed/FeedCoordinator+Notifications.swift b/Sources/Feed/FeedCoordinator+Notifications.swift index d5b9fe1eb692..b9d7faa7bd08 100644 --- a/Sources/Feed/FeedCoordinator+Notifications.swift +++ b/Sources/Feed/FeedCoordinator+Notifications.swift @@ -189,66 +189,63 @@ extension FeedCoordinator { ) let center = UNUserNotificationCenter.current() - center.getNotificationSettings { settings in - Task { @MainActor [weak self] in - guard let self, await self.isAwaitingDecision(requestId: requestId) else { return } - switch settings.authorizationStatus { - case .authorized, .provisional: - await self.addNotificationIfStillAwaiting( - center: center, - request: request, - requestId: requestId, - effects: effects - ) - case .notDetermined: - var granted = false - var requestFailed = false - do { - granted = try await center.requestAuthorization(options: [.alert, .sound]) - } catch { - requestFailed = true - } - guard await self.isAwaitingDecision(requestId: requestId) else { return } - if granted { - await self.addNotificationIfStillAwaiting( - center: center, - request: request, - requestId: requestId, - effects: effects - ) - } else { - // A non-grant without an error is the user declining - // the prompt just now: honor the fresh denial on this - // very notification. A request error is not a user - // decision, so the fallback stays audible (fail-open). - await self.runFallbackEffectsIfStillAwaiting( - requestId: requestId, - title: title, - subtitle: subtitle, - body: body, - effects: TerminalNotificationStore.fallbackEffects( - effects, - authorizationState: requestFailed ? .unknown : .denied - ), - runCommand: false - ) - } - default: - await self.runFallbackEffectsIfStillAwaiting( - requestId: requestId, - title: title, - subtitle: subtitle, - body: body, - effects: TerminalNotificationStore.fallbackEffects( - effects, - authorizationState: TerminalNotificationStore.authorizationState( - from: settings.authorizationStatus - ) - ), - runCommand: false - ) - } + let settings = await center.notificationSettings() + guard await isAwaitingDecision(requestId: requestId) else { return } + switch settings.authorizationStatus { + case .authorized, .provisional: + await addNotificationIfStillAwaiting( + center: center, + request: request, + requestId: requestId, + effects: effects + ) + case .notDetermined: + var granted = false + var requestFailed = false + do { + granted = try await center.requestAuthorization(options: [.alert, .sound]) + } catch { + requestFailed = true + } + guard await isAwaitingDecision(requestId: requestId) else { return } + if granted { + await addNotificationIfStillAwaiting( + center: center, + request: request, + requestId: requestId, + effects: effects + ) + } else { + // A non-grant without an error is the user declining + // the prompt just now: honor the fresh denial on this + // very notification. A request error is not a user + // decision, so the fallback stays audible (fail-open). + await runFallbackEffectsIfStillAwaiting( + requestId: requestId, + title: title, + subtitle: subtitle, + body: body, + effects: TerminalNotificationStore.fallbackEffects( + effects, + authorizationState: requestFailed ? .unknown : .denied + ), + runCommand: false + ) } + default: + await runFallbackEffectsIfStillAwaiting( + requestId: requestId, + title: title, + subtitle: subtitle, + body: body, + effects: TerminalNotificationStore.fallbackEffects( + effects, + authorizationState: TerminalNotificationStore.authorizationState( + from: settings.authorizationStatus + ) + ), + runCommand: false + ) } } @@ -263,33 +260,29 @@ extension FeedCoordinator { let title = request.content.title let subtitle = request.content.subtitle let body = request.content.body - center.add(request) { error in - let didFail = error != nil - Task { @MainActor [weak self] in - guard let self else { return } - if !(await self.isAwaitingDecision(requestId: requestId)) { - self.cancelNotification(requestId: requestId) - return - } - if didFail { - await self.runFallbackEffectsIfStillAwaiting( - requestId: requestId, - title: title, - subtitle: subtitle, - body: body, - effects: effects, - runCommand: false - ) - return - } - if effects.command { - NotificationSoundSettings.runCustomCommand( - title: title, - subtitle: subtitle, - body: body - ) - } - } + do { + try await center.add(request) + } catch { + await runFallbackEffectsIfStillAwaiting( + requestId: requestId, + title: title, + subtitle: subtitle, + body: body, + effects: effects, + runCommand: false + ) + return + } + guard await isAwaitingDecision(requestId: requestId) else { + cancelNotification(requestId: requestId) + return + } + if effects.command { + NotificationSoundSettings.runCustomCommand( + title: title, + subtitle: subtitle, + body: body + ) } } From b6e9143e0b5bcfb022bed270a4df18dc8f562b66 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:45:40 -0700 Subject: [PATCH 11/49] Test Feed bridge deadline fallback --- cmuxTests/FeedCoordinatorTests.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index d74fe0661441..eb58e81ee31b 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -10,6 +10,12 @@ import CMUXAgentLaunch @Suite("Feed coordinator", .serialized) struct FeedCoordinatorTests { + @Test func blockingBridgeReturnsFallbackAtItsDeadline() { + let bridge = FeedBlockingCallBridge() + + #expect(bridge.wait(timeout: 0, fallback: false) == false) + } + @MainActor @Test func feedWorkspaceContainsOnlyFeedAndIsPinned() throws { let manager = TabManager() From 4c4bdec29b48f68660cf86a5ac7db54826afe8c9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:46:41 -0700 Subject: [PATCH 12/49] Bound Feed socket waits and cache list ordering --- Resources/Localizable.xcstrings | 4 ++-- Sources/Feed/FeedActivitySnapshotGroups.swift | 7 +++++- Sources/Feed/FeedBlockingCallBridge.swift | 14 +++++++---- Sources/Feed/FeedListView.swift | 23 ++++++++----------- Sources/TerminalController.swift | 7 ++++-- 5 files changed, 32 insertions(+), 23 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 8808079ae80f..325014685b2c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -104046,7 +104046,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "プランの確認" + "value": "プランの準備完了" } } } @@ -104165,7 +104165,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "権限の確認" + "value": "権限が必要です" } } } diff --git a/Sources/Feed/FeedActivitySnapshotGroups.swift b/Sources/Feed/FeedActivitySnapshotGroups.swift index 2ba80bbe9afe..6cd7a4e18f9b 100644 --- a/Sources/Feed/FeedActivitySnapshotGroups.swift +++ b/Sources/Feed/FeedActivitySnapshotGroups.swift @@ -2,6 +2,11 @@ struct FeedActivitySnapshotGroups: Equatable { let stable: [FeedItemSnapshot] let history: [FeedItemSnapshot] + let ordered: [FeedItemSnapshot] - var ordered: [FeedItemSnapshot] { stable + history } + init(stable: [FeedItemSnapshot], history: [FeedItemSnapshot]) { + self.stable = stable + self.history = history + self.ordered = stable + history + } } diff --git a/Sources/Feed/FeedBlockingCallBridge.swift b/Sources/Feed/FeedBlockingCallBridge.swift index b4786b092af7..8cade2307a84 100644 --- a/Sources/Feed/FeedBlockingCallBridge.swift +++ b/Sources/Feed/FeedBlockingCallBridge.swift @@ -2,9 +2,10 @@ import Foundation /// Single-writer completion bridge for a synchronous control-socket worker. /// -/// The worker waits on the dispatch group while an async task owns Feed state. -/// `DispatchGroup.leave()` publishes the result before the waiting worker reads -/// it, so no separate mutable-state lock is needed. +/// The worker waits on the dispatch group up to its caller-provided safety +/// deadline while an async task owns Feed state. `DispatchGroup.leave()` +/// publishes the result before the waiting worker reads it, so no separate +/// mutable-state lock is needed. final class FeedBlockingCallBridge: @unchecked Sendable { private let completion = DispatchGroup() nonisolated(unsafe) private var value: Value? @@ -18,8 +19,11 @@ final class FeedBlockingCallBridge: @unchecked Sendable { completion.leave() } - func wait() -> Value { - completion.wait() + func wait(timeout: TimeInterval, fallback: @autoclosure () -> Value) -> Value { + let boundedTimeout = timeout.isFinite ? max(timeout, 0) : 0 + guard completion.wait(timeout: .now() + boundedTimeout) == .success else { + return fallback() + } precondition(value != nil, "Feed blocking call completed without a result") return value! } diff --git a/Sources/Feed/FeedListView.swift b/Sources/Feed/FeedListView.swift index aadb9f0aef62..0da0387328e8 100644 --- a/Sources/Feed/FeedListView.swift +++ b/Sources/Feed/FeedListView.swift @@ -22,11 +22,8 @@ struct FeedListView: View { @State private var focusHostReference = FeedFocusHostReference() var body: some View { - let snapshots = filter == .actionable - ? presentation.actionable - : presentation.activity.ordered let activityGroups = filter == .activity ? presentation.activity : nil - let focusSnapshots = activityGroups?.ordered ?? snapshots + let snapshots = activityGroups?.ordered ?? presentation.actionable let rowActions = FeedRowActions.bound() ScrollViewReader { proxy in Group { @@ -67,13 +64,13 @@ struct FeedListView: View { focusSnapshot.isKeyboardActive = false }, onMoveSelection: { delta in - moveSelection(in: focusSnapshots, delta: delta) + moveSelection(in: snapshots, delta: delta) }, onActivateSelection: { - activateSelection(in: focusSnapshots, actions: rowActions) + activateSelection(in: snapshots, actions: rowActions) }, onFocusFirstItemRequested: { - focusFirstVisibleItem(in: focusSnapshots, focusHost: false) + focusFirstVisibleItem(in: snapshots, focusHost: false) }, onFocusChanged: { focused in let window = activeFeedWindow() @@ -122,11 +119,11 @@ struct FeedListView: View { ) -> some View { ScrollView(.vertical) { LazyVStack(spacing: 0) { - ForEach(Array(snapshots.enumerated()), id: \.element.id) { idx, snapshot in + ForEach(snapshots, id: \.id) { snapshot in rowSurface( snapshot: snapshot, actions: actions, - showsDivider: idx < snapshots.count - 1 + showsDivider: snapshot.id != snapshots.last?.id ) } } @@ -142,11 +139,11 @@ struct FeedListView: View { showsLoadMore: Bool ) -> some View { List { - ForEach(Array(groups.stable.enumerated()), id: \.element.id) { idx, snapshot in + ForEach(groups.stable, id: \.id) { snapshot in rowSurface( snapshot: snapshot, actions: actions, - showsDivider: idx < groups.stable.count - 1 + showsDivider: snapshot.id != groups.stable.last?.id ) .listRowInsets(EdgeInsets()) .listRowSeparator(.hidden) @@ -159,11 +156,11 @@ struct FeedListView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) } - ForEach(Array(groups.history.enumerated()), id: \.element.id) { idx, snapshot in + ForEach(groups.history, id: \.id) { snapshot in rowSurface( snapshot: snapshot, actions: actions, - showsDivider: idx < groups.history.count - 1 + showsDivider: snapshot.id != groups.history.last?.id ) .listRowInsets(EdgeInsets()) .listRowSeparator(.hidden) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 8af11bba5ddd..0e98bbfd3d45 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5630,7 +5630,10 @@ class TerminalController { waitTimeout: waitTimeout )) } - let result = bridge.wait() + let result = bridge.wait( + timeout: waitTimeout + 10, + fallback: .timedOut(itemId: nil) + ) CmuxEventBus.shared.publishWorkstreamEvent( event, phase: "completed", @@ -5753,7 +5756,7 @@ class TerminalController { decision: decision )) } - return bridge.wait() + return bridge.wait(timeout: 10, fallback: false) } // MARK: - V2 Browser Methods From dec2d9304a4f259f7bc336f2d9b115543c53ac7a Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:53:49 -0700 Subject: [PATCH 13/49] Remove duplicate workspace todo project reference --- cmux.xcodeproj/project.pbxproj | 1 - 1 file changed, 1 deletion(-) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 0df5919376ed..60f6a9d1f145 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -6047,7 +6047,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = AB12CD340000000000000002 /* WorkspaceTodoNotificationRegressionTests.swift */, DCE71A0F7E02A07A155241E4 /* SidebarWorkspaceRowStatusGlyphRemovalTests.swift */, 9C5C9824B98FFC44A2C44F47 /* WorkspaceTodoSnapshotTests.swift */, - E9F3A2B1C4D567890ABCDEF1 /* WorkspaceTodoNotificationRegressionTests.swift */, 9F007352E96022D7EFBE73C9 /* WorkspaceTodoSidebarModelTests.swift */, 5B0C00010000000000000004 /* SidebarResizerOcclusionResolverTests.swift */, 5B0C00010000000000000006 /* PortalDividerCursorOcclusionTests.swift */, From 6574773b71c406d2a6672ece2de7f95f76539788 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:03:55 -0700 Subject: [PATCH 14/49] Share Feed projections and cancel timed-out ingest --- Sources/Feed/FeedBlockingCallBridge.swift | 6 +- Sources/Feed/FeedCoordinator.swift | 62 +++++++++++++-- Sources/Feed/FeedItemRow.swift | 18 +++-- Sources/Feed/FeedPanelViewModel.swift | 80 +++----------------- Sources/Feed/FeedPresentationStore.swift | 92 +++++++++++++++++++++++ Sources/TerminalController.swift | 18 +++-- cmux.xcodeproj/project.pbxproj | 4 + cmuxTests/FeedCoordinatorTests.swift | 2 +- 8 files changed, 188 insertions(+), 94 deletions(-) create mode 100644 Sources/Feed/FeedPresentationStore.swift diff --git a/Sources/Feed/FeedBlockingCallBridge.swift b/Sources/Feed/FeedBlockingCallBridge.swift index 8cade2307a84..4198700da96e 100644 --- a/Sources/Feed/FeedBlockingCallBridge.swift +++ b/Sources/Feed/FeedBlockingCallBridge.swift @@ -19,12 +19,12 @@ final class FeedBlockingCallBridge: @unchecked Sendable { completion.leave() } - func wait(timeout: TimeInterval, fallback: @autoclosure () -> Value) -> Value { + func wait(timeout: TimeInterval) -> Value? { let boundedTimeout = timeout.isFinite ? max(timeout, 0) : 0 guard completion.wait(timeout: .now() + boundedTimeout) == .success else { - return fallback() + return nil } precondition(value != nil, "Feed blocking call completed without a result") - return value! + return value } } diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 6edd78e10db4..652a656a252e 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -19,11 +19,11 @@ import Foundation // state lives in `FeedBlockingWaiterRegistry` behind its documented lock. final class FeedCoordinator: @unchecked Sendable { static let shared = FeedCoordinator() - static let storeInstalledNotification = Notification.Name("cmux.feed.storeInstalled") // The store runs on the main actor. The coordinator is not isolated, // so it hops to main explicitly when touching the store. @MainActor private(set) var store: WorkstreamStore! + @MainActor private(set) lazy var presentationStore = FeedPresentationStore() private let waiterRegistry = FeedBlockingWaiterRegistry() let notificationHookCache = CmuxNotificationHookCache() @@ -62,7 +62,7 @@ final class FeedCoordinator: @unchecked Sendable { @MainActor func install(store: WorkstreamStore) { self.store = store - NotificationCenter.default.post(name: Self.storeInstalledNotification, object: self) + presentationStore.install(source: store) // Catch any pending items that were restored from disk whose // agent is already gone. After this, live tracking is // kqueue-driven — no polling. @@ -104,14 +104,17 @@ final class FeedCoordinator: @unchecked Sendable { event: WorkstreamEvent, waitTimeout: TimeInterval ) async -> IngestBlockingResult { + guard !Task.isCancelled else { return .timedOut(itemId: nil) } guard let requestId = event.requestId, waitTimeout > 0 else { - Task { @MainActor in - FeedCoordinator.shared.store.ingest(event) + let itemID = await MainActor.run { () -> UUID? in + guard !Task.isCancelled else { return nil } + self.store.ingest(event) if let ppid = event.ppid, ppid > 0 { - FeedCoordinator.shared.armPidWatcher(ppid: ppid) + self.armPidWatcher(ppid: ppid) } + return self.store.items.last?.id } - return .acknowledged(itemId: nil) + return Task.isCancelled ? .timedOut(itemId: itemID) : .acknowledged(itemId: itemID) } // Register the waiter before the store sees the event so a very @@ -119,6 +122,10 @@ final class FeedCoordinator: @unchecked Sendable { guard let decisions = await waiterRegistry.register(requestID: requestId) else { return .timedOut(itemId: nil) } + guard !Task.isCancelled else { + _ = await waiterRegistry.remove(requestID: requestId) + return .timedOut(itemId: nil) + } // Hop to main to actually insert the item + install the // kqueue watcher for the agent's PID. The watcher handler @@ -129,6 +136,7 @@ final class FeedCoordinator: @unchecked Sendable { ? resolveAttentionTarget(event: event) : nil let ingestResult: (itemID: UUID?, attentionTarget: AttentionTarget?) = await MainActor.run { + guard !Task.isCancelled else { return (nil, nil) } self.store.ingest(event) let itemID = self.store.items.last?.id if let ppid = event.ppid, ppid > 0 { @@ -140,11 +148,27 @@ final class FeedCoordinator: @unchecked Sendable { ) return (itemID, attentionTarget) } + guard !Task.isCancelled else { + await cancelIngest( + requestID: requestId, + itemID: ingestResult.itemID, + attentionTarget: ingestResult.attentionTarget + ) + return .timedOut(itemId: ingestResult.itemID) + } let registration = await waiterRegistry.recordIngest( itemID: ingestResult.itemID, attentionTarget: ingestResult.attentionTarget, requestID: requestId ) + guard !Task.isCancelled else { + await cancelIngest( + requestID: requestId, + itemID: ingestResult.itemID, + attentionTarget: ingestResult.attentionTarget + ) + return .timedOut(itemId: ingestResult.itemID) + } guard registration.registered else { await MainActor.run { self.concludeBlockingDecisionAttentionIfPresent(ingestResult.attentionTarget) @@ -187,6 +211,17 @@ final class FeedCoordinator: @unchecked Sendable { let waiter = await waiterRegistry.remove(requestID: requestId) + guard !Task.isCancelled else { + cancelNotification(requestId: requestId) + await MainActor.run { + self.concludeBlockingDecisionAttentionIfPresent(waiter?.attentionTarget) + if let itemID = waiter?.itemID { + self.store?.markExpired(itemID) + } + } + return .timedOut(itemId: waiter?.itemID) + } + if let decision = deliveredDecision ?? waiter?.decision { // A decision that wins at the timeout boundary remains terminal // even when Dispatch reports the timeout result first. @@ -199,6 +234,21 @@ final class FeedCoordinator: @unchecked Sendable { return .timedOut(itemId: waiter?.itemID) } + private func cancelIngest( + requestID: String, + itemID: UUID?, + attentionTarget: AttentionTarget? + ) async { + _ = await waiterRegistry.remove(requestID: requestID) + cancelNotification(requestId: requestID) + await MainActor.run { + self.concludeBlockingDecisionAttentionIfPresent(attentionTarget) + if let itemID { + self.store?.markExpired(itemID) + } + } + } + /// Concludes an attention overlay (if any) on the main actor, hopping if /// called from the socket worker thread. private func concludeAttentionOnMain(_ target: AttentionTarget?) { diff --git a/Sources/Feed/FeedItemRow.swift b/Sources/Feed/FeedItemRow.swift index 4b4e00eb8159..09c9ebcb7a72 100644 --- a/Sources/Feed/FeedItemRow.swift +++ b/Sources/Feed/FeedItemRow.swift @@ -119,12 +119,14 @@ struct FeedItemRow: View, Equatable { fg: sourceChipForeground, bg: sourceChipBackground ) - chip( - text: relativeTimeChip(snapshot.createdAt), - fg: .secondary, - bg: Color.primary.opacity(0.10), - mono: true - ) + TimelineView(.periodic(from: .now, by: 60)) { context in + chip( + text: relativeTimeChip(snapshot.createdAt, now: context.date), + fg: .secondary, + bg: Color.primary.opacity(0.10), + mono: true + ) + } } } } @@ -202,8 +204,8 @@ struct FeedItemRow: View, Equatable { return sourceChipForeground.opacity(0.18) } - private func relativeTimeChip(_ date: Date) -> String { - let interval = Date().timeIntervalSince(date) + private func relativeTimeChip(_ date: Date, now: Date) -> String { + let interval = now.timeIntervalSince(date) if interval < 60 { return String(localized: "feed.time.underMinute", defaultValue: "<1m") } diff --git a/Sources/Feed/FeedPanelViewModel.swift b/Sources/Feed/FeedPanelViewModel.swift index bd9a04b4bfe2..621efb2012db 100644 --- a/Sources/Feed/FeedPanelViewModel.swift +++ b/Sources/Feed/FeedPanelViewModel.swift @@ -4,7 +4,7 @@ import Foundation import Observation import SwiftUI -/// Projects the shared Workstream store into immutable Feed snapshots. +/// Mirrors the process-wide Feed projection into one panel's observable state. @MainActor @Observable final class FeedPanelViewModel { @@ -13,40 +13,24 @@ final class FeedPanelViewModel { private(set) var hasMorePersistedItems = false private(set) var isLoadingOlderItems = false - @ObservationIgnored private let coordinator: FeedCoordinator - @ObservationIgnored private var storeInstallTask: Task? + @ObservationIgnored private let presentationStore: FeedPresentationStore @ObservationIgnored private var loadOlderTask: Task? - init( - coordinator: FeedCoordinator = .shared, - notificationCenter: NotificationCenter = .default - ) { - self.coordinator = coordinator - storeInstallTask = Task { @MainActor [weak self, weak coordinator] in - guard let coordinator else { return } - self?.arm() - for await _ in notificationCenter.notifications( - named: FeedCoordinator.storeInstalledNotification, - object: coordinator - ) { - guard !Task.isCancelled else { return } - self?.arm() - } - } + init(coordinator: FeedCoordinator = .shared) { + self.presentationStore = coordinator.presentationStore + arm() } deinit { - storeInstallTask?.cancel() loadOlderTask?.cancel() } private func arm() { - guard let store = coordinator.store else { return } withObservationTracking { - items = store.items - presentation = Self.makePresentation(items: store.items) - hasMorePersistedItems = store.hasMorePersistedItems - isLoadingOlderItems = store.isLoadingOlderItems + items = presentationStore.items + presentation = presentationStore.presentation + hasMorePersistedItems = presentationStore.hasMorePersistedItems + isLoadingOlderItems = presentationStore.isLoadingOlderItems } onChange: { [weak self] in Task { @MainActor in self?.arm() @@ -54,54 +38,12 @@ final class FeedPanelViewModel { } } - private static func makePresentation(items: [WorkstreamItem]) -> FeedPresentationSnapshot { - var lastPromptByWorkstream: [String: String] = [:] - for item in items { - if case .userPrompt(let text) = item.payload, !text.isEmpty { - lastPromptByWorkstream[item.workstreamId] = text - } - } - - var actionable: [FeedItemSnapshot] = [] - var activityStable: [FeedItemSnapshot] = [] - var activityHistory: [FeedItemSnapshot] = [] - actionable.reserveCapacity(items.count) - activityStable.reserveCapacity(items.count) - activityHistory.reserveCapacity(items.count) - - for item in items.reversed() { - let snapshot = FeedItemSnapshot( - item: item, - userPromptEcho: lastPromptByWorkstream[item.workstreamId] - ) - if item.kind.isActionable { - actionable.append(snapshot) - } - guard item.kind.isActionable || item.kind == .todos || item.kind == .stop else { - continue - } - if snapshot.status.isPending || snapshot.kind == .stop { - activityStable.append(snapshot) - } else { - activityHistory.append(snapshot) - } - } - return FeedPresentationSnapshot( - actionable: actionable, - activity: FeedActivitySnapshotGroups( - stable: activityStable, - history: activityHistory - ) - ) - } - func loadOlderItems() { guard !isLoadingOlderItems, - hasMorePersistedItems, - let store = coordinator.store else { return } + hasMorePersistedItems else { return } loadOlderTask?.cancel() loadOlderTask = Task { @MainActor in - await store.loadOlderItems() + await presentationStore.loadOlderItems() } } } diff --git a/Sources/Feed/FeedPresentationStore.swift b/Sources/Feed/FeedPresentationStore.swift new file mode 100644 index 000000000000..fe73cdad5d74 --- /dev/null +++ b/Sources/Feed/FeedPresentationStore.swift @@ -0,0 +1,92 @@ +import CMUXAgentLaunch +import Observation + +/// Builds the process-wide Feed projection once per Workstream mutation. +@MainActor +@Observable +final class FeedPresentationStore { + private(set) var items: [WorkstreamItem] = [] + private(set) var presentation = FeedPresentationSnapshot.empty + private(set) var hasMorePersistedItems = false + private(set) var isLoadingOlderItems = false + + @ObservationIgnored private weak var source: WorkstreamStore? + @ObservationIgnored private var observationGeneration = 0 + + func install(source: WorkstreamStore) { + self.source = source + observationGeneration &+= 1 + armItemsObservation(generation: observationGeneration) + armPagingObservation(generation: observationGeneration) + } + + func loadOlderItems() async { + await source?.loadOlderItems() + } + + private func armItemsObservation(generation: Int) { + guard generation == observationGeneration, let source else { return } + withObservationTracking { + let sourceItems = source.items + items = sourceItems + presentation = Self.makePresentation(items: sourceItems) + } onChange: { [weak self] in + Task { @MainActor in + self?.armItemsObservation(generation: generation) + } + } + } + + private func armPagingObservation(generation: Int) { + guard generation == observationGeneration, let source else { return } + withObservationTracking { + hasMorePersistedItems = source.hasMorePersistedItems + isLoadingOlderItems = source.isLoadingOlderItems + } onChange: { [weak self] in + Task { @MainActor in + self?.armPagingObservation(generation: generation) + } + } + } + + private static func makePresentation(items: [WorkstreamItem]) -> FeedPresentationSnapshot { + var lastPromptByWorkstream: [String: String] = [:] + for item in items { + if case .userPrompt(let text) = item.payload, !text.isEmpty { + lastPromptByWorkstream[item.workstreamId] = text + } + } + + var actionable: [FeedItemSnapshot] = [] + var activityStable: [FeedItemSnapshot] = [] + var activityHistory: [FeedItemSnapshot] = [] + actionable.reserveCapacity(items.count) + activityStable.reserveCapacity(items.count) + activityHistory.reserveCapacity(items.count) + + for item in items.reversed() { + let snapshot = FeedItemSnapshot( + item: item, + userPromptEcho: lastPromptByWorkstream[item.workstreamId] + ) + if item.kind.isActionable { + actionable.append(snapshot) + } + guard item.kind.isActionable || item.kind == .todos || item.kind == .stop else { + continue + } + if snapshot.status.isPending || snapshot.kind == .stop { + activityStable.append(snapshot) + } else { + activityHistory.append(snapshot) + } + } + return FeedPresentationSnapshot( + actionable: actionable, + activity: FeedActivitySnapshotGroups( + stable: activityStable, + history: activityHistory + ) + ) + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0e98bbfd3d45..9281fe37eb5a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5624,16 +5624,16 @@ class TerminalController { Task { @MainActor in self.agentChatTranscriptService?.noteHookEvent(event) } let bridge = FeedBlockingCallBridge() - Task { + let ingestTask = Task { bridge.finish(with: await FeedCoordinator.shared.ingestBlocking( event: event, waitTimeout: waitTimeout )) } - let result = bridge.wait( - timeout: waitTimeout + 10, - fallback: .timedOut(itemId: nil) - ) + let result = bridge.wait(timeout: waitTimeout + 10) ?? { + ingestTask.cancel() + return .timedOut(itemId: nil) + }() CmuxEventBus.shared.publishWorkstreamEvent( event, phase: "completed", @@ -5750,13 +5750,17 @@ class TerminalController { decision: WorkstreamDecision ) -> Bool { let bridge = FeedBlockingCallBridge() - Task { + let replyTask = Task { bridge.finish(with: await FeedCoordinator.shared.deliverReply( requestId: requestId, decision: decision )) } - return bridge.wait(timeout: 10, fallback: false) + guard let delivered = bridge.wait(timeout: 10) else { + replyTask.cancel() + return false + } + return delivered } // MARK: - V2 Browser Methods diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 60f6a9d1f145..4fca2bc6c620 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -814,6 +814,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources FEED0000000000000000F013 /* FeedPermissionActionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */; }; FEE20000000000000000000E /* FeedPermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000E /* FeedPermissionView.swift */; }; FEE200000000000000000014 /* FeedPresentationSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000014 /* FeedPresentationSnapshot.swift */; }; + FEE200000000000000000016 /* FeedPresentationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000016 /* FeedPresentationStore.swift */; }; FEED0000000000000000F00B /* FeedPreviewWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00A /* FeedPreviewWindowController.swift */; }; FEE200000000000000000010 /* FeedQuestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000010 /* FeedQuestionView.swift */; }; FEED0000000000000000F009 /* FeedSidebarUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F008 /* FeedSidebarUITests.swift */; }; @@ -2866,6 +2867,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPermissionActionPolicy.swift; sourceTree = ""; }; FEE10000000000000000000E /* FeedPermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPermissionView.swift; sourceTree = ""; }; FEE100000000000000000014 /* FeedPresentationSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPresentationSnapshot.swift; sourceTree = ""; }; + FEE100000000000000000016 /* FeedPresentationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPresentationStore.swift; sourceTree = ""; }; FEED0000000000000000F00A /* FeedPreviewWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPreviewWindowController.swift; sourceTree = ""; }; FEE100000000000000000010 /* FeedQuestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedQuestionView.swift; sourceTree = ""; }; FEED0000000000000000F008 /* FeedSidebarUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSidebarUITests.swift; sourceTree = ""; }; @@ -6245,6 +6247,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEE100000000000000000012 /* FeedBlockingCallBridge.swift */, FEE100000000000000000013 /* FeedActivitySnapshotGroups.swift */, FEE100000000000000000014 /* FeedPresentationSnapshot.swift */, + FEE100000000000000000016 /* FeedPresentationStore.swift */, FEE100000000000000000015 /* FeedNotificationPolicySnapshot.swift */, FEE100000000000000000007 /* FeedJumpResolver.swift */, FEE100000000000000000008 /* FeedNotificationPolicyContext.swift */, @@ -7169,6 +7172,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEED0000000000000000F013 /* FeedPermissionActionPolicy.swift in Sources */, FEE20000000000000000000E /* FeedPermissionView.swift in Sources */, FEE200000000000000000014 /* FeedPresentationSnapshot.swift in Sources */, + FEE200000000000000000016 /* FeedPresentationStore.swift in Sources */, FEED0000000000000000F00B /* FeedPreviewWindowController.swift in Sources */, FEE200000000000000000010 /* FeedQuestionView.swift in Sources */, FEE200000000000000000009 /* FeedSocketEncoding.swift in Sources */, diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index eb58e81ee31b..d8a4d46a5ee2 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -13,7 +13,7 @@ struct FeedCoordinatorTests { @Test func blockingBridgeReturnsFallbackAtItsDeadline() { let bridge = FeedBlockingCallBridge() - #expect(bridge.wait(timeout: 0, fallback: false) == false) + #expect(bridge.wait(timeout: 0) == nil) } @MainActor From 611e3b95bda1e792dd3daa2bb64ecf2a085b7009 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:32:37 -0700 Subject: [PATCH 15/49] Refine Feed component boundaries --- Sources/Feed/FeedBlockingWaiterRegistry.swift | 13 +- Sources/Feed/FeedButton.swift | 589 ++++++++++++++++ Sources/Feed/FeedButtonKind.swift | 19 + Sources/Feed/FeedButtonSize.swift | 4 + Sources/Feed/FeedContextBlock.swift | 44 ++ .../Feed/FeedExitPlanAllowedPromptsView.swift | 50 ++ Sources/Feed/FeedExitPlanPlanFileView.swift | 27 + Sources/Feed/FeedExitPlanView.swift | 269 -------- Sources/Feed/FeedFlowLayout.swift | 54 ++ Sources/Feed/FeedFocusHostReference.swift | 6 + Sources/Feed/FeedInlineNativeTextView.swift | 94 +++ Sources/Feed/FeedInlinePassthroughLabel.swift | 11 + Sources/Feed/FeedInlineTextEditorView.swift | 172 +++++ Sources/Feed/FeedInlineTextField.swift | 119 ++++ .../Feed/FeedInlineTextFieldCoordinator.swift | 68 ++ Sources/Feed/FeedItemRow.swift | 72 -- Sources/Feed/FeedItemSnapshot.swift | 57 -- Sources/Feed/FeedJumpResolver.swift | 13 +- Sources/Feed/FeedJumpTarget.swift | 4 + Sources/Feed/FeedKeyboardFocus.swift | 191 +----- Sources/Feed/FeedKeyboardFocusBridge.swift | 61 ++ .../FeedKeyboardFocusBridgeCoordinator.swift | 27 + Sources/Feed/FeedKeyboardFocusResponder.swift | 1 + Sources/Feed/FeedLabeledTextRow.swift | 30 + Sources/Feed/FeedListView.swift | 10 - Sources/Feed/FeedMarkdownInlineText.swift | 42 ++ Sources/Feed/FeedPendingWaiter.swift | 9 + Sources/Feed/FeedPermissionView.swift | 648 ------------------ Sources/Feed/FeedPlacement.swift | 8 + Sources/Feed/FeedPlanBlock.swift | 6 + Sources/Feed/FeedPlanBodyView.swift | 161 +++++ Sources/Feed/FeedPlanNumberedItem.swift | 4 + Sources/Feed/FeedQuestionView.swift | 497 -------------- Sources/Feed/FeedResolvedDivider.swift | 28 + Sources/Feed/FeedRowActions.swift | 55 ++ Sources/Feed/FeedRowSurface.swift | 90 +++ .../FeedScopedKeyboardFocusResponder.swift | 5 + Sources/Feed/FeedScrollContentMargins.swift | 17 + Sources/Feed/FeedScrollRequest.swift | 6 + Sources/Feed/FeedStopDraft.swift | 14 + Sources/Feed/FeedTelemetryActionArea.swift | 55 ++ Sources/Feed/FeedTelemetryView.swift | 226 ------ Sources/Feed/FeedTodoListBody.swift | 161 +++++ Sources/Feed/FeedWrapHStack.swift | 23 + Sources/Feed/PermissionInputPreview.swift | 43 ++ Sources/MainWindowFocusTypes.swift | 13 - cmux.xcodeproj/project.pbxproj | 144 ++++ cmuxTests/FeedFocusRoutingTests.swift | 59 ++ .../ShortcutAndCommandPaletteTests.swift | 30 - 49 files changed, 2323 insertions(+), 2026 deletions(-) create mode 100644 Sources/Feed/FeedButton.swift create mode 100644 Sources/Feed/FeedButtonKind.swift create mode 100644 Sources/Feed/FeedButtonSize.swift create mode 100644 Sources/Feed/FeedContextBlock.swift create mode 100644 Sources/Feed/FeedExitPlanAllowedPromptsView.swift create mode 100644 Sources/Feed/FeedExitPlanPlanFileView.swift create mode 100644 Sources/Feed/FeedFlowLayout.swift create mode 100644 Sources/Feed/FeedFocusHostReference.swift create mode 100644 Sources/Feed/FeedInlineNativeTextView.swift create mode 100644 Sources/Feed/FeedInlinePassthroughLabel.swift create mode 100644 Sources/Feed/FeedInlineTextEditorView.swift create mode 100644 Sources/Feed/FeedInlineTextField.swift create mode 100644 Sources/Feed/FeedInlineTextFieldCoordinator.swift create mode 100644 Sources/Feed/FeedJumpTarget.swift create mode 100644 Sources/Feed/FeedKeyboardFocusBridge.swift create mode 100644 Sources/Feed/FeedKeyboardFocusBridgeCoordinator.swift create mode 100644 Sources/Feed/FeedKeyboardFocusResponder.swift create mode 100644 Sources/Feed/FeedLabeledTextRow.swift create mode 100644 Sources/Feed/FeedMarkdownInlineText.swift create mode 100644 Sources/Feed/FeedPendingWaiter.swift create mode 100644 Sources/Feed/FeedPlacement.swift create mode 100644 Sources/Feed/FeedPlanBlock.swift create mode 100644 Sources/Feed/FeedPlanBodyView.swift create mode 100644 Sources/Feed/FeedPlanNumberedItem.swift create mode 100644 Sources/Feed/FeedResolvedDivider.swift create mode 100644 Sources/Feed/FeedRowActions.swift create mode 100644 Sources/Feed/FeedRowSurface.swift create mode 100644 Sources/Feed/FeedScopedKeyboardFocusResponder.swift create mode 100644 Sources/Feed/FeedScrollContentMargins.swift create mode 100644 Sources/Feed/FeedScrollRequest.swift create mode 100644 Sources/Feed/FeedStopDraft.swift create mode 100644 Sources/Feed/FeedTelemetryActionArea.swift create mode 100644 Sources/Feed/FeedTodoListBody.swift create mode 100644 Sources/Feed/FeedWrapHStack.swift create mode 100644 Sources/Feed/PermissionInputPreview.swift create mode 100644 cmuxTests/FeedFocusRoutingTests.swift diff --git a/Sources/Feed/FeedBlockingWaiterRegistry.swift b/Sources/Feed/FeedBlockingWaiterRegistry.swift index fdfa159762de..8059804a1365 100644 --- a/Sources/Feed/FeedBlockingWaiterRegistry.swift +++ b/Sources/Feed/FeedBlockingWaiterRegistry.swift @@ -7,21 +7,14 @@ import Foundation /// first-writer-wins, and timeout removal finishes the stream so no suspended /// consumer or continuation outlives its socket request. actor FeedBlockingWaiterRegistry { - struct PendingWaiter { - let continuation: AsyncStream.Continuation - var decision: WorkstreamDecision? - var itemID: UUID? - var attentionTarget: FeedCoordinator.AttentionTarget? - } - - private var waiters: [String: PendingWaiter] = [:] + private var waiters: [String: FeedPendingWaiter] = [:] func register(requestID: String) -> AsyncStream? { guard waiters[requestID] == nil else { return nil } let (stream, continuation) = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(1) ) - waiters[requestID] = PendingWaiter( + waiters[requestID] = FeedPendingWaiter( continuation: continuation, decision: nil, itemID: nil, @@ -60,7 +53,7 @@ actor FeedBlockingWaiterRegistry { return (true, waiter.itemID, waiter.attentionTarget) } - func remove(requestID: String) -> PendingWaiter? { + func remove(requestID: String) -> FeedPendingWaiter? { let waiter = waiters.removeValue(forKey: requestID) waiter?.continuation.finish() return waiter diff --git a/Sources/Feed/FeedButton.swift b/Sources/Feed/FeedButton.swift new file mode 100644 index 000000000000..bb8fc0312555 --- /dev/null +++ b/Sources/Feed/FeedButton.swift @@ -0,0 +1,589 @@ +import CmuxFoundation +import Foundation +import SwiftUI + +struct FeedButton: View { + typealias Kind = FeedButtonKind + typealias Size = FeedButtonSize + + let label: String + var leadingIcon: String? = nil + var trailingIcon: String? = nil + var kind: Kind = .ghost + var size: Size = .compact + var fullWidth: Bool = false + var isSelected: Bool = false + var dimmed: Bool = false + let action: () -> Void + + @Environment(\.colorScheme) private var colorScheme + @State private var isHovered: Bool = false +#if DEBUG + @AppStorage(FeedButtonDebugSettings.generationKey) private var debugStyleGeneration = 0 +#endif + + var body: some View { +#if DEBUG + #if compiler(>=6.2) + if #available(macOS 26.0, *), usesSystemGlassButtonStyle { + systemGlassButton + } else { + plainFeedButton + } + #else + plainFeedButton + #endif +#else + plainFeedButton +#endif + } + + private var plainFeedButton: some View { + Button { + performAction() + } label: { + labelContent + .foregroundColor(foreground) + .frame(maxWidth: fullWidth ? .infinity : nil) + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) + .background(buttonBackground) + .overlay(buttonBorder) + .shadow( + color: buttonShadowColor, + radius: buttonShadowRadius, + x: 0, + y: buttonShadowY + ) + .opacity(dimmed ? 0.55 : 1.0) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(dimmed) + .onHover { hovering in + handleHover(hovering) + } + .help(label) + } + + private var labelContent: some View { + HStack(spacing: iconSpacing) { + if let leadingIcon { + Image(systemName: leadingIcon) + .cmuxFont(size: iconSize, weight: .semibold) + } + Text(label) + .cmuxFont(size: labelSize, weight: .semibold) + if let trailingIcon { + Image(systemName: trailingIcon) + .cmuxFont(size: iconSize, weight: .semibold) + } + } + } + + private var standardLabelContent: some View { + HStack(spacing: 4) { + if let leadingIcon { + Image(systemName: leadingIcon) + } + Text(label) + if let trailingIcon { + Image(systemName: trailingIcon) + } + } + .cmuxFont(size: labelSize, weight: .semibold) + } + + private func performAction() { + // `dimmed` doubles as the disabled signal — swallow the + // click at the primitive so upstream action closures don't + // have to re-check. + guard !dimmed else { return } + action() + } + + private func handleHover(_ hovering: Bool) { + isHovered = hovering + } + +#if DEBUG + private var usesSystemGlassButtonStyle: Bool { + _ = debugStyleGeneration + switch FeedButtonDebugSettings.visualStyle { + case .standardGlass, .standardTintedGlass, .nativeGlass, .nativeProminentGlass, .commandLight: + return true + case .solid, .glass, .liquid, .halo, .command, .outline, .flat: + return false + } + } + + #if compiler(>=6.2) + @available(macOS 26.0, *) + @ViewBuilder + private var systemGlassButton: some View { + if FeedButtonDebugSettings.visualStyle == .standardGlass { + standardSystemGlassButtonBase + .buttonStyle(.glass) + } else if FeedButtonDebugSettings.visualStyle == .standardTintedGlass { + standardSystemGlassButtonBase + .buttonStyle(.glass) + .tint(systemGlassTint) + } else if FeedButtonDebugSettings.visualStyle == .nativeProminentGlass { + systemGlassButtonBase + .buttonStyle(.glassProminent) + } else { + systemGlassButtonBase + .buttonStyle(.glass) + } + } + + @available(macOS 26.0, *) + private var standardSystemGlassButtonBase: some View { + Button { + performAction() + } label: { + standardLabelContent + .frame(maxWidth: fullWidth ? .infinity : nil) + } + .controlSize(size == .compact ? .small : .regular) + .disabled(dimmed) + .opacity(dimmed ? 0.55 : 1.0) + .onHover { hovering in + handleHover(hovering) + } + .help(label) + } + + @available(macOS 26.0, *) + private var systemGlassButtonBase: some View { + Button { + performAction() + } label: { + labelContent + .foregroundStyle(systemGlassForeground) + .frame(maxWidth: fullWidth ? .infinity : nil) + .padding(.horizontal, max(CGFloat(0), horizontalPadding - 2)) + .padding(.vertical, max(CGFloat(0), verticalPadding - 1)) + .contentShape( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + ) + } + .buttonBorderShape(.roundedRectangle(radius: cornerRadius)) + .controlSize(size == .compact ? .small : .regular) + .tint(systemGlassTint) + .disabled(dimmed) + .opacity(dimmed ? 0.55 : 1.0) + .onHover { hovering in + handleHover(hovering) + } + .help(label) + } + #endif + + private var systemGlassTint: Color { + glassEffectTint.opacity(FeedButtonDebugSettings.glassTintOpacity) + } + + private var systemGlassForeground: Color { + if let color = FeedButtonDebugSettings.color( + for: kind, + role: .foreground, + colorScheme: colorScheme + ) { + return color + } + + switch FeedButtonDebugSettings.visualStyle { + case .nativeProminentGlass: + return kind == .light ? .black : .white + case .nativeGlass: + return .primary + case .standardGlass, .standardTintedGlass, .solid, .glass, .liquid, .halo, .command, .commandLight, .outline, .flat: + return foreground + } + } +#endif + + // MARK: - Style resolution + + private var labelSize: CGFloat { size == .compact ? 11 : 12 } + private var iconSize: CGFloat { size == .compact ? 10 : 11 } + private var iconSpacing: CGFloat { size == .compact ? 3 : 5 } + private var cornerRadius: CGFloat { +#if DEBUG + _ = debugStyleGeneration + return size == .compact + ? CGFloat(FeedButtonDebugSettings.compactCornerRadius) + : CGFloat(FeedButtonDebugSettings.mediumCornerRadius) +#else + return size == .compact ? 5 : 6 +#endif + } + private var horizontalPadding: CGFloat { +#if DEBUG + _ = debugStyleGeneration + return size == .compact + ? CGFloat(FeedButtonDebugSettings.compactHorizontalPadding) + : CGFloat(FeedButtonDebugSettings.mediumHorizontalPadding) +#else + return size == .compact ? 8 : 12 +#endif + } + private var verticalPadding: CGFloat { +#if DEBUG + _ = debugStyleGeneration + return size == .compact + ? CGFloat(FeedButtonDebugSettings.compactVerticalPadding) + : CGFloat(FeedButtonDebugSettings.mediumVerticalPadding) +#else + return size == .compact ? 5 : 7 +#endif + } + + private var foreground: Color { +#if DEBUG + _ = debugStyleGeneration + if let color = FeedButtonDebugSettings.color( + for: kind, + role: .foreground, + colorScheme: colorScheme + ) { + return color + } +#endif + switch kind { + case .ghost: + return isSelected ? .primary : .primary.opacity(0.85) + case .soft: return .primary + case .dark: return .white + case .light: return .black + case .primary: return .white + case .success: return .white + case .warning: return .white + case .destructive: return .white + } + } + + private var backgroundFill: Color { +#if DEBUG + _ = debugStyleGeneration + if let color = FeedButtonDebugSettings.color( + for: kind, + role: isHovered ? .hoverBackground : .background, + colorScheme: colorScheme + ) { + return color + } +#endif + switch kind { + case .ghost: + if isSelected { return Color.primary.opacity(0.12) } + if isHovered { return Color.primary.opacity(0.06) } + return Color.clear + case .soft: + return isHovered ? Color.primary.opacity(0.16) : Color.primary.opacity(0.10) + case .dark: + return isHovered ? Color.black.opacity(0.85) : Color.black.opacity(0.75) + case .light: + return isHovered ? Color.white.opacity(0.96) : Color.white.opacity(0.88) + case .primary: + return isHovered + ? Color(red: 0.28, green: 0.55, blue: 0.95) + : Color(red: 0.24, green: 0.48, blue: 0.88) + case .success: + return isHovered + ? Color(red: 0.22, green: 0.72, blue: 0.42) + : Color(red: 0.18, green: 0.62, blue: 0.35) + case .warning: + return isHovered + ? Color(red: 0.95, green: 0.55, blue: 0.18) + : Color(red: 0.92, green: 0.54, blue: 0.29) + case .destructive: + return isHovered + ? Color(red: 0.85, green: 0.28, blue: 0.28) + : Color(red: 0.75, green: 0.22, blue: 0.22) + } + } + +#if DEBUG + private var glassEffectTint: Color { + _ = debugStyleGeneration + if let color = FeedButtonDebugSettings.color( + for: kind, + role: isHovered ? .hoverBackground : .background, + colorScheme: colorScheme + ) { + return color + } + + switch kind { + case .ghost: return Color.accentColor + case .soft: return Color.gray + case .dark: return Color.black + case .light: return Color.white + case .primary: return Color(red: 0.24, green: 0.48, blue: 0.88) + case .success: return Color(red: 0.18, green: 0.62, blue: 0.35) + case .warning: return Color(red: 0.92, green: 0.54, blue: 0.29) + case .destructive: return Color(red: 0.75, green: 0.22, blue: 0.22) + } + } +#endif + + @ViewBuilder + private var buttonBackground: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) +#if DEBUG + let generation = debugStyleGeneration + switch generation >= 0 ? FeedButtonDebugSettings.visualStyle : .solid { + case .solid: + shape.fill(backgroundFill) + case .standardGlass: + shape.fill(.regularMaterial) + case .standardTintedGlass: + shape + .fill(.regularMaterial) + .overlay( + shape.fill( + backgroundFill.opacity( + FeedButtonDebugSettings.glassTintOpacity + ) + ) + ) + case .glass: + shape + .fill(.thinMaterial) + .overlay( + shape.fill( + backgroundFill.opacity( + FeedButtonDebugSettings.glassTintOpacity + ) + ) + ) + case .nativeGlass: + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + shape + .fill(Color.clear) + .glassEffect( + .regular + .tint(glassEffectTint.opacity(FeedButtonDebugSettings.glassTintOpacity)) + .interactive(!dimmed), + in: shape + ) + } else { + shape + .fill(.ultraThinMaterial) + .overlay(shape.fill(backgroundFill.opacity(0.20))) + } + #else + shape + .fill(.ultraThinMaterial) + .overlay(shape.fill(backgroundFill.opacity(0.20))) + #endif + case .nativeProminentGlass: + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + shape + .fill(Color.clear) + .glassEffect( + .regular + .tint(glassEffectTint.opacity(FeedButtonDebugSettings.glassTintOpacity)) + .interactive(!dimmed), + in: shape + ) + .overlay( + shape.fill( + backgroundFill.opacity(isHovered || isSelected ? 0.30 : 0.18) + ) + ) + } else { + shape + .fill(.regularMaterial) + .overlay(shape.fill(backgroundFill.opacity(0.26))) + } + #else + shape + .fill(.regularMaterial) + .overlay(shape.fill(backgroundFill.opacity(0.26))) + #endif + case .liquid: + shape + .fill(.ultraThinMaterial) + .overlay( + shape.fill( + backgroundFill.opacity( + FeedButtonDebugSettings.glassTintOpacity + ) + ) + ) + .overlay( + shape.fill( + LinearGradient( + colors: [ + Color.white.opacity(isHovered || isSelected ? 0.42 : 0.30), + Color.white.opacity(0.08), + Color.clear, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .blendMode(.screen) + ) + case .halo: + shape + .fill(.thinMaterial) + .overlay( + shape.fill( + backgroundFill.opacity( + FeedButtonDebugSettings.glassTintOpacity + ) + ) + ) + .overlay( + shape.fill( + RadialGradient( + colors: [ + Color.white.opacity(isHovered || isSelected ? 0.30 : 0.18), + Color.clear, + ], + center: .topLeading, + startRadius: 1, + endRadius: 54 + ) + ) + .blendMode(.screen) + ) + case .command: + shape + .fill(.regularMaterial) + .overlay(shape.fill(Color.black.opacity(0.28))) + .overlay( + shape.fill( + backgroundFill.opacity( + FeedButtonDebugSettings.glassTintOpacity + ) + ) + ) + case .commandLight: + shape + .fill(.regularMaterial) + .overlay(shape.fill(Color.white.opacity(0.22))) + .overlay( + shape.fill( + backgroundFill.opacity( + FeedButtonDebugSettings.glassTintOpacity + ) + ) + ) + case .outline: + shape.fill(isHovered || isSelected ? backgroundFill.opacity(0.14) : Color.clear) + case .flat: + shape.fill(isHovered || isSelected ? backgroundFill.opacity(0.12) : Color.clear) + } +#else + shape.fill(backgroundFill) +#endif + } + + @ViewBuilder + private var buttonBorder: some View { +#if DEBUG + let generation = debugStyleGeneration + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + switch generation >= 0 ? FeedButtonDebugSettings.visualStyle : .solid { + case .solid: + EmptyView() + case .standardGlass: + shape.stroke(Color.white.opacity(0.12), lineWidth: FeedButtonDebugSettings.borderWidth) + case .standardTintedGlass: + shape.stroke(backgroundFill.opacity(0.22), lineWidth: FeedButtonDebugSettings.borderWidth) + case .glass: + shape.stroke(Color.white.opacity(0.16), lineWidth: 0.75) + case .nativeGlass: + shape.stroke(Color.white.opacity(0.14), lineWidth: FeedButtonDebugSettings.borderWidth) + case .nativeProminentGlass: + shape.stroke(Color.white.opacity(0.18), lineWidth: FeedButtonDebugSettings.borderWidth) + case .liquid: + shape.stroke( + LinearGradient( + colors: [ + Color.white.opacity(0.42), + backgroundFill.opacity(0.28), + Color.white.opacity(0.10), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: FeedButtonDebugSettings.borderWidth + ) + case .halo: + shape.stroke( + backgroundFill.opacity(isHovered || isSelected ? 0.55 : 0.34), + lineWidth: FeedButtonDebugSettings.borderWidth + ) + case .command: + shape.stroke(Color.white.opacity(0.12), lineWidth: FeedButtonDebugSettings.borderWidth) + case .commandLight: + shape.stroke(Color.black.opacity(0.12), lineWidth: FeedButtonDebugSettings.borderWidth) + case .outline: + shape.stroke(backgroundFill.opacity(0.75), lineWidth: FeedButtonDebugSettings.borderWidth) + case .flat: + EmptyView() + } +#else + EmptyView() +#endif + } + + private var buttonShadowColor: Color { +#if DEBUG + _ = debugStyleGeneration + switch FeedButtonDebugSettings.visualStyle { + case .halo: + return backgroundFill.opacity(isHovered || isSelected ? 0.44 : 0.24) + case .liquid: + return backgroundFill.opacity(isHovered || isSelected ? 0.18 : 0.10) + case .command: + return Color.black.opacity(0.28) + case .commandLight: + return Color.black.opacity(isHovered || isSelected ? 0.16 : 0.08) + case .nativeProminentGlass: + return backgroundFill.opacity(isHovered || isSelected ? 0.18 : 0.10) + case .standardGlass, .standardTintedGlass, .solid, .glass, .nativeGlass, .outline, .flat: + return Color.clear + } +#else + return Color.clear +#endif + } + + private var buttonShadowRadius: CGFloat { +#if DEBUG + _ = debugStyleGeneration + switch FeedButtonDebugSettings.visualStyle { + case .halo: return isHovered || isSelected ? 9 : 6 + case .liquid: return isHovered || isSelected ? 5 : 3 + case .nativeProminentGlass: return isHovered || isSelected ? 5 : 3 + case .command: return 3 + case .commandLight: return isHovered || isSelected ? 4 : 2 + case .standardGlass, .standardTintedGlass, .solid, .glass, .nativeGlass, .outline, .flat: return 0 + } +#else + return 0 +#endif + } + + private var buttonShadowY: CGFloat { +#if DEBUG + _ = debugStyleGeneration + switch FeedButtonDebugSettings.visualStyle { + case .halo: return 2 + case .liquid, .nativeProminentGlass, .command, .commandLight: return 1 + case .standardGlass, .standardTintedGlass, .solid, .glass, .nativeGlass, .outline, .flat: return 0 + } +#else + return 0 +#endif + } +} diff --git a/Sources/Feed/FeedButtonKind.swift b/Sources/Feed/FeedButtonKind.swift new file mode 100644 index 000000000000..3d1456169428 --- /dev/null +++ b/Sources/Feed/FeedButtonKind.swift @@ -0,0 +1,19 @@ +enum FeedButtonKind: String { + /// Transparent pill that lights up on hover/selection. Used + /// for filter bar pills and single-select option pills. + case ghost + /// Soft neutral fill (e.g. Manual, disabled Submit). + case soft + /// Dark background with white text (Deny). + case dark + /// Light background with black text (Allow Once). + case light + /// Solid blue (Always Allow, Send feedback, active Submit). + case primary + /// Solid green (Auto, checked multi-select option, confirmations). + case success + /// Solid orange (warning actions). + case warning + /// Solid red (destructive deny). + case destructive +} diff --git a/Sources/Feed/FeedButtonSize.swift b/Sources/Feed/FeedButtonSize.swift new file mode 100644 index 000000000000..924e08cc3a2a --- /dev/null +++ b/Sources/Feed/FeedButtonSize.swift @@ -0,0 +1,4 @@ +enum FeedButtonSize { + case compact // filter bar / option pills + case medium // full-width CTAs +} diff --git a/Sources/Feed/FeedContextBlock.swift b/Sources/Feed/FeedContextBlock.swift new file mode 100644 index 000000000000..08f5bf27547d --- /dev/null +++ b/Sources/Feed/FeedContextBlock.swift @@ -0,0 +1,44 @@ +import CMUXAgentLaunch +import SwiftUI + +struct FeedContextBlock: View { + let context: WorkstreamContext + let source: WorkstreamSource + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if let user = context.lastUserMessage { + FeedLabeledTextRow( + label: String(localized: "feed.context.you", defaultValue: "You:"), + text: user, + labelColor: .secondary, + textColor: .secondary + ) + } + if let preamble = context.assistantPreamble { + FeedLabeledTextRow( + label: agentLabel, + text: preamble, + labelColor: .secondary, + textColor: .secondary, + rendersMarkdown: source == .claude + ) + } + if let plan = context.planSummary { + FeedLabeledTextRow( + label: String(localized: "feed.context.plan", defaultValue: "Plan:"), + text: plan, + labelColor: Color.purple.opacity(0.85), + textColor: .secondary, + rendersMarkdown: source == .claude + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var agentLabel: String { + "\(source.rawValue.capitalized):" + } +} + diff --git a/Sources/Feed/FeedExitPlanAllowedPromptsView.swift b/Sources/Feed/FeedExitPlanAllowedPromptsView.swift new file mode 100644 index 000000000000..d66017490bfd --- /dev/null +++ b/Sources/Feed/FeedExitPlanAllowedPromptsView.swift @@ -0,0 +1,50 @@ +import AppKit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +struct ExitPlanAllowedPromptsView: View { + let prompts: [WorkstreamAllowedPrompt] + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 5) { + Image(systemName: "checklist") + .cmuxFont(size: 10, weight: .medium) + .foregroundColor(Color.purple.opacity(0.85)) + Text(String(localized: "feed.exitplan.allowedPrompts", defaultValue: "Allowed prompts")) + .cmuxFont(size: 11, weight: .semibold) + .foregroundColor(Color.purple.opacity(0.9)) + } + VStack(alignment: .leading, spacing: 5) { + ForEach(Array(prompts.enumerated()), id: \.offset) { _, prompt in + HStack(alignment: .firstTextBaseline, spacing: 6) { + if !prompt.tool.isEmpty { + Text(prompt.tool) + .cmuxFont(size: 10, weight: .semibold) + .foregroundColor(.purple) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.purple.opacity(0.14)) + ) + } + Text(prompt.prompt) + .cmuxFont(size: 11) + .foregroundColor(.primary.opacity(0.86)) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.purple.opacity(0.06)) + ) + } + } +} + diff --git a/Sources/Feed/FeedExitPlanPlanFileView.swift b/Sources/Feed/FeedExitPlanPlanFileView.swift new file mode 100644 index 000000000000..7f06df47354c --- /dev/null +++ b/Sources/Feed/FeedExitPlanPlanFileView.swift @@ -0,0 +1,27 @@ +import AppKit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +struct ExitPlanPlanFileView: View { + let path: String + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: "doc.text") + .cmuxFont(size: 10, weight: .medium) + .foregroundColor(.secondary) + Text(String(localized: "feed.exitplan.planFile", defaultValue: "Plan file")) + .cmuxFont(size: 10, weight: .semibold) + .foregroundColor(.secondary) + Text((path as NSString).lastPathComponent) + .cmuxFont(size: 10, design: .monospaced) + .foregroundColor(.secondary.opacity(0.85)) + .lineLimit(1) + .truncationMode(.middle) + .help(path) + } + } +} + diff --git a/Sources/Feed/FeedExitPlanView.swift b/Sources/Feed/FeedExitPlanView.swift index 70880ddfc1cc..1e5c2ad8b201 100644 --- a/Sources/Feed/FeedExitPlanView.swift +++ b/Sources/Feed/FeedExitPlanView.swift @@ -143,272 +143,3 @@ struct ExitPlanActionArea: View { } } } - -private struct ExitPlanAllowedPromptsView: View { - let prompts: [WorkstreamAllowedPrompt] - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 5) { - Image(systemName: "checklist") - .cmuxFont(size: 10, weight: .medium) - .foregroundColor(Color.purple.opacity(0.85)) - Text(String(localized: "feed.exitplan.allowedPrompts", defaultValue: "Allowed prompts")) - .cmuxFont(size: 11, weight: .semibold) - .foregroundColor(Color.purple.opacity(0.9)) - } - VStack(alignment: .leading, spacing: 5) { - ForEach(Array(prompts.enumerated()), id: \.offset) { _, prompt in - HStack(alignment: .firstTextBaseline, spacing: 6) { - if !prompt.tool.isEmpty { - Text(prompt.tool) - .cmuxFont(size: 10, weight: .semibold) - .foregroundColor(.purple) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color.purple.opacity(0.14)) - ) - } - Text(prompt.prompt) - .cmuxFont(size: 11) - .foregroundColor(.primary.opacity(0.86)) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.purple.opacity(0.06)) - ) - } - } -} - -private struct ExitPlanPlanFileView: View { - let path: String - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: "doc.text") - .cmuxFont(size: 10, weight: .medium) - .foregroundColor(.secondary) - Text(String(localized: "feed.exitplan.planFile", defaultValue: "Plan file")) - .cmuxFont(size: 10, weight: .semibold) - .foregroundColor(.secondary) - Text((path as NSString).lastPathComponent) - .cmuxFont(size: 10, design: .monospaced) - .foregroundColor(.secondary.opacity(0.85)) - .lineLimit(1) - .truncationMode(.middle) - .help(path) - } - } -} - -struct FeedMarkdownInlineText: View { - let text: String - let fontSize: CGFloat - let weight: Font.Weight? - let foregroundColor: Color - - init( - text: String, - fontSize: CGFloat, - weight: Font.Weight? = nil, - foregroundColor: Color - ) { - self.text = text - self.fontSize = fontSize - self.weight = weight - self.foregroundColor = foregroundColor - } - - var body: some View { - let parsed = (try? AttributedString( - markdown: text, - options: AttributedString.MarkdownParsingOptions( - interpretedSyntax: .inlineOnlyPreservingWhitespace - ) - )) ?? AttributedString(text) - Text(parsed) - .cmuxFont(size: fontSize, weight: weight ?? .regular) - .foregroundColor(foregroundColor) - .fixedSize(horizontal: false, vertical: true) - } -} - -/// Renders plan text as a stack of small structured sections. Block -/// headings, lists, and paragraphs keep the Feed's compact rhythm, while -/// Claude markdown inside each line gets parsed tastefully. Heading text -/// intentionally stays at body scale. -private struct PlanBodyView: View { - let plan: String - let rendersMarkdown: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in - switch block { - case .heading(let text): - markdownText( - text, - weight: .semibold, - color: .primary.opacity(0.95) - ) - .padding(.top, 2) - case .paragraph(let text): - markdownText(text, color: .primary.opacity(0.85)) - case .numbered(let items): - VStack(alignment: .leading, spacing: 2) { - ForEach(Array(items.enumerated()), id: \.offset) { _, item in - HStack(alignment: .top, spacing: 5) { - Text("\(item.index).") - .cmuxFont(size: 11, weight: .medium, monospacedDigit: true) - .foregroundColor(.secondary) - markdownText(item.text, color: .primary.opacity(0.85)) - } - } - } - case .bulleted(let items): - VStack(alignment: .leading, spacing: 2) { - ForEach(Array(items.enumerated()), id: \.offset) { _, item in - HStack(alignment: .top, spacing: 8) { - Circle() - .fill(Color.blue.opacity(0.85)) - .frame(width: 3.5, height: 3.5) - .padding(.top, 5.5) - .frame(width: 10, alignment: .center) - markdownText(item, color: .primary.opacity(0.85)) - } - } - } - } - } - } - } - - @ViewBuilder - private func markdownText( - _ text: String, - weight: Font.Weight? = nil, - color: Color - ) -> some View { - if rendersMarkdown { - FeedMarkdownInlineText( - text: text, - fontSize: 11, - weight: weight, - foregroundColor: color - ) - } else { - Text(text) - .cmuxFont(size: 11, weight: weight ?? .regular) - .foregroundColor(color) - .fixedSize(horizontal: false, vertical: true) - } - } - - private enum Block { - case heading(String) - case paragraph(String) - case numbered([NumberedItem]) - case bulleted([String]) - } - - private struct NumberedItem { - let index: Int - let text: String - } - - private var blocks: [Block] { - var out: [Block] = [] - var buffer: [String] = [] - func flushParagraph() { - guard !buffer.isEmpty else { return } - let joined = buffer.joined(separator: " ") - out.append(.paragraph(joined)) - buffer = [] - } - var numbered: [NumberedItem] = [] - func flushNumbered() { - if !numbered.isEmpty { - out.append(.numbered(numbered)) - numbered = [] - } - } - var bulleted: [String] = [] - func flushBulleted() { - if !bulleted.isEmpty { - out.append(.bulleted(bulleted)) - bulleted = [] - } - } - - for rawLine in plan.split(separator: "\n", omittingEmptySubsequences: false) { - let line = String(rawLine).trimmingCharacters(in: .whitespaces) - if line.isEmpty { - flushParagraph(); flushNumbered(); flushBulleted() - continue - } - // **Bold heading** or ## heading or "Word:" on its own line - if line.hasPrefix("**") && line.hasSuffix("**") && line.count > 4 { - flushParagraph(); flushNumbered(); flushBulleted() - out.append(.heading(String(line.dropFirst(2).dropLast(2)))) - continue - } - if let heading = markdownHeadingText(line) { - flushParagraph(); flushNumbered(); flushBulleted() - out.append(.heading(heading)) - continue - } - if line.hasSuffix(":") && line.count <= 40 - && line.split(separator: " ").count <= 4 - { - flushParagraph(); flushNumbered(); flushBulleted() - out.append(.heading(line)) - continue - } - // Numbered list - if let match = line.range( - of: #"^(\d+)\.\s+(.+)$"#, - options: .regularExpression - ) { - flushParagraph(); flushBulleted() - let text = String(line[match]) - if let dotIdx = text.firstIndex(of: ".") { - let numStr = String(text[text.startIndex.. String? { - guard line.hasPrefix("#") else { return nil } - let hashCount = line.prefix(while: { $0 == "#" }).count - guard (1...6).contains(hashCount), - line.count > hashCount, - line[line.index(line.startIndex, offsetBy: hashCount)] == " " - else { return nil } - return String(line.dropFirst(hashCount + 1)) - } -} diff --git a/Sources/Feed/FeedFlowLayout.swift b/Sources/Feed/FeedFlowLayout.swift new file mode 100644 index 000000000000..6074813fc569 --- /dev/null +++ b/Sources/Feed/FeedFlowLayout.swift @@ -0,0 +1,54 @@ +import AppKit +import Bonsplit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +struct FlowLayout: Layout { + var spacing: CGFloat = 4 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var currentX: CGFloat = 0 + var currentRowHeight: CGFloat = 0 + var totalHeight: CGFloat = 0 + var totalWidth: CGFloat = 0 + for sub in subviews { + let size = sub.sizeThatFits(.unspecified) + if currentX + size.width > maxWidth && currentX > 0 { + totalHeight += currentRowHeight + spacing + totalWidth = max(totalWidth, currentX - spacing) + currentX = 0 + currentRowHeight = 0 + } + currentX += size.width + spacing + currentRowHeight = max(currentRowHeight, size.height) + } + totalHeight += currentRowHeight + totalWidth = max(totalWidth, currentX - spacing) + return CGSize(width: totalWidth, height: totalHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x: CGFloat = bounds.minX + var y: CGFloat = bounds.minY + var rowHeight: CGFloat = 0 + for sub in subviews { + let size = sub.sizeThatFits(.unspecified) + if x + size.width > bounds.maxX && x > bounds.minX { + x = bounds.minX + y += rowHeight + spacing + rowHeight = 0 + } + sub.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} + +/// Renders a Stop event (Claude finished a turn and is waiting for +/// the next user prompt). Shows a text field + Send button that +/// types the reply into the agent's terminal surface and presses +/// Return — so the user can reply without switching focus. diff --git a/Sources/Feed/FeedFocusHostReference.swift b/Sources/Feed/FeedFocusHostReference.swift new file mode 100644 index 000000000000..21042a22b38d --- /dev/null +++ b/Sources/Feed/FeedFocusHostReference.swift @@ -0,0 +1,6 @@ +import Foundation + +@MainActor +final class FeedFocusHostReference { + weak var host: FeedKeyboardFocusView? +} diff --git a/Sources/Feed/FeedInlineNativeTextView.swift b/Sources/Feed/FeedInlineNativeTextView.swift new file mode 100644 index 000000000000..62b4a8f31d5e --- /dev/null +++ b/Sources/Feed/FeedInlineNativeTextView.swift @@ -0,0 +1,94 @@ +import AppKit +import Bonsplit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +final class FeedInlineNativeTextView: NSTextView, FeedScopedKeyboardFocusResponder { + private static weak var activeEditor: FeedInlineNativeTextView? + + var onActivate: (() -> Void)? + var onEscape: (() -> Void)? + var onSubmit: (() -> Void)? + var feedFocusScopeID = UUID() + + static func blurActiveEditor(in hostWindow: NSWindow?) { + guard let activeEditor else { return } + guard let window = activeEditor.window else { + if Self.activeEditor === activeEditor { + Self.activeEditor = nil + } + return + } + guard window === hostWindow else { return } + guard window.firstResponder === activeEditor else { + if Self.activeEditor === activeEditor { + Self.activeEditor = nil + } + return + } +#if DEBUG + dlog("feed.editor.blurActive fr=\(feedDebugResponderSummary(window.firstResponder))") +#endif + window.makeFirstResponder(nil) + } + + override func mouseDown(with event: NSEvent) { +#if DEBUG + dlog("feed.editor.mouseDown frBefore=\(feedDebugResponderSummary(window?.firstResponder))") +#endif + onActivate?() + super.mouseDown(with: event) + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if event.type == .keyDown, event.keyCode == 53 { +#if DEBUG + dlog("feed.editor.escape fr=\(feedDebugResponderSummary(window?.firstResponder))") +#endif + onEscape?() + return true + } + return super.performKeyEquivalent(with: event) + } + + override func keyDown(with event: NSEvent) { + let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let shouldSubmit = (event.keyCode == 36 || event.keyCode == 76) + && normalizedFlags.intersection([.shift, .option, .command, .control]).isEmpty + if shouldSubmit, !hasMarkedText(), let onSubmit { + onSubmit() + return + } + super.keyDown(with: event) + } + + override func resetCursorRects() { + super.resetCursorRects() + addCursorRect(bounds, cursor: .iBeam) + } + + override func becomeFirstResponder() -> Bool { + let didBecomeFirstResponder = super.becomeFirstResponder() + if didBecomeFirstResponder { + Self.activeEditor = self + onActivate?() + } +#if DEBUG + dlog("feed.editor.become result=\(didBecomeFirstResponder ? 1 : 0) fr=\(feedDebugResponderSummary(window?.firstResponder))") +#endif + return didBecomeFirstResponder + } + + override func resignFirstResponder() -> Bool { + let didResignFirstResponder = super.resignFirstResponder() + if didResignFirstResponder, Self.activeEditor === self { + Self.activeEditor = nil + } +#if DEBUG + dlog("feed.editor.resign result=\(didResignFirstResponder ? 1 : 0) fr=\(feedDebugResponderSummary(window?.firstResponder))") +#endif + return didResignFirstResponder + } +} diff --git a/Sources/Feed/FeedInlinePassthroughLabel.swift b/Sources/Feed/FeedInlinePassthroughLabel.swift new file mode 100644 index 000000000000..5db3682b4f00 --- /dev/null +++ b/Sources/Feed/FeedInlinePassthroughLabel.swift @@ -0,0 +1,11 @@ +import AppKit +import Bonsplit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +final class FeedInlinePassthroughLabel: NSTextField { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + diff --git a/Sources/Feed/FeedInlineTextEditorView.swift b/Sources/Feed/FeedInlineTextEditorView.swift new file mode 100644 index 000000000000..600691644d55 --- /dev/null +++ b/Sources/Feed/FeedInlineTextEditorView.swift @@ -0,0 +1,172 @@ +import AppKit +import Bonsplit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +final class FeedInlineTextEditorView: NSView { + private static let textInset = NSSize(width: 0, height: 1) + + let textView = FeedInlineNativeTextView(frame: .zero) + private let placeholderField = FeedInlinePassthroughLabel(labelWithString: "") + private var currentFont = GlobalFontMagnification.systemFont(ofSize: 11) + + static func minimumHeight(for font: NSFont) -> CGFloat { + ceil(font.ascender - font.descender + font.leading) + textInset.height * 2 + } + + var placeholder: String = "" { + didSet { + guard placeholder != oldValue else { return } + placeholderField.stringValue = placeholder + updatePlaceholderVisibility() + } + } + + var isEnabled: Bool = true { + didSet { + guard isEnabled != oldValue else { return } + textView.isEditable = isEnabled + textView.isSelectable = isEnabled + textView.textColor = isEnabled ? .labelColor : .disabledControlTextColor + textView.insertionPointColor = .controlAccentColor + } + } + + override var isFlipped: Bool { true } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setContentHuggingPriority(.defaultLow, for: .horizontal) + setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + textView.isEditable = true + textView.isSelectable = true + textView.isRichText = false + textView.importsGraphics = false + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.backgroundColor = .clear + textView.drawsBackground = false + textView.textContainerInset = Self.textInset + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.heightTracksTextView = false + textView.minSize = .zero + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + addSubview(textView) + + placeholderField.textColor = .placeholderTextColor + placeholderField.lineBreakMode = .byWordWrapping + placeholderField.maximumNumberOfLines = 0 + addSubview(placeholderField) + + apply(font: currentFont, isEnabled: true) + updatePlaceholderVisibility() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: fittingHeight()) + } + + override func mouseDown(with event: NSEvent) { + _ = window?.makeFirstResponder(textView) + super.mouseDown(with: event) + } + + override func layout() { + super.layout() + let availableWidth = max(bounds.width, 1) + let height = fittingHeight(for: availableWidth) + textView.frame = NSRect(x: 0, y: 0, width: availableWidth, height: height) + placeholderField.frame = NSRect( + x: Self.textInset.width, + y: Self.textInset.height, + width: max(bounds.width - Self.textInset.width * 2, 1), + height: Self.minimumHeight(for: currentFont) + ) + } + + func apply(font: NSFont, isEnabled: Bool) { + let fontChanged = currentFont != font || textView.font != font || placeholderField.font != font + let enabledChanged = self.isEnabled != isEnabled + + if fontChanged { + currentFont = font + textView.font = font + placeholderField.font = font + textView.textColor = self.isEnabled ? .labelColor : .disabledControlTextColor + textView.insertionPointColor = .controlAccentColor + } + if enabledChanged { + self.isEnabled = isEnabled + } + if fontChanged || enabledChanged { + refreshMetrics() + } + } + + func refreshMetrics() { + updatePlaceholderVisibility() + needsLayout = true + invalidateIntrinsicContentSize() + layoutSubtreeIfNeeded() + } + + func focusIfNeeded() { + guard let window, window.firstResponder !== textView else { return } + window.makeFirstResponder(textView) + let length = (textView.string as NSString).length + textView.setSelectedRange(NSRange(location: length, length: 0)) + } + + func fittingHeight(for width: CGFloat) -> CGFloat { + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { + return Self.minimumHeight(for: currentFont) + } + let availableWidth = max(width - Self.textInset.width * 2, 1) + textContainer.containerSize = NSSize( + width: availableWidth, + height: CGFloat.greatestFiniteMagnitude + ) + layoutManager.ensureLayout(for: textContainer) + let usedRect = layoutManager.usedRect(for: textContainer) + let extraLineHeight = layoutManager.extraLineFragmentTextContainer == textContainer + ? layoutManager.extraLineFragmentRect.height + : 0 + let lineHeight = ceil(currentFont.ascender - currentFont.descender + currentFont.leading) + let contentHeight = max(lineHeight, ceil(usedRect.height + extraLineHeight)) + return max( + Self.minimumHeight(for: currentFont), + ceil(contentHeight + Self.textInset.height * 2) + ) + } + + private func updateTextViewLayout() { + let availableWidth = max(bounds.width, 1) + let height = fittingHeight(for: availableWidth) + textView.frame = NSRect(x: 0, y: 0, width: availableWidth, height: height) + } + + private func fittingHeight() -> CGFloat { + guard bounds.width > 1 else { + return Self.minimumHeight(for: currentFont) + } + let availableWidth = max(bounds.width, 1) + return fittingHeight(for: availableWidth) + } + + private func updatePlaceholderVisibility() { + placeholderField.isHidden = !textView.string.isEmpty + } +} + diff --git a/Sources/Feed/FeedInlineTextField.swift b/Sources/Feed/FeedInlineTextField.swift new file mode 100644 index 000000000000..100235731bbf --- /dev/null +++ b/Sources/Feed/FeedInlineTextField.swift @@ -0,0 +1,119 @@ +import AppKit +import Bonsplit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +struct FeedInlineTextField: NSViewRepresentable { + @Binding var text: String + + let focusRequest: Int? + let placeholder: String + let isEnabled: Bool + let font: NSFont + let placement: FeedPlacement + let focusScopeID: UUID + let onFocus: () -> Void + let onBlur: () -> Void + let onSubmit: (() -> Void)? + + typealias Coordinator = FeedInlineTextFieldCoordinator + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> FeedInlineTextEditorView { + let view = FeedInlineTextEditorView(frame: .zero) + view.textView.delegate = context.coordinator + view.textView.string = text + view.textView.onActivate = { [weak coordinator = context.coordinator] in + coordinator?.activateField() + } + view.textView.onEscape = { [weak coordinator = context.coordinator] in + coordinator?.blurField() + } + view.textView.onSubmit = onSubmit + configure(view) + context.coordinator.view = view + return view + } + + func updateNSView(_ nsView: FeedInlineTextEditorView, context: Context) { + context.coordinator.parent = self + context.coordinator.view = nsView + nsView.textView.onActivate = { [weak coordinator = context.coordinator] in + coordinator?.activateField() + } + nsView.textView.onEscape = { [weak coordinator = context.coordinator] in + coordinator?.blurField() + } + nsView.textView.onSubmit = onSubmit + configure(nsView) + + if nsView.textView.string != text, !nsView.textView.hasMarkedText() { + context.coordinator.isProgrammaticMutation = true + nsView.textView.string = text + context.coordinator.isProgrammaticMutation = false + nsView.refreshMetrics() + } + + guard let window = nsView.window else { return } + let isFirstResponder = window.firstResponder === nsView.textView + if let focusRequest, + focusRequest != context.coordinator.lastAppliedFocusRequest { + context.coordinator.lastAppliedFocusRequest = focusRequest + if isEnabled { + nsView.focusIfNeeded() + } else if isFirstResponder { + moveFocusToFeedHost(in: window) + } + } else if focusRequest == nil { + context.coordinator.lastAppliedFocusRequest = nil + if !isEnabled, isFirstResponder { + moveFocusToFeedHost(in: window) + } + } else if !isEnabled, isFirstResponder { + moveFocusToFeedHost(in: window) + } + } + + private func moveFocusToFeedHost(in window: NSWindow) { + guard placement.usesRightSidebarFocusCoordinator else { + window.makeFirstResponder(nil) + return + } + if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( + mode: .feed, + focusFirstItem: false, + preferredWindow: window + ) == true { + return + } + window.makeFirstResponder(nil) + } + + private func configure(_ view: FeedInlineTextEditorView) { + view.placeholder = placeholder + view.textView.feedFocusScopeID = focusScopeID + view.apply(font: font, isEnabled: isEnabled) + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + nsView: FeedInlineTextEditorView, + context: Context + ) -> CGSize? { + nil + } + + static func dismantleNSView(_ nsView: FeedInlineTextEditorView, coordinator: Coordinator) { + nsView.textView.delegate = nil + nsView.textView.onActivate = nil + nsView.textView.onEscape = nil + nsView.textView.onSubmit = nil + } +} + +/// Minimal wrapping HStack that flows its children into multiple rows. diff --git a/Sources/Feed/FeedInlineTextFieldCoordinator.swift b/Sources/Feed/FeedInlineTextFieldCoordinator.swift new file mode 100644 index 000000000000..5f10dd50a4d3 --- /dev/null +++ b/Sources/Feed/FeedInlineTextFieldCoordinator.swift @@ -0,0 +1,68 @@ +import AppKit +import Foundation + +@MainActor +final class FeedInlineTextFieldCoordinator: NSObject, NSTextViewDelegate { + var parent: FeedInlineTextField + var isProgrammaticMutation = false + weak var view: FeedInlineTextEditorView? + var lastAppliedFocusRequest: Int? + + init(parent: FeedInlineTextField) { + self.parent = parent + self.lastAppliedFocusRequest = parent.focusRequest + } + + func activateField() { +#if DEBUG + dlog("feed.editor.activateField") +#endif + parent.onFocus() + } + + func blurField() { + guard let view, let window = view.window, window.firstResponder === view.textView else { + return + } +#if DEBUG + dlog("feed.editor.blurField frBefore=\(feedDebugResponderSummary(window.firstResponder))") +#endif + guard parent.placement.usesRightSidebarFocusCoordinator else { + window.makeFirstResponder(nil) + return + } + if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( + mode: .feed, + focusFirstItem: false, + preferredWindow: window + ) != true { + window.makeFirstResponder(nil) + } + } + + func textDidBeginEditing(_ notification: Notification) { + activateField() + } + + func textDidChange(_ notification: Notification) { + guard !isProgrammaticMutation else { return } + guard let textView = notification.object as? NSTextView else { return } + parent.text = textView.string + view?.refreshMetrics() + } + + func textDidEndEditing(_ notification: Notification) { + if !isProgrammaticMutation, let textView = notification.object as? NSTextView { + parent.text = textView.string + } + guard let window = view?.window else { + parent.onBlur() + return + } + let responder = window.firstResponder + if !(responder is FeedKeyboardFocusView) && !(responder is FeedInlineNativeTextView) { + parent.onBlur() + } + } + +} diff --git a/Sources/Feed/FeedItemRow.swift b/Sources/Feed/FeedItemRow.swift index 09c9ebcb7a72..f983786664f6 100644 --- a/Sources/Feed/FeedItemRow.swift +++ b/Sources/Feed/FeedItemRow.swift @@ -396,75 +396,3 @@ struct FeedItemRow: View, Equatable { return f }() } - -// MARK: - Per-kind action areas - -private struct FeedContextBlock: View { - let context: WorkstreamContext - let source: WorkstreamSource - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - if let user = context.lastUserMessage { - FeedLabeledTextRow( - label: String(localized: "feed.context.you", defaultValue: "You:"), - text: user, - labelColor: .secondary, - textColor: .secondary - ) - } - if let preamble = context.assistantPreamble { - FeedLabeledTextRow( - label: agentLabel, - text: preamble, - labelColor: .secondary, - textColor: .secondary, - rendersMarkdown: source == .claude - ) - } - if let plan = context.planSummary { - FeedLabeledTextRow( - label: String(localized: "feed.context.plan", defaultValue: "Plan:"), - text: plan, - labelColor: Color.purple.opacity(0.85), - textColor: .secondary, - rendersMarkdown: source == .claude - ) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var agentLabel: String { - "\(source.rawValue.capitalized):" - } -} - -struct FeedLabeledTextRow: View { - let label: String - let text: String - let labelColor: Color - let textColor: Color - var rendersMarkdown: Bool = false - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(label) - .cmuxFont(size: 10, weight: .semibold) - .foregroundColor(labelColor) - .frame(width: 48, alignment: .leading) - if rendersMarkdown { - FeedMarkdownInlineText( - text: text, - fontSize: 11, - foregroundColor: textColor - ) - } else { - Text(text) - .cmuxFont(size: 11) - .foregroundColor(textColor) - .fixedSize(horizontal: false, vertical: true) - } - } - } -} diff --git a/Sources/Feed/FeedItemSnapshot.swift b/Sources/Feed/FeedItemSnapshot.swift index 5f31073c7cf5..6cb72ed5ffad 100644 --- a/Sources/Feed/FeedItemSnapshot.swift +++ b/Sources/Feed/FeedItemSnapshot.swift @@ -40,60 +40,3 @@ struct FeedItemSnapshot: Equatable { self.userPromptEcho = userPromptEcho } } - -/// Closure bundle; binds to `FeedCoordinator` by default. -struct FeedRowActions { - let approvePermission: (String, WorkstreamPermissionMode) -> Void - let replyQuestion: (String, [String]) -> Void - let approveExitPlan: (String, WorkstreamExitPlanMode, String?) -> Void - let jump: (String) -> Void - /// Types the user's reply into the agent's terminal surface and - /// presses Return. Used by Stop-kind cards so the user can nudge - /// Claude without switching focus to the terminal. - let sendText: (String, String) -> Void - - static func bound() -> FeedRowActions { - FeedRowActions( - approvePermission: { requestID, mode in - Task { - await FeedCoordinator.shared.deliverReply( - requestId: requestID, - decision: .permission(mode) - ) - } - }, - replyQuestion: { requestID, selections in - Task { - await FeedCoordinator.shared.deliverReply( - requestId: requestID, - decision: .question(selections: selections) - ) - } - }, - approveExitPlan: { requestID, mode, feedback in - Task { - await FeedCoordinator.shared.deliverReply( - requestId: requestID, - decision: .exitPlan(mode, feedback: feedback) - ) - } - }, - jump: { workstreamId in - Task { - _ = await FeedCoordinator.shared.focusIfPossible(workstreamId: workstreamId) - } - }, - sendText: { workstreamId, text in - Task { - await FeedCoordinator.shared.sendTextToWorkstream( - workstreamId: workstreamId, - text: text - ) - } - } - ) - } - -} - -// MARK: - Row (matches SessionIndexView row aesthetic) diff --git a/Sources/Feed/FeedJumpResolver.swift b/Sources/Feed/FeedJumpResolver.swift index 91f35c0a5d01..052a86cf2ef6 100644 --- a/Sources/Feed/FeedJumpResolver.swift +++ b/Sources/Feed/FeedJumpResolver.swift @@ -3,11 +3,6 @@ import Foundation /// Resolves Feed workstream identifiers through the per-agent hook-session /// stores written by `cmux -hook session-start`. final class FeedJumpResolver: @unchecked Sendable { - struct Target: Equatable, Sendable { - let workspaceId: String - let surfaceId: String - } - private let sessionsDirectory: URL init( @@ -25,7 +20,7 @@ final class FeedJumpResolver: @unchecked Sendable { return (agent, sessionId) } - func lookup(agent: String, sessionId: String) -> Target? { + func lookup(agent: String, sessionId: String) -> FeedJumpTarget? { let file = sessionsDirectory .appendingPathComponent("\(agent)-hook-sessions.json", isDirectory: false) guard let data = try? Data(contentsOf: file), @@ -44,10 +39,10 @@ final class FeedJumpResolver: @unchecked Sendable { let surfaceId = entry["surfaceId"] as? String, !workspaceId.isEmpty, !surfaceId.isEmpty else { return nil } - return Target(workspaceId: workspaceId, surfaceId: surfaceId) + return FeedJumpTarget(workspaceId: workspaceId, surfaceId: surfaceId) } - func resolve(_ workstreamId: String) -> Target? { + func resolve(_ workstreamId: String) -> FeedJumpTarget? { guard let parsed = parse(workstreamId) else { return nil } return lookup(agent: parsed.agent, sessionId: parsed.sessionId) } @@ -57,7 +52,7 @@ final class FeedJumpResolver: @unchecked Sendable { #else @Sendable #endif - nonisolated func resolveOffMain(_ workstreamId: String) async -> Target? { + nonisolated func resolveOffMain(_ workstreamId: String) async -> FeedJumpTarget? { await Task.detached(priority: .userInitiated) { [self] in resolve(workstreamId) }.value diff --git a/Sources/Feed/FeedJumpTarget.swift b/Sources/Feed/FeedJumpTarget.swift new file mode 100644 index 000000000000..f79a4c17d47e --- /dev/null +++ b/Sources/Feed/FeedJumpTarget.swift @@ -0,0 +1,4 @@ +struct FeedJumpTarget: Equatable, Sendable { + let workspaceId: String + let surfaceId: String +} diff --git a/Sources/Feed/FeedKeyboardFocus.swift b/Sources/Feed/FeedKeyboardFocus.swift index aa266b272e86..97a015d4dcf0 100644 --- a/Sources/Feed/FeedKeyboardFocus.swift +++ b/Sources/Feed/FeedKeyboardFocus.swift @@ -4,190 +4,7 @@ import CMUXAgentLaunch import Foundation import SwiftUI -struct FeedStopDraft: Equatable { - var reply = "" - - var isPristine: Bool { - reply.isEmpty - } -} - -struct FeedRowSurface: View { - let snapshot: FeedItemSnapshot - let actions: FeedRowActions - let isSelected: Bool - let isFocusActive: Bool - let showsDivider: Bool - @Binding var stopDraft: FeedStopDraft - let placement: FeedPlacement - let focusScopeID: UUID - let onPressSelect: () -> Void - let onControlFocus: () -> Void - let onControlAction: () -> Void - let onControlBlur: () -> Void - let onActivate: () -> Void - - @State private var isHovered = false - @State private var stopReplyFocusRequest = 0 - - var body: some View { - VStack(spacing: 0) { - FeedItemRow( - snapshot: snapshot, - actions: actions, - isSelected: isFocusActive, - onPressSelect: onPressSelect, - onControlFocus: onControlFocus, - onControlAction: onControlAction, - onControlBlur: onControlBlur, - onActivate: onActivate, - stopDraft: $stopDraft, - stopDraftValue: stopDraft, - stopFocusRequest: $stopReplyFocusRequest, - stopFocusRequestValue: stopReplyFocusRequest, - placement: placement, - focusScopeID: focusScopeID - ) - .equatable() - if showsDivider { - Rectangle() - .fill(Color.primary.opacity(0.08)) - .frame(maxWidth: .infinity) - .frame(height: 1) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .background(rowBackgroundFill) - .animation(.easeOut(duration: 0.14), value: isHovered) - .animation(.easeOut(duration: 0.14), value: isSelected) - .onHover { hovering in - withAnimation(.easeOut(duration: 0.14)) { - isHovered = hovering - } - } - } - - private var rowBackgroundFill: Color { - if isSelected { - guard isFocusActive else { - return Color.primary.opacity(0.07) - } - if snapshot.status.isPending { - return tint.opacity(0.14) - } - return Color.primary.opacity(0.075) - } - if isHovered { - if snapshot.status.isPending { - return tint.opacity(0.10) - } - return Color.primary.opacity(0.055) - } - return .clear - } - - private var tint: Color { - switch snapshot.kind { - case .permissionRequest: return .orange - case .exitPlan: return .purple - case .question: return .blue - default: return snapshot.status.isPending ? .orange : .secondary.opacity(0.8) - } - } -} - -extension View { - @ViewBuilder - func feedZeroScrollContentMargins() -> some View { - if #available(macOS 14.0, *) { - contentMargins(.all, 0, for: .scrollContent) - } else { - self - } - } -} - -struct FeedKeyboardFocusBridge: NSViewRepresentable { - let placement: FeedPlacement - let focusScopeID: UUID - let focusRequest: Int - let onHostChange: (FeedKeyboardFocusView?) -> Void - let onEscape: () -> Void - let onMoveSelection: (Int) -> Void - let onActivateSelection: () -> Void - let onFocusFirstItemRequested: () -> Void - let onFocusChanged: (Bool) -> Void - let onFocusSnapshotChanged: (FeedFocusSnapshot) -> Void - - final class Coordinator { - var onHostChange: (FeedKeyboardFocusView?) -> Void - weak var host: FeedKeyboardFocusView? - var lastFocusRequest: Int - - init( - onHostChange: @escaping (FeedKeyboardFocusView?) -> Void, - focusRequest: Int - ) { - self.onHostChange = onHostChange - self.lastFocusRequest = focusRequest - } - - func attach(_ host: FeedKeyboardFocusView) { - guard self.host !== host else { return } - self.host = host - onHostChange(host) - } - - func detach(_ host: FeedKeyboardFocusView) { - guard self.host === host else { return } - self.host = nil - onHostChange(nil) - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(onHostChange: onHostChange, focusRequest: focusRequest) - } - - func makeNSView(context: Context) -> FeedKeyboardFocusView { - let view = FeedKeyboardFocusView(frame: NSRect(x: 0, y: 0, width: 1, height: 1)) - view.placement = placement - view.feedFocusScopeID = focusScopeID - view.onEscape = onEscape - view.onMoveSelection = onMoveSelection - view.onActivateSelection = onActivateSelection - view.onFocusFirstItemRequested = onFocusFirstItemRequested - view.onFocusChanged = onFocusChanged - view.onFocusSnapshotChanged = onFocusSnapshotChanged - context.coordinator.attach(view) - return view - } - - func updateNSView(_ nsView: FeedKeyboardFocusView, context: Context) { - context.coordinator.onHostChange = onHostChange - context.coordinator.attach(nsView) - nsView.placement = placement - nsView.feedFocusScopeID = focusScopeID - nsView.onEscape = onEscape - nsView.onMoveSelection = onMoveSelection - nsView.onActivateSelection = onActivateSelection - nsView.onFocusFirstItemRequested = onFocusFirstItemRequested - nsView.onFocusChanged = onFocusChanged - nsView.onFocusSnapshotChanged = onFocusSnapshotChanged - nsView.registerWithKeyboardFocusCoordinatorIfNeeded() - if !placement.usesRightSidebarFocusCoordinator, - focusRequest != context.coordinator.lastFocusRequest { - context.coordinator.lastFocusRequest = focusRequest - _ = nsView.focusHostFromCoordinator() - } - } - - static func dismantleNSView(_ nsView: FeedKeyboardFocusView, coordinator: Coordinator) { - coordinator.detach(nsView) - } -} - -final class FeedKeyboardFocusView: NSView { +final class FeedKeyboardFocusView: NSView, FeedScopedKeyboardFocusResponder { var placement: FeedPlacement = .rightSidebar var feedFocusScopeID = UUID() var onEscape: (() -> Void)? @@ -339,7 +156,11 @@ final class FeedKeyboardFocusView: NSView { func ownsKeyboardFocus(_ responder: NSResponder) -> Bool { if responder === self { return true } - guard let feedResponder = responder as? FeedKeyboardFocusResponder else { return false } + guard responder is FeedKeyboardFocusResponder else { return false } + guard let feedResponder = responder as? FeedScopedKeyboardFocusResponder else { + // Compatibility for legacy Feed controls that predate pane-scoped focus. + return true + } return feedResponder.feedFocusScopeID == feedFocusScopeID } } diff --git a/Sources/Feed/FeedKeyboardFocusBridge.swift b/Sources/Feed/FeedKeyboardFocusBridge.swift new file mode 100644 index 000000000000..b8b750652294 --- /dev/null +++ b/Sources/Feed/FeedKeyboardFocusBridge.swift @@ -0,0 +1,61 @@ +import AppKit +import Bonsplit +import CMUXAgentLaunch +import Foundation +import SwiftUI + +struct FeedKeyboardFocusBridge: NSViewRepresentable { + let placement: FeedPlacement + let focusScopeID: UUID + let focusRequest: Int + let onHostChange: (FeedKeyboardFocusView?) -> Void + let onEscape: () -> Void + let onMoveSelection: (Int) -> Void + let onActivateSelection: () -> Void + let onFocusFirstItemRequested: () -> Void + let onFocusChanged: (Bool) -> Void + let onFocusSnapshotChanged: (FeedFocusSnapshot) -> Void + + typealias Coordinator = FeedKeyboardFocusBridgeCoordinator + + func makeCoordinator() -> Coordinator { + Coordinator(onHostChange: onHostChange, focusRequest: focusRequest) + } + + func makeNSView(context: Context) -> FeedKeyboardFocusView { + let view = FeedKeyboardFocusView(frame: NSRect(x: 0, y: 0, width: 1, height: 1)) + view.placement = placement + view.feedFocusScopeID = focusScopeID + view.onEscape = onEscape + view.onMoveSelection = onMoveSelection + view.onActivateSelection = onActivateSelection + view.onFocusFirstItemRequested = onFocusFirstItemRequested + view.onFocusChanged = onFocusChanged + view.onFocusSnapshotChanged = onFocusSnapshotChanged + context.coordinator.attach(view) + return view + } + + func updateNSView(_ nsView: FeedKeyboardFocusView, context: Context) { + context.coordinator.onHostChange = onHostChange + context.coordinator.attach(nsView) + nsView.placement = placement + nsView.feedFocusScopeID = focusScopeID + nsView.onEscape = onEscape + nsView.onMoveSelection = onMoveSelection + nsView.onActivateSelection = onActivateSelection + nsView.onFocusFirstItemRequested = onFocusFirstItemRequested + nsView.onFocusChanged = onFocusChanged + nsView.onFocusSnapshotChanged = onFocusSnapshotChanged + nsView.registerWithKeyboardFocusCoordinatorIfNeeded() + if !placement.usesRightSidebarFocusCoordinator, + focusRequest != context.coordinator.lastFocusRequest { + context.coordinator.lastFocusRequest = focusRequest + _ = nsView.focusHostFromCoordinator() + } + } + + static func dismantleNSView(_ nsView: FeedKeyboardFocusView, coordinator: Coordinator) { + coordinator.detach(nsView) + } +} diff --git a/Sources/Feed/FeedKeyboardFocusBridgeCoordinator.swift b/Sources/Feed/FeedKeyboardFocusBridgeCoordinator.swift new file mode 100644 index 000000000000..a1d3128467dd --- /dev/null +++ b/Sources/Feed/FeedKeyboardFocusBridgeCoordinator.swift @@ -0,0 +1,27 @@ +import Foundation + +final class FeedKeyboardFocusBridgeCoordinator { + var onHostChange: (FeedKeyboardFocusView?) -> Void + weak var host: FeedKeyboardFocusView? + var lastFocusRequest: Int + + init( + onHostChange: @escaping (FeedKeyboardFocusView?) -> Void, + focusRequest: Int + ) { + self.onHostChange = onHostChange + self.lastFocusRequest = focusRequest + } + + func attach(_ host: FeedKeyboardFocusView) { + guard self.host !== host else { return } + self.host = host + onHostChange(host) + } + + func detach(_ host: FeedKeyboardFocusView) { + guard self.host === host else { return } + self.host = nil + onHostChange(nil) + } +} diff --git a/Sources/Feed/FeedKeyboardFocusResponder.swift b/Sources/Feed/FeedKeyboardFocusResponder.swift new file mode 100644 index 000000000000..1e32809b88e8 --- /dev/null +++ b/Sources/Feed/FeedKeyboardFocusResponder.swift @@ -0,0 +1 @@ +protocol FeedKeyboardFocusResponder: AnyObject {} diff --git a/Sources/Feed/FeedLabeledTextRow.swift b/Sources/Feed/FeedLabeledTextRow.swift new file mode 100644 index 000000000000..50f1921a2b5e --- /dev/null +++ b/Sources/Feed/FeedLabeledTextRow.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct FeedLabeledTextRow: View { + let label: String + let text: String + let labelColor: Color + let textColor: Color + var rendersMarkdown: Bool = false + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(label) + .cmuxFont(size: 10, weight: .semibold) + .foregroundColor(labelColor) + .frame(width: 48, alignment: .leading) + if rendersMarkdown { + FeedMarkdownInlineText( + text: text, + fontSize: 11, + foregroundColor: textColor + ) + } else { + Text(text) + .cmuxFont(size: 11) + .foregroundColor(textColor) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} diff --git a/Sources/Feed/FeedListView.swift b/Sources/Feed/FeedListView.swift index 0da0387328e8..adeedc6c1fdf 100644 --- a/Sources/Feed/FeedListView.swift +++ b/Sources/Feed/FeedListView.swift @@ -427,13 +427,3 @@ struct FeedListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } - -@MainActor -private final class FeedFocusHostReference { - weak var host: FeedKeyboardFocusView? -} - -private struct FeedScrollRequest: Equatable { - let id: UUID - let sequence: Int -} diff --git a/Sources/Feed/FeedMarkdownInlineText.swift b/Sources/Feed/FeedMarkdownInlineText.swift new file mode 100644 index 000000000000..bd88879ef840 --- /dev/null +++ b/Sources/Feed/FeedMarkdownInlineText.swift @@ -0,0 +1,42 @@ +import AppKit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +struct FeedMarkdownInlineText: View { + let text: String + let fontSize: CGFloat + let weight: Font.Weight? + let foregroundColor: Color + + init( + text: String, + fontSize: CGFloat, + weight: Font.Weight? = nil, + foregroundColor: Color + ) { + self.text = text + self.fontSize = fontSize + self.weight = weight + self.foregroundColor = foregroundColor + } + + var body: some View { + let parsed = (try? AttributedString( + markdown: text, + options: AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + )) ?? AttributedString(text) + Text(parsed) + .cmuxFont(size: fontSize, weight: weight ?? .regular) + .foregroundColor(foregroundColor) + .fixedSize(horizontal: false, vertical: true) + } +} + +/// Renders plan text as a stack of small structured sections. Block +/// headings, lists, and paragraphs keep the Feed's compact rhythm, while +/// Claude markdown inside each line gets parsed tastefully. Heading text +/// intentionally stays at body scale. diff --git a/Sources/Feed/FeedPendingWaiter.swift b/Sources/Feed/FeedPendingWaiter.swift new file mode 100644 index 000000000000..5164a77efdb6 --- /dev/null +++ b/Sources/Feed/FeedPendingWaiter.swift @@ -0,0 +1,9 @@ +import CMUXAgentLaunch +import Foundation + +struct FeedPendingWaiter { + let continuation: AsyncStream.Continuation + var decision: WorkstreamDecision? + var itemID: UUID? + var attentionTarget: FeedCoordinator.AttentionTarget? +} diff --git a/Sources/Feed/FeedPermissionView.swift b/Sources/Feed/FeedPermissionView.swift index 146730e02a0c..b729ea4c0cf4 100644 --- a/Sources/Feed/FeedPermissionView.swift +++ b/Sources/Feed/FeedPermissionView.swift @@ -141,651 +141,3 @@ struct PermissionActionArea: View { /// Pulls a human-readable command + description out of an agent's /// tool_input JSON. Handles Bash (`command` + `description`), Write / /// Edit / Read (`file_path`), and falls back to the raw JSON. -private struct PermissionInputPreview { - let sigil: String? - let primary: String? - let secondary: String? - - init(toolName: String, toolInputJSON: String) { - let dict = (try? JSONSerialization.jsonObject( - with: Data(toolInputJSON.utf8) - )) as? [String: Any] ?? [:] - - switch toolName.lowercased() { - case "bash": - self.sigil = "$" - self.primary = (dict["command"] as? String) ?? toolInputJSON - self.secondary = (dict["description"] as? String) - case "write", "edit", "multiedit": - self.sigil = nil - self.primary = (dict["file_path"] as? String) ?? toolInputJSON - if toolName.lowercased() == "write" { - let content = (dict["content"] as? String) ?? "" - let preview = content.split(separator: "\n").first.map(String.init) ?? "" - self.secondary = preview.isEmpty ? nil : preview - } else { - self.secondary = nil - } - case "read": - self.sigil = nil - self.primary = (dict["file_path"] as? String) ?? toolInputJSON - self.secondary = nil - default: - self.sigil = nil - self.primary = toolInputJSON == "{}" ? nil : toolInputJSON - self.secondary = nil - } - } -} - -/// Single DRY button primitive used across every actionable card -/// (permission / plan / question / filter pills / option pills). -/// Replaces the old PermissionCTAButton / PlanCTAButton / -/// FeedPillButton trio so styling is defined in exactly one place. -struct FeedButton: View { - enum Kind: String { - /// Transparent pill that lights up on hover/selection. Used - /// for filter bar pills and single-select option pills. - case ghost - /// Soft neutral fill (e.g. Manual, disabled Submit). - case soft - /// Dark background with white text (Deny). - case dark - /// Light background with black text (Allow Once). - case light - /// Solid blue (Always Allow, Send feedback, active Submit). - case primary - /// Solid green (Auto, checked multi-select option, confirmations). - case success - /// Solid orange (warning actions). - case warning - /// Solid red (destructive deny). - case destructive - } - - enum Size { - case compact // filter bar / option pills - case medium // full-width CTAs - } - - let label: String - var leadingIcon: String? = nil - var trailingIcon: String? = nil - var kind: Kind = .ghost - var size: Size = .compact - var fullWidth: Bool = false - var isSelected: Bool = false - var dimmed: Bool = false - let action: () -> Void - - @Environment(\.colorScheme) private var colorScheme - @State private var isHovered: Bool = false -#if DEBUG - @AppStorage(FeedButtonDebugSettings.generationKey) private var debugStyleGeneration = 0 -#endif - - var body: some View { -#if DEBUG - #if compiler(>=6.2) - if #available(macOS 26.0, *), usesSystemGlassButtonStyle { - systemGlassButton - } else { - plainFeedButton - } - #else - plainFeedButton - #endif -#else - plainFeedButton -#endif - } - - private var plainFeedButton: some View { - Button { - performAction() - } label: { - labelContent - .foregroundColor(foreground) - .frame(maxWidth: fullWidth ? .infinity : nil) - .padding(.horizontal, horizontalPadding) - .padding(.vertical, verticalPadding) - .background(buttonBackground) - .overlay(buttonBorder) - .shadow( - color: buttonShadowColor, - radius: buttonShadowRadius, - x: 0, - y: buttonShadowY - ) - .opacity(dimmed ? 0.55 : 1.0) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(dimmed) - .onHover { hovering in - handleHover(hovering) - } - .help(label) - } - - private var labelContent: some View { - HStack(spacing: iconSpacing) { - if let leadingIcon { - Image(systemName: leadingIcon) - .cmuxFont(size: iconSize, weight: .semibold) - } - Text(label) - .cmuxFont(size: labelSize, weight: .semibold) - if let trailingIcon { - Image(systemName: trailingIcon) - .cmuxFont(size: iconSize, weight: .semibold) - } - } - } - - private var standardLabelContent: some View { - HStack(spacing: 4) { - if let leadingIcon { - Image(systemName: leadingIcon) - } - Text(label) - if let trailingIcon { - Image(systemName: trailingIcon) - } - } - .cmuxFont(size: labelSize, weight: .semibold) - } - - private func performAction() { - // `dimmed` doubles as the disabled signal — swallow the - // click at the primitive so upstream action closures don't - // have to re-check. - guard !dimmed else { return } - action() - } - - private func handleHover(_ hovering: Bool) { - isHovered = hovering - } - -#if DEBUG - private var usesSystemGlassButtonStyle: Bool { - _ = debugStyleGeneration - switch FeedButtonDebugSettings.visualStyle { - case .standardGlass, .standardTintedGlass, .nativeGlass, .nativeProminentGlass, .commandLight: - return true - case .solid, .glass, .liquid, .halo, .command, .outline, .flat: - return false - } - } - - #if compiler(>=6.2) - @available(macOS 26.0, *) - @ViewBuilder - private var systemGlassButton: some View { - if FeedButtonDebugSettings.visualStyle == .standardGlass { - standardSystemGlassButtonBase - .buttonStyle(.glass) - } else if FeedButtonDebugSettings.visualStyle == .standardTintedGlass { - standardSystemGlassButtonBase - .buttonStyle(.glass) - .tint(systemGlassTint) - } else if FeedButtonDebugSettings.visualStyle == .nativeProminentGlass { - systemGlassButtonBase - .buttonStyle(.glassProminent) - } else { - systemGlassButtonBase - .buttonStyle(.glass) - } - } - - @available(macOS 26.0, *) - private var standardSystemGlassButtonBase: some View { - Button { - performAction() - } label: { - standardLabelContent - .frame(maxWidth: fullWidth ? .infinity : nil) - } - .controlSize(size == .compact ? .small : .regular) - .disabled(dimmed) - .opacity(dimmed ? 0.55 : 1.0) - .onHover { hovering in - handleHover(hovering) - } - .help(label) - } - - @available(macOS 26.0, *) - private var systemGlassButtonBase: some View { - Button { - performAction() - } label: { - labelContent - .foregroundStyle(systemGlassForeground) - .frame(maxWidth: fullWidth ? .infinity : nil) - .padding(.horizontal, max(CGFloat(0), horizontalPadding - 2)) - .padding(.vertical, max(CGFloat(0), verticalPadding - 1)) - .contentShape( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - ) - } - .buttonBorderShape(.roundedRectangle(radius: cornerRadius)) - .controlSize(size == .compact ? .small : .regular) - .tint(systemGlassTint) - .disabled(dimmed) - .opacity(dimmed ? 0.55 : 1.0) - .onHover { hovering in - handleHover(hovering) - } - .help(label) - } - #endif - - private var systemGlassTint: Color { - glassEffectTint.opacity(FeedButtonDebugSettings.glassTintOpacity) - } - - private var systemGlassForeground: Color { - if let color = FeedButtonDebugSettings.color( - for: kind, - role: .foreground, - colorScheme: colorScheme - ) { - return color - } - - switch FeedButtonDebugSettings.visualStyle { - case .nativeProminentGlass: - return kind == .light ? .black : .white - case .nativeGlass: - return .primary - case .standardGlass, .standardTintedGlass, .solid, .glass, .liquid, .halo, .command, .commandLight, .outline, .flat: - return foreground - } - } -#endif - - // MARK: - Style resolution - - private var labelSize: CGFloat { size == .compact ? 11 : 12 } - private var iconSize: CGFloat { size == .compact ? 10 : 11 } - private var iconSpacing: CGFloat { size == .compact ? 3 : 5 } - private var cornerRadius: CGFloat { -#if DEBUG - _ = debugStyleGeneration - return size == .compact - ? CGFloat(FeedButtonDebugSettings.compactCornerRadius) - : CGFloat(FeedButtonDebugSettings.mediumCornerRadius) -#else - return size == .compact ? 5 : 6 -#endif - } - private var horizontalPadding: CGFloat { -#if DEBUG - _ = debugStyleGeneration - return size == .compact - ? CGFloat(FeedButtonDebugSettings.compactHorizontalPadding) - : CGFloat(FeedButtonDebugSettings.mediumHorizontalPadding) -#else - return size == .compact ? 8 : 12 -#endif - } - private var verticalPadding: CGFloat { -#if DEBUG - _ = debugStyleGeneration - return size == .compact - ? CGFloat(FeedButtonDebugSettings.compactVerticalPadding) - : CGFloat(FeedButtonDebugSettings.mediumVerticalPadding) -#else - return size == .compact ? 5 : 7 -#endif - } - - private var foreground: Color { -#if DEBUG - _ = debugStyleGeneration - if let color = FeedButtonDebugSettings.color( - for: kind, - role: .foreground, - colorScheme: colorScheme - ) { - return color - } -#endif - switch kind { - case .ghost: - return isSelected ? .primary : .primary.opacity(0.85) - case .soft: return .primary - case .dark: return .white - case .light: return .black - case .primary: return .white - case .success: return .white - case .warning: return .white - case .destructive: return .white - } - } - - private var backgroundFill: Color { -#if DEBUG - _ = debugStyleGeneration - if let color = FeedButtonDebugSettings.color( - for: kind, - role: isHovered ? .hoverBackground : .background, - colorScheme: colorScheme - ) { - return color - } -#endif - switch kind { - case .ghost: - if isSelected { return Color.primary.opacity(0.12) } - if isHovered { return Color.primary.opacity(0.06) } - return Color.clear - case .soft: - return isHovered ? Color.primary.opacity(0.16) : Color.primary.opacity(0.10) - case .dark: - return isHovered ? Color.black.opacity(0.85) : Color.black.opacity(0.75) - case .light: - return isHovered ? Color.white.opacity(0.96) : Color.white.opacity(0.88) - case .primary: - return isHovered - ? Color(red: 0.28, green: 0.55, blue: 0.95) - : Color(red: 0.24, green: 0.48, blue: 0.88) - case .success: - return isHovered - ? Color(red: 0.22, green: 0.72, blue: 0.42) - : Color(red: 0.18, green: 0.62, blue: 0.35) - case .warning: - return isHovered - ? Color(red: 0.95, green: 0.55, blue: 0.18) - : Color(red: 0.92, green: 0.54, blue: 0.29) - case .destructive: - return isHovered - ? Color(red: 0.85, green: 0.28, blue: 0.28) - : Color(red: 0.75, green: 0.22, blue: 0.22) - } - } - -#if DEBUG - private var glassEffectTint: Color { - _ = debugStyleGeneration - if let color = FeedButtonDebugSettings.color( - for: kind, - role: isHovered ? .hoverBackground : .background, - colorScheme: colorScheme - ) { - return color - } - - switch kind { - case .ghost: return Color.accentColor - case .soft: return Color.gray - case .dark: return Color.black - case .light: return Color.white - case .primary: return Color(red: 0.24, green: 0.48, blue: 0.88) - case .success: return Color(red: 0.18, green: 0.62, blue: 0.35) - case .warning: return Color(red: 0.92, green: 0.54, blue: 0.29) - case .destructive: return Color(red: 0.75, green: 0.22, blue: 0.22) - } - } -#endif - - @ViewBuilder - private var buttonBackground: some View { - let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) -#if DEBUG - let generation = debugStyleGeneration - switch generation >= 0 ? FeedButtonDebugSettings.visualStyle : .solid { - case .solid: - shape.fill(backgroundFill) - case .standardGlass: - shape.fill(.regularMaterial) - case .standardTintedGlass: - shape - .fill(.regularMaterial) - .overlay( - shape.fill( - backgroundFill.opacity( - FeedButtonDebugSettings.glassTintOpacity - ) - ) - ) - case .glass: - shape - .fill(.thinMaterial) - .overlay( - shape.fill( - backgroundFill.opacity( - FeedButtonDebugSettings.glassTintOpacity - ) - ) - ) - case .nativeGlass: - #if compiler(>=6.2) - if #available(macOS 26.0, *) { - shape - .fill(Color.clear) - .glassEffect( - .regular - .tint(glassEffectTint.opacity(FeedButtonDebugSettings.glassTintOpacity)) - .interactive(!dimmed), - in: shape - ) - } else { - shape - .fill(.ultraThinMaterial) - .overlay(shape.fill(backgroundFill.opacity(0.20))) - } - #else - shape - .fill(.ultraThinMaterial) - .overlay(shape.fill(backgroundFill.opacity(0.20))) - #endif - case .nativeProminentGlass: - #if compiler(>=6.2) - if #available(macOS 26.0, *) { - shape - .fill(Color.clear) - .glassEffect( - .regular - .tint(glassEffectTint.opacity(FeedButtonDebugSettings.glassTintOpacity)) - .interactive(!dimmed), - in: shape - ) - .overlay( - shape.fill( - backgroundFill.opacity(isHovered || isSelected ? 0.30 : 0.18) - ) - ) - } else { - shape - .fill(.regularMaterial) - .overlay(shape.fill(backgroundFill.opacity(0.26))) - } - #else - shape - .fill(.regularMaterial) - .overlay(shape.fill(backgroundFill.opacity(0.26))) - #endif - case .liquid: - shape - .fill(.ultraThinMaterial) - .overlay( - shape.fill( - backgroundFill.opacity( - FeedButtonDebugSettings.glassTintOpacity - ) - ) - ) - .overlay( - shape.fill( - LinearGradient( - colors: [ - Color.white.opacity(isHovered || isSelected ? 0.42 : 0.30), - Color.white.opacity(0.08), - Color.clear, - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .blendMode(.screen) - ) - case .halo: - shape - .fill(.thinMaterial) - .overlay( - shape.fill( - backgroundFill.opacity( - FeedButtonDebugSettings.glassTintOpacity - ) - ) - ) - .overlay( - shape.fill( - RadialGradient( - colors: [ - Color.white.opacity(isHovered || isSelected ? 0.30 : 0.18), - Color.clear, - ], - center: .topLeading, - startRadius: 1, - endRadius: 54 - ) - ) - .blendMode(.screen) - ) - case .command: - shape - .fill(.regularMaterial) - .overlay(shape.fill(Color.black.opacity(0.28))) - .overlay( - shape.fill( - backgroundFill.opacity( - FeedButtonDebugSettings.glassTintOpacity - ) - ) - ) - case .commandLight: - shape - .fill(.regularMaterial) - .overlay(shape.fill(Color.white.opacity(0.22))) - .overlay( - shape.fill( - backgroundFill.opacity( - FeedButtonDebugSettings.glassTintOpacity - ) - ) - ) - case .outline: - shape.fill(isHovered || isSelected ? backgroundFill.opacity(0.14) : Color.clear) - case .flat: - shape.fill(isHovered || isSelected ? backgroundFill.opacity(0.12) : Color.clear) - } -#else - shape.fill(backgroundFill) -#endif - } - - @ViewBuilder - private var buttonBorder: some View { -#if DEBUG - let generation = debugStyleGeneration - let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - switch generation >= 0 ? FeedButtonDebugSettings.visualStyle : .solid { - case .solid: - EmptyView() - case .standardGlass: - shape.stroke(Color.white.opacity(0.12), lineWidth: FeedButtonDebugSettings.borderWidth) - case .standardTintedGlass: - shape.stroke(backgroundFill.opacity(0.22), lineWidth: FeedButtonDebugSettings.borderWidth) - case .glass: - shape.stroke(Color.white.opacity(0.16), lineWidth: 0.75) - case .nativeGlass: - shape.stroke(Color.white.opacity(0.14), lineWidth: FeedButtonDebugSettings.borderWidth) - case .nativeProminentGlass: - shape.stroke(Color.white.opacity(0.18), lineWidth: FeedButtonDebugSettings.borderWidth) - case .liquid: - shape.stroke( - LinearGradient( - colors: [ - Color.white.opacity(0.42), - backgroundFill.opacity(0.28), - Color.white.opacity(0.10), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ), - lineWidth: FeedButtonDebugSettings.borderWidth - ) - case .halo: - shape.stroke( - backgroundFill.opacity(isHovered || isSelected ? 0.55 : 0.34), - lineWidth: FeedButtonDebugSettings.borderWidth - ) - case .command: - shape.stroke(Color.white.opacity(0.12), lineWidth: FeedButtonDebugSettings.borderWidth) - case .commandLight: - shape.stroke(Color.black.opacity(0.12), lineWidth: FeedButtonDebugSettings.borderWidth) - case .outline: - shape.stroke(backgroundFill.opacity(0.75), lineWidth: FeedButtonDebugSettings.borderWidth) - case .flat: - EmptyView() - } -#else - EmptyView() -#endif - } - - private var buttonShadowColor: Color { -#if DEBUG - _ = debugStyleGeneration - switch FeedButtonDebugSettings.visualStyle { - case .halo: - return backgroundFill.opacity(isHovered || isSelected ? 0.44 : 0.24) - case .liquid: - return backgroundFill.opacity(isHovered || isSelected ? 0.18 : 0.10) - case .command: - return Color.black.opacity(0.28) - case .commandLight: - return Color.black.opacity(isHovered || isSelected ? 0.16 : 0.08) - case .nativeProminentGlass: - return backgroundFill.opacity(isHovered || isSelected ? 0.18 : 0.10) - case .standardGlass, .standardTintedGlass, .solid, .glass, .nativeGlass, .outline, .flat: - return Color.clear - } -#else - return Color.clear -#endif - } - - private var buttonShadowRadius: CGFloat { -#if DEBUG - _ = debugStyleGeneration - switch FeedButtonDebugSettings.visualStyle { - case .halo: return isHovered || isSelected ? 9 : 6 - case .liquid: return isHovered || isSelected ? 5 : 3 - case .nativeProminentGlass: return isHovered || isSelected ? 5 : 3 - case .command: return 3 - case .commandLight: return isHovered || isSelected ? 4 : 2 - case .standardGlass, .standardTintedGlass, .solid, .glass, .nativeGlass, .outline, .flat: return 0 - } -#else - return 0 -#endif - } - - private var buttonShadowY: CGFloat { -#if DEBUG - _ = debugStyleGeneration - switch FeedButtonDebugSettings.visualStyle { - case .halo: return 2 - case .liquid, .nativeProminentGlass, .command, .commandLight: return 1 - case .standardGlass, .standardTintedGlass, .solid, .glass, .nativeGlass, .outline, .flat: return 0 - } -#else - return 0 -#endif - } -} diff --git a/Sources/Feed/FeedPlacement.swift b/Sources/Feed/FeedPlacement.swift new file mode 100644 index 000000000000..7d3ec3025666 --- /dev/null +++ b/Sources/Feed/FeedPlacement.swift @@ -0,0 +1,8 @@ +enum FeedPlacement: Equatable, Sendable { + case rightSidebar + case pane + + var usesRightSidebarFocusCoordinator: Bool { + self == .rightSidebar + } +} diff --git a/Sources/Feed/FeedPlanBlock.swift b/Sources/Feed/FeedPlanBlock.swift new file mode 100644 index 000000000000..991708a4b623 --- /dev/null +++ b/Sources/Feed/FeedPlanBlock.swift @@ -0,0 +1,6 @@ +enum FeedPlanBlock { + case heading(String) + case paragraph(String) + case numbered([FeedPlanNumberedItem]) + case bulleted([String]) +} diff --git a/Sources/Feed/FeedPlanBodyView.swift b/Sources/Feed/FeedPlanBodyView.swift new file mode 100644 index 000000000000..2238772905e3 --- /dev/null +++ b/Sources/Feed/FeedPlanBodyView.swift @@ -0,0 +1,161 @@ +import AppKit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +struct PlanBodyView: View { + let plan: String + let rendersMarkdown: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + switch block { + case .heading(let text): + markdownText( + text, + weight: .semibold, + color: .primary.opacity(0.95) + ) + .padding(.top, 2) + case .paragraph(let text): + markdownText(text, color: .primary.opacity(0.85)) + case .numbered(let items): + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + HStack(alignment: .top, spacing: 5) { + Text("\(item.index).") + .cmuxFont(size: 11, weight: .medium, monospacedDigit: true) + .foregroundColor(.secondary) + markdownText(item.text, color: .primary.opacity(0.85)) + } + } + } + case .bulleted(let items): + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(Color.blue.opacity(0.85)) + .frame(width: 3.5, height: 3.5) + .padding(.top, 5.5) + .frame(width: 10, alignment: .center) + markdownText(item, color: .primary.opacity(0.85)) + } + } + } + } + } + } + } + + @ViewBuilder + private func markdownText( + _ text: String, + weight: Font.Weight? = nil, + color: Color + ) -> some View { + if rendersMarkdown { + FeedMarkdownInlineText( + text: text, + fontSize: 11, + weight: weight, + foregroundColor: color + ) + } else { + Text(text) + .cmuxFont(size: 11, weight: weight ?? .regular) + .foregroundColor(color) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var blocks: [FeedPlanBlock] { + var out: [FeedPlanBlock] = [] + var buffer: [String] = [] + func flushParagraph() { + guard !buffer.isEmpty else { return } + let joined = buffer.joined(separator: " ") + out.append(.paragraph(joined)) + buffer = [] + } + var numbered: [FeedPlanNumberedItem] = [] + func flushNumbered() { + if !numbered.isEmpty { + out.append(.numbered(numbered)) + numbered = [] + } + } + var bulleted: [String] = [] + func flushBulleted() { + if !bulleted.isEmpty { + out.append(.bulleted(bulleted)) + bulleted = [] + } + } + + for rawLine in plan.split(separator: "\n", omittingEmptySubsequences: false) { + let line = String(rawLine).trimmingCharacters(in: .whitespaces) + if line.isEmpty { + flushParagraph(); flushNumbered(); flushBulleted() + continue + } + // **Bold heading** or ## heading or "Word:" on its own line + if line.hasPrefix("**") && line.hasSuffix("**") && line.count > 4 { + flushParagraph(); flushNumbered(); flushBulleted() + out.append(.heading(String(line.dropFirst(2).dropLast(2)))) + continue + } + if let heading = markdownHeadingText(line) { + flushParagraph(); flushNumbered(); flushBulleted() + out.append(.heading(heading)) + continue + } + if line.hasSuffix(":") && line.count <= 40 + && line.split(separator: " ").count <= 4 + { + flushParagraph(); flushNumbered(); flushBulleted() + out.append(.heading(line)) + continue + } + // Numbered list + if let match = line.range( + of: #"^(\d+)\.\s+(.+)$"#, + options: .regularExpression + ) { + flushParagraph(); flushBulleted() + let text = String(line[match]) + if let dotIdx = text.firstIndex(of: ".") { + let numStr = String(text[text.startIndex.. String? { + guard line.hasPrefix("#") else { return nil } + let hashCount = line.prefix(while: { $0 == "#" }).count + guard (1...6).contains(hashCount), + line.count > hashCount, + line[line.index(line.startIndex, offsetBy: hashCount)] == " " + else { return nil } + return String(line.dropFirst(hashCount + 1)) + } +} diff --git a/Sources/Feed/FeedPlanNumberedItem.swift b/Sources/Feed/FeedPlanNumberedItem.swift new file mode 100644 index 000000000000..50d0a7ecd0d2 --- /dev/null +++ b/Sources/Feed/FeedPlanNumberedItem.swift @@ -0,0 +1,4 @@ +struct FeedPlanNumberedItem { + let index: Int + let text: String +} diff --git a/Sources/Feed/FeedQuestionView.swift b/Sources/Feed/FeedQuestionView.swift index e10449cc7600..38c1fb65b347 100644 --- a/Sources/Feed/FeedQuestionView.swift +++ b/Sources/Feed/FeedQuestionView.swift @@ -473,500 +473,3 @@ struct QuestionActionArea: View { } } } - -private final class FeedInlinePassthroughLabel: NSTextField { - override func hitTest(_ point: NSPoint) -> NSView? { nil } -} - -final class FeedInlineNativeTextView: NSTextView, FeedKeyboardFocusResponder { - private static weak var activeEditor: FeedInlineNativeTextView? - - var onActivate: (() -> Void)? - var onEscape: (() -> Void)? - var onSubmit: (() -> Void)? - var feedFocusScopeID = UUID() - - static func blurActiveEditor(in hostWindow: NSWindow?) { - guard let activeEditor else { return } - guard let window = activeEditor.window else { - if Self.activeEditor === activeEditor { - Self.activeEditor = nil - } - return - } - guard window === hostWindow else { return } - guard window.firstResponder === activeEditor else { - if Self.activeEditor === activeEditor { - Self.activeEditor = nil - } - return - } -#if DEBUG - dlog("feed.editor.blurActive fr=\(feedDebugResponderSummary(window.firstResponder))") -#endif - window.makeFirstResponder(nil) - } - - override func mouseDown(with event: NSEvent) { -#if DEBUG - dlog("feed.editor.mouseDown frBefore=\(feedDebugResponderSummary(window?.firstResponder))") -#endif - onActivate?() - super.mouseDown(with: event) - } - - override func performKeyEquivalent(with event: NSEvent) -> Bool { - if event.type == .keyDown, event.keyCode == 53 { -#if DEBUG - dlog("feed.editor.escape fr=\(feedDebugResponderSummary(window?.firstResponder))") -#endif - onEscape?() - return true - } - return super.performKeyEquivalent(with: event) - } - - override func keyDown(with event: NSEvent) { - let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let shouldSubmit = (event.keyCode == 36 || event.keyCode == 76) - && normalizedFlags.intersection([.shift, .option, .command, .control]).isEmpty - if shouldSubmit, !hasMarkedText(), let onSubmit { - onSubmit() - return - } - super.keyDown(with: event) - } - - override func resetCursorRects() { - super.resetCursorRects() - addCursorRect(bounds, cursor: .iBeam) - } - - override func becomeFirstResponder() -> Bool { - let didBecomeFirstResponder = super.becomeFirstResponder() - if didBecomeFirstResponder { - Self.activeEditor = self - onActivate?() - } -#if DEBUG - dlog("feed.editor.become result=\(didBecomeFirstResponder ? 1 : 0) fr=\(feedDebugResponderSummary(window?.firstResponder))") -#endif - return didBecomeFirstResponder - } - - override func resignFirstResponder() -> Bool { - let didResignFirstResponder = super.resignFirstResponder() - if didResignFirstResponder, Self.activeEditor === self { - Self.activeEditor = nil - } -#if DEBUG - dlog("feed.editor.resign result=\(didResignFirstResponder ? 1 : 0) fr=\(feedDebugResponderSummary(window?.firstResponder))") -#endif - return didResignFirstResponder - } -} - -final class FeedInlineTextEditorView: NSView { - private static let textInset = NSSize(width: 0, height: 1) - - let textView = FeedInlineNativeTextView(frame: .zero) - private let placeholderField = FeedInlinePassthroughLabel(labelWithString: "") - private var currentFont = GlobalFontMagnification.systemFont(ofSize: 11) - - static func minimumHeight(for font: NSFont) -> CGFloat { - ceil(font.ascender - font.descender + font.leading) + textInset.height * 2 - } - - var placeholder: String = "" { - didSet { - guard placeholder != oldValue else { return } - placeholderField.stringValue = placeholder - updatePlaceholderVisibility() - } - } - - var isEnabled: Bool = true { - didSet { - guard isEnabled != oldValue else { return } - textView.isEditable = isEnabled - textView.isSelectable = isEnabled - textView.textColor = isEnabled ? .labelColor : .disabledControlTextColor - textView.insertionPointColor = .controlAccentColor - } - } - - override var isFlipped: Bool { true } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setContentHuggingPriority(.defaultLow, for: .horizontal) - setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - - textView.isEditable = true - textView.isSelectable = true - textView.isRichText = false - textView.importsGraphics = false - textView.isHorizontallyResizable = false - textView.isVerticallyResizable = true - textView.backgroundColor = .clear - textView.drawsBackground = false - textView.textContainerInset = Self.textInset - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.heightTracksTextView = false - textView.minSize = .zero - textView.maxSize = NSSize( - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - ) - addSubview(textView) - - placeholderField.textColor = .placeholderTextColor - placeholderField.lineBreakMode = .byWordWrapping - placeholderField.maximumNumberOfLines = 0 - addSubview(placeholderField) - - apply(font: currentFont, isEnabled: true) - updatePlaceholderVisibility() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var intrinsicContentSize: NSSize { - NSSize(width: NSView.noIntrinsicMetric, height: fittingHeight()) - } - - override func mouseDown(with event: NSEvent) { - _ = window?.makeFirstResponder(textView) - super.mouseDown(with: event) - } - - override func layout() { - super.layout() - let availableWidth = max(bounds.width, 1) - let height = fittingHeight(for: availableWidth) - textView.frame = NSRect(x: 0, y: 0, width: availableWidth, height: height) - placeholderField.frame = NSRect( - x: Self.textInset.width, - y: Self.textInset.height, - width: max(bounds.width - Self.textInset.width * 2, 1), - height: Self.minimumHeight(for: currentFont) - ) - } - - func apply(font: NSFont, isEnabled: Bool) { - let fontChanged = currentFont != font || textView.font != font || placeholderField.font != font - let enabledChanged = self.isEnabled != isEnabled - - if fontChanged { - currentFont = font - textView.font = font - placeholderField.font = font - textView.textColor = self.isEnabled ? .labelColor : .disabledControlTextColor - textView.insertionPointColor = .controlAccentColor - } - if enabledChanged { - self.isEnabled = isEnabled - } - if fontChanged || enabledChanged { - refreshMetrics() - } - } - - func refreshMetrics() { - updatePlaceholderVisibility() - needsLayout = true - invalidateIntrinsicContentSize() - layoutSubtreeIfNeeded() - } - - func focusIfNeeded() { - guard let window, window.firstResponder !== textView else { return } - window.makeFirstResponder(textView) - let length = (textView.string as NSString).length - textView.setSelectedRange(NSRange(location: length, length: 0)) - } - - func fittingHeight(for width: CGFloat) -> CGFloat { - guard let layoutManager = textView.layoutManager, - let textContainer = textView.textContainer else { - return Self.minimumHeight(for: currentFont) - } - let availableWidth = max(width - Self.textInset.width * 2, 1) - textContainer.containerSize = NSSize( - width: availableWidth, - height: CGFloat.greatestFiniteMagnitude - ) - layoutManager.ensureLayout(for: textContainer) - let usedRect = layoutManager.usedRect(for: textContainer) - let extraLineHeight = layoutManager.extraLineFragmentTextContainer == textContainer - ? layoutManager.extraLineFragmentRect.height - : 0 - let lineHeight = ceil(currentFont.ascender - currentFont.descender + currentFont.leading) - let contentHeight = max(lineHeight, ceil(usedRect.height + extraLineHeight)) - return max( - Self.minimumHeight(for: currentFont), - ceil(contentHeight + Self.textInset.height * 2) - ) - } - - private func updateTextViewLayout() { - let availableWidth = max(bounds.width, 1) - let height = fittingHeight(for: availableWidth) - textView.frame = NSRect(x: 0, y: 0, width: availableWidth, height: height) - } - - private func fittingHeight() -> CGFloat { - guard bounds.width > 1 else { - return Self.minimumHeight(for: currentFont) - } - let availableWidth = max(bounds.width, 1) - return fittingHeight(for: availableWidth) - } - - private func updatePlaceholderVisibility() { - placeholderField.isHidden = !textView.string.isEmpty - } -} - -struct FeedInlineTextField: NSViewRepresentable { - @Binding var text: String - - let focusRequest: Int? - let placeholder: String - let isEnabled: Bool - let font: NSFont - let placement: FeedPlacement - let focusScopeID: UUID - let onFocus: () -> Void - let onBlur: () -> Void - let onSubmit: (() -> Void)? - - @MainActor - final class Coordinator: NSObject, NSTextViewDelegate { - var parent: FeedInlineTextField - var isProgrammaticMutation = false - weak var view: FeedInlineTextEditorView? - var lastAppliedFocusRequest: Int? - - init(parent: FeedInlineTextField) { - self.parent = parent - self.lastAppliedFocusRequest = parent.focusRequest - } - - func activateField() { -#if DEBUG - dlog("feed.editor.activateField") -#endif - parent.onFocus() - } - - func blurField() { - guard let view, let window = view.window, window.firstResponder === view.textView else { - return - } -#if DEBUG - dlog("feed.editor.blurField frBefore=\(feedDebugResponderSummary(window.firstResponder))") -#endif - guard parent.placement.usesRightSidebarFocusCoordinator else { - window.makeFirstResponder(nil) - return - } - if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( - mode: .feed, - focusFirstItem: false, - preferredWindow: window - ) != true { - window.makeFirstResponder(nil) - } - } - - func textDidBeginEditing(_ notification: Notification) { - activateField() - } - - func textDidChange(_ notification: Notification) { - guard !isProgrammaticMutation else { return } - guard let textView = notification.object as? NSTextView else { return } - parent.text = textView.string - view?.refreshMetrics() - } - - func textDidEndEditing(_ notification: Notification) { - if !isProgrammaticMutation, let textView = notification.object as? NSTextView { - parent.text = textView.string - } - guard let window = view?.window else { - parent.onBlur() - return - } - let responder = window.firstResponder - if !(responder is FeedKeyboardFocusView) && !(responder is FeedInlineNativeTextView) { - parent.onBlur() - } - } - - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - func makeNSView(context: Context) -> FeedInlineTextEditorView { - let view = FeedInlineTextEditorView(frame: .zero) - view.textView.delegate = context.coordinator - view.textView.string = text - view.textView.onActivate = { [weak coordinator = context.coordinator] in - coordinator?.activateField() - } - view.textView.onEscape = { [weak coordinator = context.coordinator] in - coordinator?.blurField() - } - view.textView.onSubmit = onSubmit - configure(view) - context.coordinator.view = view - return view - } - - func updateNSView(_ nsView: FeedInlineTextEditorView, context: Context) { - context.coordinator.parent = self - context.coordinator.view = nsView - nsView.textView.onActivate = { [weak coordinator = context.coordinator] in - coordinator?.activateField() - } - nsView.textView.onEscape = { [weak coordinator = context.coordinator] in - coordinator?.blurField() - } - nsView.textView.onSubmit = onSubmit - configure(nsView) - - if nsView.textView.string != text, !nsView.textView.hasMarkedText() { - context.coordinator.isProgrammaticMutation = true - nsView.textView.string = text - context.coordinator.isProgrammaticMutation = false - nsView.refreshMetrics() - } - - guard let window = nsView.window else { return } - let isFirstResponder = window.firstResponder === nsView.textView - if let focusRequest, - focusRequest != context.coordinator.lastAppliedFocusRequest { - context.coordinator.lastAppliedFocusRequest = focusRequest - if isEnabled { - nsView.focusIfNeeded() - } else if isFirstResponder { - moveFocusToFeedHost(in: window) - } - } else if focusRequest == nil { - context.coordinator.lastAppliedFocusRequest = nil - if !isEnabled, isFirstResponder { - moveFocusToFeedHost(in: window) - } - } else if !isEnabled, isFirstResponder { - moveFocusToFeedHost(in: window) - } - } - - private func moveFocusToFeedHost(in window: NSWindow) { - guard placement.usesRightSidebarFocusCoordinator else { - window.makeFirstResponder(nil) - return - } - if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( - mode: .feed, - focusFirstItem: false, - preferredWindow: window - ) == true { - return - } - window.makeFirstResponder(nil) - } - - private func configure(_ view: FeedInlineTextEditorView) { - view.placeholder = placeholder - view.textView.feedFocusScopeID = focusScopeID - view.apply(font: font, isEnabled: isEnabled) - } - - func sizeThatFits( - _ proposal: ProposedViewSize, - nsView: FeedInlineTextEditorView, - context: Context - ) -> CGSize? { - nil - } - - static func dismantleNSView(_ nsView: FeedInlineTextEditorView, coordinator: Coordinator) { - nsView.textView.delegate = nil - nsView.textView.onActivate = nil - nsView.textView.onEscape = nil - nsView.textView.onSubmit = nil - } -} - -/// Minimal wrapping HStack that flows its children into multiple rows. -private struct WrapHStack: View { - let spacing: CGFloat - let content: () -> Content - - init(spacing: CGFloat = 4, @ViewBuilder content: @escaping () -> Content) { - self.spacing = spacing - self.content = content - } - - var body: some View { - FlowLayout(spacing: spacing) { - content() - } - } -} - -private struct FlowLayout: Layout { - var spacing: CGFloat = 4 - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - let maxWidth = proposal.width ?? .infinity - var currentX: CGFloat = 0 - var currentRowHeight: CGFloat = 0 - var totalHeight: CGFloat = 0 - var totalWidth: CGFloat = 0 - for sub in subviews { - let size = sub.sizeThatFits(.unspecified) - if currentX + size.width > maxWidth && currentX > 0 { - totalHeight += currentRowHeight + spacing - totalWidth = max(totalWidth, currentX - spacing) - currentX = 0 - currentRowHeight = 0 - } - currentX += size.width + spacing - currentRowHeight = max(currentRowHeight, size.height) - } - totalHeight += currentRowHeight - totalWidth = max(totalWidth, currentX - spacing) - return CGSize(width: totalWidth, height: totalHeight) - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - var x: CGFloat = bounds.minX - var y: CGFloat = bounds.minY - var rowHeight: CGFloat = 0 - for sub in subviews { - let size = sub.sizeThatFits(.unspecified) - if x + size.width > bounds.maxX && x > bounds.minX { - x = bounds.minX - y += rowHeight + spacing - rowHeight = 0 - } - sub.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) - x += size.width + spacing - rowHeight = max(rowHeight, size.height) - } - } -} - -/// Renders a Stop event (Claude finished a turn and is waiting for -/// the next user prompt). Shows a text field + Send button that -/// types the reply into the agent's terminal surface and presses -/// Return — so the user can reply without switching focus. diff --git a/Sources/Feed/FeedResolvedDivider.swift b/Sources/Feed/FeedResolvedDivider.swift new file mode 100644 index 000000000000..6642498182aa --- /dev/null +++ b/Sources/Feed/FeedResolvedDivider.swift @@ -0,0 +1,28 @@ +import AppKit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +struct ResolvedDivider: View { + var body: some View { + HStack(spacing: 8) { + line + Text(String(localized: "feed.divider.resolved", defaultValue: "Resolved")) + .cmuxFont(size: 10, weight: .medium) + .tracking(0.5) + .foregroundColor(.secondary.opacity(0.7)) + line + } + .padding(.vertical, 2) + } + + private var line: some View { + Rectangle() + .fill(Color.primary.opacity(0.08)) + .frame(height: 1) + } +} + +// MARK: - Kind → SF Symbol + diff --git a/Sources/Feed/FeedRowActions.swift b/Sources/Feed/FeedRowActions.swift new file mode 100644 index 000000000000..456f0355d2ea --- /dev/null +++ b/Sources/Feed/FeedRowActions.swift @@ -0,0 +1,55 @@ +import CMUXAgentLaunch + +/// Closure bundle; binds to `FeedCoordinator` by default. +struct FeedRowActions { + let approvePermission: (String, WorkstreamPermissionMode) -> Void + let replyQuestion: (String, [String]) -> Void + let approveExitPlan: (String, WorkstreamExitPlanMode, String?) -> Void + let jump: (String) -> Void + /// Types the user's reply into the agent's terminal surface and + /// presses Return. Used by Stop-kind cards so the user can nudge + /// Claude without switching focus to the terminal. + let sendText: (String, String) -> Void + + static func bound() -> FeedRowActions { + FeedRowActions( + approvePermission: { requestID, mode in + Task { + await FeedCoordinator.shared.deliverReply( + requestId: requestID, + decision: .permission(mode) + ) + } + }, + replyQuestion: { requestID, selections in + Task { + await FeedCoordinator.shared.deliverReply( + requestId: requestID, + decision: .question(selections: selections) + ) + } + }, + approveExitPlan: { requestID, mode, feedback in + Task { + await FeedCoordinator.shared.deliverReply( + requestId: requestID, + decision: .exitPlan(mode, feedback: feedback) + ) + } + }, + jump: { workstreamId in + Task { + _ = await FeedCoordinator.shared.focusIfPossible(workstreamId: workstreamId) + } + }, + sendText: { workstreamId, text in + Task { + await FeedCoordinator.shared.sendTextToWorkstream( + workstreamId: workstreamId, + text: text + ) + } + } + ) + } +} diff --git a/Sources/Feed/FeedRowSurface.swift b/Sources/Feed/FeedRowSurface.swift new file mode 100644 index 000000000000..62f168ba2fb0 --- /dev/null +++ b/Sources/Feed/FeedRowSurface.swift @@ -0,0 +1,90 @@ +import AppKit +import Bonsplit +import CMUXAgentLaunch +import Foundation +import SwiftUI + +struct FeedRowSurface: View { + let snapshot: FeedItemSnapshot + let actions: FeedRowActions + let isSelected: Bool + let isFocusActive: Bool + let showsDivider: Bool + @Binding var stopDraft: FeedStopDraft + let placement: FeedPlacement + let focusScopeID: UUID + let onPressSelect: () -> Void + let onControlFocus: () -> Void + let onControlAction: () -> Void + let onControlBlur: () -> Void + let onActivate: () -> Void + + @State private var isHovered = false + @State private var stopReplyFocusRequest = 0 + + var body: some View { + VStack(spacing: 0) { + FeedItemRow( + snapshot: snapshot, + actions: actions, + isSelected: isFocusActive, + onPressSelect: onPressSelect, + onControlFocus: onControlFocus, + onControlAction: onControlAction, + onControlBlur: onControlBlur, + onActivate: onActivate, + stopDraft: $stopDraft, + stopDraftValue: stopDraft, + stopFocusRequest: $stopReplyFocusRequest, + stopFocusRequestValue: stopReplyFocusRequest, + placement: placement, + focusScopeID: focusScopeID + ) + .equatable() + if showsDivider { + Rectangle() + .fill(Color.primary.opacity(0.08)) + .frame(maxWidth: .infinity) + .frame(height: 1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackgroundFill) + .animation(.easeOut(duration: 0.14), value: isHovered) + .animation(.easeOut(duration: 0.14), value: isSelected) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.14)) { + isHovered = hovering + } + } + } + + private var rowBackgroundFill: Color { + if isSelected { + guard isFocusActive else { + return Color.primary.opacity(0.07) + } + if snapshot.status.isPending { + return tint.opacity(0.14) + } + return Color.primary.opacity(0.075) + } + if isHovered { + if snapshot.status.isPending { + return tint.opacity(0.10) + } + return Color.primary.opacity(0.055) + } + return .clear + } + + private var tint: Color { + switch snapshot.kind { + case .permissionRequest: return .orange + case .exitPlan: return .purple + case .question: return .blue + default: return snapshot.status.isPending ? .orange : .secondary.opacity(0.8) + } + } +} + diff --git a/Sources/Feed/FeedScopedKeyboardFocusResponder.swift b/Sources/Feed/FeedScopedKeyboardFocusResponder.swift new file mode 100644 index 000000000000..4ec6650ec008 --- /dev/null +++ b/Sources/Feed/FeedScopedKeyboardFocusResponder.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol FeedScopedKeyboardFocusResponder: FeedKeyboardFocusResponder { + var feedFocusScopeID: UUID { get } +} diff --git a/Sources/Feed/FeedScrollContentMargins.swift b/Sources/Feed/FeedScrollContentMargins.swift new file mode 100644 index 000000000000..c6c5c0349528 --- /dev/null +++ b/Sources/Feed/FeedScrollContentMargins.swift @@ -0,0 +1,17 @@ +import AppKit +import Bonsplit +import CMUXAgentLaunch +import Foundation +import SwiftUI + +extension View { + @ViewBuilder + func feedZeroScrollContentMargins() -> some View { + if #available(macOS 14.0, *) { + contentMargins(.all, 0, for: .scrollContent) + } else { + self + } + } +} + diff --git a/Sources/Feed/FeedScrollRequest.swift b/Sources/Feed/FeedScrollRequest.swift new file mode 100644 index 000000000000..0ac2a41d63cf --- /dev/null +++ b/Sources/Feed/FeedScrollRequest.swift @@ -0,0 +1,6 @@ +import Foundation + +struct FeedScrollRequest: Equatable { + let id: UUID + let sequence: Int +} diff --git a/Sources/Feed/FeedStopDraft.swift b/Sources/Feed/FeedStopDraft.swift new file mode 100644 index 000000000000..64ad7530c078 --- /dev/null +++ b/Sources/Feed/FeedStopDraft.swift @@ -0,0 +1,14 @@ +import AppKit +import Bonsplit +import CMUXAgentLaunch +import Foundation +import SwiftUI + +struct FeedStopDraft: Equatable { + var reply = "" + + var isPristine: Bool { + reply.isEmpty + } +} + diff --git a/Sources/Feed/FeedTelemetryActionArea.swift b/Sources/Feed/FeedTelemetryActionArea.swift new file mode 100644 index 000000000000..6a9f91c7a4b5 --- /dev/null +++ b/Sources/Feed/FeedTelemetryActionArea.swift @@ -0,0 +1,55 @@ +import AppKit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +struct TelemetryActionArea: View { + let snapshot: FeedItemSnapshot + + var body: some View { + if case .todos(let todos) = snapshot.payload { + TodoListBody(todos: todos) + } else if case .assistantMessage(let text) = snapshot.payload, + snapshot.source == .claude { + FeedMarkdownInlineText( + text: text, + fontSize: 11, + foregroundColor: .secondary.opacity(0.85) + ) + .lineLimit(3) + .truncationMode(.tail) + } else if !summary.isEmpty { + Text(summary) + .cmuxFont(size: 11, design: .monospaced) + .foregroundColor(.secondary.opacity(0.85)) + .lineLimit(3) + .truncationMode(.tail) + } + } + + private var summary: String { + switch snapshot.payload { + case .toolUse(let name, let json): + return "\(name) \(json)" + case .toolResult(let name, let json, let err): + let status = err + ? String(localized: "feed.telemetry.error", defaultValue: "error") + : String(localized: "feed.telemetry.ok", defaultValue: "ok") + return "\(name) \(status) \(json)" + case .userPrompt(let text), .assistantMessage(let text): + return text + case .sessionStart: + return String(localized: "feed.telemetry.sessionStart", defaultValue: "session start") + case .sessionEnd: + return String(localized: "feed.telemetry.sessionEnd", defaultValue: "session end") + case .stop(let reason): + let label = String(localized: "feed.telemetry.stop", defaultValue: "stop") + guard let reason, !reason.isEmpty else { return label } + return "\(label) \(reason)" + default: + return "" + } + } +} + diff --git a/Sources/Feed/FeedTelemetryView.swift b/Sources/Feed/FeedTelemetryView.swift index 7901b7053bd5..e20706138389 100644 --- a/Sources/Feed/FeedTelemetryView.swift +++ b/Sources/Feed/FeedTelemetryView.swift @@ -97,232 +97,6 @@ struct StopActionArea: View { } } -struct TelemetryActionArea: View { - let snapshot: FeedItemSnapshot - - var body: some View { - if case .todos(let todos) = snapshot.payload { - TodoListBody(todos: todos) - } else if case .assistantMessage(let text) = snapshot.payload, - snapshot.source == .claude { - FeedMarkdownInlineText( - text: text, - fontSize: 11, - foregroundColor: .secondary.opacity(0.85) - ) - .lineLimit(3) - .truncationMode(.tail) - } else if !summary.isEmpty { - Text(summary) - .cmuxFont(size: 11, design: .monospaced) - .foregroundColor(.secondary.opacity(0.85)) - .lineLimit(3) - .truncationMode(.tail) - } - } - - private var summary: String { - switch snapshot.payload { - case .toolUse(let name, let json): - return "\(name) \(json)" - case .toolResult(let name, let json, let err): - let status = err - ? String(localized: "feed.telemetry.error", defaultValue: "error") - : String(localized: "feed.telemetry.ok", defaultValue: "ok") - return "\(name) \(status) \(json)" - case .userPrompt(let text), .assistantMessage(let text): - return text - case .sessionStart: - return String(localized: "feed.telemetry.sessionStart", defaultValue: "session start") - case .sessionEnd: - return String(localized: "feed.telemetry.sessionEnd", defaultValue: "session end") - case .stop(let reason): - let label = String(localized: "feed.telemetry.stop", defaultValue: "stop") - guard let reason, !reason.isEmpty else { return label } - return "\(label) \(reason)" - default: - return "" - } - } -} - -private struct TodoListBody: View { - let todos: [WorkstreamTaskTodo] - private let done: [WorkstreamTaskTodo] - private let inProgress: [WorkstreamTaskTodo] - private let pending: [WorkstreamTaskTodo] - - @State private var expanded = false - - init(todos: [WorkstreamTaskTodo]) { - self.todos = todos - var done: [WorkstreamTaskTodo] = [] - var inProgress: [WorkstreamTaskTodo] = [] - var pending: [WorkstreamTaskTodo] = [] - for todo in todos { - switch todo.state { - case .completed: done.append(todo) - case .inProgress: inProgress.append(todo) - case .pending: pending.append(todo) - } - } - self.done = done - self.inProgress = inProgress - self.pending = pending - } - - private var visibleDone: [WorkstreamTaskTodo] { - expanded ? done : Array(done.prefix(2)) - } - - var body: some View { - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 4) { - Text(String(localized: "feed.todos.title", defaultValue: "Tasks")) - .cmuxFont(size: 11, weight: .semibold) - .foregroundColor(.primary.opacity(0.9)) - Text(summaryLabel) - .cmuxFont(size: 10) - .foregroundColor(.secondary) - } - VStack(alignment: .leading, spacing: 3) { - ForEach(inProgress, id: \.id) { row($0) } - ForEach(pending, id: \.id) { row($0) } - ForEach(visibleDone, id: \.id) { row($0) } - if done.count > visibleDone.count { - Button { - expanded.toggle() - } label: { - Text(moreCompletedLabel(done.count - visibleDone.count)) - .cmuxFont(size: 11) - .foregroundColor(.secondary.opacity(0.8)) - .padding(.leading, 22) - } - .buttonStyle(.plain) - } - if expanded && done.count > 2 { - Button { expanded = false } label: { - Text(String(localized: "feed.todos.collapse", defaultValue: "Collapse")) - .cmuxFont(size: 11) - .foregroundColor(.secondary.opacity(0.8)) - .padding(.leading, 22) - } - .buttonStyle(.plain) - } - } - } - } - - private var summaryLabel: String { - let d = done.count, ip = inProgress.count, p = pending.count - var parts: [String] = [] - if d > 0 { - parts.append(doneLabel(d)) - } - if ip > 0 { - parts.append(inProgressLabel(ip)) - } - if p > 0 { - parts.append(openLabel(p)) - } - return "(" + parts.joined(separator: ", ") + ")" - } - - private func moreCompletedLabel(_ count: Int) -> String { - if count == 1 { - return String(localized: "feed.todos.moreCompleted.one", defaultValue: "... +1 completed") - } - return String( - localized: "feed.todos.moreCompleted.other", - defaultValue: "... +\(count) completed" - ) - } - - private func doneLabel(_ count: Int) -> String { - if count == 1 { - return String(localized: "feed.todos.summary.done.one", defaultValue: "1 done") - } - return String(localized: "feed.todos.summary.done.other", defaultValue: "\(count) done") - } - - private func inProgressLabel(_ count: Int) -> String { - if count == 1 { - return String( - localized: "feed.todos.summary.inProgress.one", - defaultValue: "1 in progress" - ) - } - return String( - localized: "feed.todos.summary.inProgress.other", - defaultValue: "\(count) in progress" - ) - } - - private func openLabel(_ count: Int) -> String { - if count == 1 { - return String(localized: "feed.todos.summary.open.one", defaultValue: "1 open") - } - return String(localized: "feed.todos.summary.open.other", defaultValue: "\(count) open") - } - - @ViewBuilder - private func row(_ todo: WorkstreamTaskTodo) -> some View { - HStack(alignment: .top, spacing: 6) { - Image(systemName: symbol(for: todo.state)) - .cmuxFont(size: 11, weight: .medium) - .foregroundColor(color(for: todo.state)) - .frame(width: 14, height: 14) - Text(todo.content) - .cmuxFont(size: 12) - .foregroundColor(todo.state == .completed - ? .secondary.opacity(0.7) - : .primary.opacity(0.9)) - .strikethrough(todo.state == .completed, color: .secondary.opacity(0.6)) - .fixedSize(horizontal: false, vertical: true) - Spacer() - } - } - - private func symbol(for state: WorkstreamTaskTodo.State) -> String { - switch state { - case .completed: return "checkmark.square.fill" - case .inProgress: return "circle.fill" - case .pending: return "square" - } - } - - private func color(for state: WorkstreamTaskTodo.State) -> Color { - switch state { - case .completed: return .secondary.opacity(0.7) - case .inProgress: return .blue - case .pending: return .secondary - } - } -} - -/// Dashed separator between pending items and resolved ones. -private struct ResolvedDivider: View { - var body: some View { - HStack(spacing: 8) { - line - Text(String(localized: "feed.divider.resolved", defaultValue: "Resolved")) - .cmuxFont(size: 10, weight: .medium) - .tracking(0.5) - .foregroundColor(.secondary.opacity(0.7)) - line - } - .padding(.vertical, 2) - } - - private var line: some View { - Rectangle() - .fill(Color.primary.opacity(0.08)) - .frame(height: 1) - } -} - -// MARK: - Kind → SF Symbol - extension WorkstreamKind { var symbolName: String { switch self { diff --git a/Sources/Feed/FeedTodoListBody.swift b/Sources/Feed/FeedTodoListBody.swift new file mode 100644 index 000000000000..51b2eb969bad --- /dev/null +++ b/Sources/Feed/FeedTodoListBody.swift @@ -0,0 +1,161 @@ +import AppKit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +struct TodoListBody: View { + let todos: [WorkstreamTaskTodo] + private let done: [WorkstreamTaskTodo] + private let inProgress: [WorkstreamTaskTodo] + private let pending: [WorkstreamTaskTodo] + + @State private var expanded = false + + init(todos: [WorkstreamTaskTodo]) { + self.todos = todos + var done: [WorkstreamTaskTodo] = [] + var inProgress: [WorkstreamTaskTodo] = [] + var pending: [WorkstreamTaskTodo] = [] + for todo in todos { + switch todo.state { + case .completed: done.append(todo) + case .inProgress: inProgress.append(todo) + case .pending: pending.append(todo) + } + } + self.done = done + self.inProgress = inProgress + self.pending = pending + } + + private var visibleDone: [WorkstreamTaskTodo] { + expanded ? done : Array(done.prefix(2)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 4) { + Text(String(localized: "feed.todos.title", defaultValue: "Tasks")) + .cmuxFont(size: 11, weight: .semibold) + .foregroundColor(.primary.opacity(0.9)) + Text(summaryLabel) + .cmuxFont(size: 10) + .foregroundColor(.secondary) + } + VStack(alignment: .leading, spacing: 3) { + ForEach(inProgress, id: \.id) { row($0) } + ForEach(pending, id: \.id) { row($0) } + ForEach(visibleDone, id: \.id) { row($0) } + if done.count > visibleDone.count { + Button { + expanded.toggle() + } label: { + Text(moreCompletedLabel(done.count - visibleDone.count)) + .cmuxFont(size: 11) + .foregroundColor(.secondary.opacity(0.8)) + .padding(.leading, 22) + } + .buttonStyle(.plain) + } + if expanded && done.count > 2 { + Button { expanded = false } label: { + Text(String(localized: "feed.todos.collapse", defaultValue: "Collapse")) + .cmuxFont(size: 11) + .foregroundColor(.secondary.opacity(0.8)) + .padding(.leading, 22) + } + .buttonStyle(.plain) + } + } + } + } + + private var summaryLabel: String { + let d = done.count, ip = inProgress.count, p = pending.count + var parts: [String] = [] + if d > 0 { + parts.append(doneLabel(d)) + } + if ip > 0 { + parts.append(inProgressLabel(ip)) + } + if p > 0 { + parts.append(openLabel(p)) + } + return "(" + parts.joined(separator: ", ") + ")" + } + + private func moreCompletedLabel(_ count: Int) -> String { + if count == 1 { + return String(localized: "feed.todos.moreCompleted.one", defaultValue: "... +1 completed") + } + return String( + localized: "feed.todos.moreCompleted.other", + defaultValue: "... +\(count) completed" + ) + } + + private func doneLabel(_ count: Int) -> String { + if count == 1 { + return String(localized: "feed.todos.summary.done.one", defaultValue: "1 done") + } + return String(localized: "feed.todos.summary.done.other", defaultValue: "\(count) done") + } + + private func inProgressLabel(_ count: Int) -> String { + if count == 1 { + return String( + localized: "feed.todos.summary.inProgress.one", + defaultValue: "1 in progress" + ) + } + return String( + localized: "feed.todos.summary.inProgress.other", + defaultValue: "\(count) in progress" + ) + } + + private func openLabel(_ count: Int) -> String { + if count == 1 { + return String(localized: "feed.todos.summary.open.one", defaultValue: "1 open") + } + return String(localized: "feed.todos.summary.open.other", defaultValue: "\(count) open") + } + + @ViewBuilder + private func row(_ todo: WorkstreamTaskTodo) -> some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: symbol(for: todo.state)) + .cmuxFont(size: 11, weight: .medium) + .foregroundColor(color(for: todo.state)) + .frame(width: 14, height: 14) + Text(todo.content) + .cmuxFont(size: 12) + .foregroundColor(todo.state == .completed + ? .secondary.opacity(0.7) + : .primary.opacity(0.9)) + .strikethrough(todo.state == .completed, color: .secondary.opacity(0.6)) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } + } + + private func symbol(for state: WorkstreamTaskTodo.State) -> String { + switch state { + case .completed: return "checkmark.square.fill" + case .inProgress: return "circle.fill" + case .pending: return "square" + } + } + + private func color(for state: WorkstreamTaskTodo.State) -> Color { + switch state { + case .completed: return .secondary.opacity(0.7) + case .inProgress: return .blue + case .pending: return .secondary + } + } +} + +/// Dashed separator between pending items and resolved ones. diff --git a/Sources/Feed/FeedWrapHStack.swift b/Sources/Feed/FeedWrapHStack.swift new file mode 100644 index 000000000000..85c232b1b2e1 --- /dev/null +++ b/Sources/Feed/FeedWrapHStack.swift @@ -0,0 +1,23 @@ +import AppKit +import Bonsplit +import CMUXAgentLaunch +import CmuxFoundation +import Foundation +import SwiftUI + +struct WrapHStack: View { + let spacing: CGFloat + let content: () -> Content + + init(spacing: CGFloat = 4, @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.content = content + } + + var body: some View { + FlowLayout(spacing: spacing) { + content() + } + } +} + diff --git a/Sources/Feed/PermissionInputPreview.swift b/Sources/Feed/PermissionInputPreview.swift new file mode 100644 index 000000000000..ff227629d7c6 --- /dev/null +++ b/Sources/Feed/PermissionInputPreview.swift @@ -0,0 +1,43 @@ +import Foundation + +struct PermissionInputPreview { + let sigil: String? + let primary: String? + let secondary: String? + + init(toolName: String, toolInputJSON: String) { + let dict = (try? JSONSerialization.jsonObject( + with: Data(toolInputJSON.utf8) + )) as? [String: Any] ?? [:] + + switch toolName.lowercased() { + case "bash": + self.sigil = "$" + self.primary = (dict["command"] as? String) ?? toolInputJSON + self.secondary = (dict["description"] as? String) + case "write", "edit", "multiedit": + self.sigil = nil + self.primary = (dict["file_path"] as? String) ?? toolInputJSON + if toolName.lowercased() == "write" { + let content = (dict["content"] as? String) ?? "" + let preview = content.split(separator: "\n").first.map(String.init) ?? "" + self.secondary = preview.isEmpty ? nil : preview + } else { + self.secondary = nil + } + case "read": + self.sigil = nil + self.primary = (dict["file_path"] as? String) ?? toolInputJSON + self.secondary = nil + default: + self.sigil = nil + self.primary = toolInputJSON == "{}" ? nil : toolInputJSON + self.secondary = nil + } + } +} + +/// Single DRY button primitive used across every actionable card +/// (permission / plan / question / filter pills / option pills). +/// Replaces the old PermissionCTAButton / PlanCTAButton / +/// FeedPillButton trio so styling is defined in exactly one place. diff --git a/Sources/MainWindowFocusTypes.swift b/Sources/MainWindowFocusTypes.swift index 85edbd10c3cb..580a0744018d 100644 --- a/Sources/MainWindowFocusTypes.swift +++ b/Sources/MainWindowFocusTypes.swift @@ -10,19 +10,6 @@ struct FeedFocusSnapshot: Equatable { } } -enum FeedPlacement: Equatable, Sendable { - case rightSidebar - case pane - - var usesRightSidebarFocusCoordinator: Bool { - self == .rightSidebar - } -} - -protocol FeedKeyboardFocusResponder: AnyObject { - var feedFocusScopeID: UUID { get } -} - enum MainWindowKeyboardFocusIntent: Equatable { case mainPanel(workspaceId: UUID, panelId: UUID) case rightSidebar(mode: RightSidebarMode) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 4fca2bc6c620..3193e55bf43f 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -790,7 +790,11 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources FEE200000000000000000013 /* FeedActivitySnapshotGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000013 /* FeedActivitySnapshotGroups.swift */; }; FEE200000000000000000012 /* FeedBlockingCallBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000012 /* FeedBlockingCallBridge.swift */; }; FEE200000000000000000002 /* FeedBlockingWaiterRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000002 /* FeedBlockingWaiterRegistry.swift */; }; + F17B00000000000000000001 /* FeedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000001 /* FeedButton.swift */; }; + F17B00000000000000000030 /* FeedButtonKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000030 /* FeedButtonKind.swift */; }; + F17B00000000000000000031 /* FeedButtonSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000031 /* FeedButtonSize.swift */; }; FEED0000000000000000F00D /* FeedButtonStyleDebugWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00C /* FeedButtonStyleDebugWindowController.swift */; }; + F17B00000000000000000002 /* FeedContextBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000002 /* FeedContextBlock.swift */; }; FEE200000000000000000003 /* FeedCoordinator+Attention.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000003 /* FeedCoordinator+Attention.swift */; }; FEE200000000000000000004 /* FeedCoordinator+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000004 /* FeedCoordinator+Notifications.swift */; }; FEE200000000000000000005 /* FeedCoordinator+Socket.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000005 /* FeedCoordinator+Socket.swift */; }; @@ -800,27 +804,58 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources FEED49850000000000000001 /* FeedEventClassificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED49850000000000000002 /* FeedEventClassificationTests.swift */; }; FEEDC1A50000000000000001 /* FeedEventClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDC1A50000000000000002 /* FeedEventClassifier.swift */; }; FEEDC1A50000000000000003 /* FeedEventClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDC1A50000000000000002 /* FeedEventClassifier.swift */; }; + F17B00000000000000000003 /* FeedExitPlanAllowedPromptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000003 /* FeedExitPlanAllowedPromptsView.swift */; }; + F17B00000000000000000004 /* FeedExitPlanPlanFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000004 /* FeedExitPlanPlanFileView.swift */; }; FEE20000000000000000000F /* FeedExitPlanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000F /* FeedExitPlanView.swift */; }; + F17B00000000000000000005 /* FeedFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000005 /* FeedFlowLayout.swift */; }; + F17B00000000000000000006 /* FeedFocusHostReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000006 /* FeedFocusHostReference.swift */; }; + F17D00000000000000000001 /* FeedFocusRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17C00000000000000000001 /* FeedFocusRoutingTests.swift */; }; + F17B00000000000000000007 /* FeedInlineNativeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000007 /* FeedInlineNativeTextView.swift */; }; + F17B00000000000000000008 /* FeedInlinePassthroughLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000008 /* FeedInlinePassthroughLabel.swift */; }; + F17B00000000000000000009 /* FeedInlineTextEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000009 /* FeedInlineTextEditorView.swift */; }; + F17B00000000000000000010 /* FeedInlineTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000010 /* FeedInlineTextField.swift */; }; + F17B00000000000000000032 /* FeedInlineTextFieldCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000032 /* FeedInlineTextFieldCoordinator.swift */; }; FEE20000000000000000000D /* FeedItemRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000D /* FeedItemRow.swift */; }; FEE20000000000000000000C /* FeedItemSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000C /* FeedItemSnapshot.swift */; }; FEE200000000000000000007 /* FeedJumpResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000007 /* FeedJumpResolver.swift */; }; + F17B00000000000000000011 /* FeedJumpTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000011 /* FeedJumpTarget.swift */; }; FEE20000000000000000000B /* FeedKeyboardFocus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000B /* FeedKeyboardFocus.swift */; }; + F17B00000000000000000012 /* FeedKeyboardFocusBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000012 /* FeedKeyboardFocusBridge.swift */; }; + F17B00000000000000000033 /* FeedKeyboardFocusBridgeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000033 /* FeedKeyboardFocusBridgeCoordinator.swift */; }; + F17B00000000000000000013 /* FeedKeyboardFocusResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000013 /* FeedKeyboardFocusResponder.swift */; }; + F17B00000000000000000014 /* FeedLabeledTextRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000014 /* FeedLabeledTextRow.swift */; }; FEE20000000000000000000A /* FeedListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000A /* FeedListView.swift */; }; + F17B00000000000000000015 /* FeedMarkdownInlineText.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000015 /* FeedMarkdownInlineText.swift */; }; FEE200000000000000000008 /* FeedNotificationPolicyContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000008 /* FeedNotificationPolicyContext.swift */; }; FEE200000000000000000015 /* FeedNotificationPolicySnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000015 /* FeedNotificationPolicySnapshot.swift */; }; FEE200000000000000000001 /* FeedOpeningCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000001 /* FeedOpeningCoordinator.swift */; }; FEED0000000000000000F005 /* FeedPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F004 /* FeedPanelView.swift */; }; FEED0000000000000000F011 /* FeedPanelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F010 /* FeedPanelViewModel.swift */; }; + F17B00000000000000000016 /* FeedPendingWaiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000016 /* FeedPendingWaiter.swift */; }; FEED0000000000000000F013 /* FeedPermissionActionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */; }; FEE20000000000000000000E /* FeedPermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE10000000000000000000E /* FeedPermissionView.swift */; }; + F17B00000000000000000017 /* FeedPlacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000017 /* FeedPlacement.swift */; }; + F17B00000000000000000034 /* FeedPlanBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000034 /* FeedPlanBlock.swift */; }; + F17B00000000000000000018 /* FeedPlanBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000018 /* FeedPlanBodyView.swift */; }; + F17B00000000000000000035 /* FeedPlanNumberedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000035 /* FeedPlanNumberedItem.swift */; }; FEE200000000000000000014 /* FeedPresentationSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000014 /* FeedPresentationSnapshot.swift */; }; FEE200000000000000000016 /* FeedPresentationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000016 /* FeedPresentationStore.swift */; }; FEED0000000000000000F00B /* FeedPreviewWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00A /* FeedPreviewWindowController.swift */; }; FEE200000000000000000010 /* FeedQuestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000010 /* FeedQuestionView.swift */; }; + F17B00000000000000000019 /* FeedResolvedDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000019 /* FeedResolvedDivider.swift */; }; + F17B00000000000000000020 /* FeedRowActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000020 /* FeedRowActions.swift */; }; + F17B00000000000000000021 /* FeedRowSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000021 /* FeedRowSurface.swift */; }; + F17B00000000000000000029 /* FeedScopedKeyboardFocusResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000029 /* FeedScopedKeyboardFocusResponder.swift */; }; + F17B00000000000000000022 /* FeedScrollContentMargins.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000022 /* FeedScrollContentMargins.swift */; }; + F17B00000000000000000023 /* FeedScrollRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000023 /* FeedScrollRequest.swift */; }; FEED0000000000000000F009 /* FeedSidebarUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F008 /* FeedSidebarUITests.swift */; }; FEE200000000000000000009 /* FeedSocketEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000009 /* FeedSocketEncoding.swift */; }; + F17B00000000000000000024 /* FeedStopDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000024 /* FeedStopDraft.swift */; }; + F17B00000000000000000025 /* FeedTelemetryActionArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000025 /* FeedTelemetryActionArea.swift */; }; FEE200000000000000000011 /* FeedTelemetryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000011 /* FeedTelemetryView.swift */; }; FEED0000000000000000F00F /* FeedTextEditorDebugWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00E /* FeedTextEditorDebugWindowController.swift */; }; + F17B00000000000000000026 /* FeedTodoListBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000026 /* FeedTodoListBody.swift */; }; + F17B00000000000000000027 /* FeedWrapHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000027 /* FeedWrapHStack.swift */; }; D0B1001AA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1001BA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift */; }; D0B10020A1B2C3D4E5F60001 /* FileDropOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10021A1B2C3D4E5F60001 /* FileDropOverlayView.swift */; }; D0B10022A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10023A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift */; }; @@ -1184,6 +1219,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 6313B6A0 /* PaneMemoryWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6313B6A1 /* PaneMemoryWarning.swift */; }; E30760090000000000000002 /* PassthroughWindowOverlayContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30760090000000000000001 /* PassthroughWindowOverlayContainerView.swift */; }; A5001425 /* PDFPreviewChromeDebugWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001424 /* PDFPreviewChromeDebugWindowController.swift */; }; + F17B00000000000000000028 /* PermissionInputPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000028 /* PermissionInputPreview.swift */; }; D1F0A00400000000000000D1 /* PhoneForwardingMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00400000000000000D2 /* PhoneForwardingMode.swift */; }; D1F0A00100000000000000A1 /* PhonePushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00100000000000000A2 /* PhonePushClient.swift */; }; D1F0A00100000000000000A3 /* PhonePushPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00100000000000000A4 /* PhonePushPayload.swift */; }; @@ -2844,7 +2880,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEE100000000000000000013 /* FeedActivitySnapshotGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedActivitySnapshotGroups.swift; sourceTree = ""; }; FEE100000000000000000012 /* FeedBlockingCallBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedBlockingCallBridge.swift; sourceTree = ""; }; FEE100000000000000000002 /* FeedBlockingWaiterRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedBlockingWaiterRegistry.swift; sourceTree = ""; }; + F17A00000000000000000001 /* FeedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedButton.swift; sourceTree = ""; }; + F17A00000000000000000030 /* FeedButtonKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedButtonKind.swift; sourceTree = ""; }; + F17A00000000000000000031 /* FeedButtonSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedButtonSize.swift; sourceTree = ""; }; FEED0000000000000000F00C /* FeedButtonStyleDebugWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedButtonStyleDebugWindowController.swift; sourceTree = ""; }; + F17A00000000000000000002 /* FeedContextBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedContextBlock.swift; sourceTree = ""; }; FEE100000000000000000003 /* FeedCoordinator+Attention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedCoordinator+Attention.swift"; sourceTree = ""; }; FEE100000000000000000004 /* FeedCoordinator+Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedCoordinator+Notifications.swift"; sourceTree = ""; }; FEE100000000000000000005 /* FeedCoordinator+Socket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedCoordinator+Socket.swift"; sourceTree = ""; }; @@ -2853,27 +2893,58 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEEDC0DEC0DEC0DEC0DE0002 /* FeedCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCoordinatorTests.swift; sourceTree = ""; }; FEED49850000000000000002 /* FeedEventClassificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedEventClassificationTests.swift; sourceTree = ""; }; FEEDC1A50000000000000002 /* FeedEventClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedEventClassifier.swift; sourceTree = ""; }; + F17A00000000000000000003 /* FeedExitPlanAllowedPromptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedExitPlanAllowedPromptsView.swift; sourceTree = ""; }; + F17A00000000000000000004 /* FeedExitPlanPlanFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedExitPlanPlanFileView.swift; sourceTree = ""; }; FEE10000000000000000000F /* FeedExitPlanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedExitPlanView.swift; sourceTree = ""; }; + F17A00000000000000000005 /* FeedFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedFlowLayout.swift; sourceTree = ""; }; + F17A00000000000000000006 /* FeedFocusHostReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedFocusHostReference.swift; sourceTree = ""; }; + F17C00000000000000000001 /* FeedFocusRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedFocusRoutingTests.swift; sourceTree = ""; }; + F17A00000000000000000007 /* FeedInlineNativeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedInlineNativeTextView.swift; sourceTree = ""; }; + F17A00000000000000000008 /* FeedInlinePassthroughLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedInlinePassthroughLabel.swift; sourceTree = ""; }; + F17A00000000000000000009 /* FeedInlineTextEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedInlineTextEditorView.swift; sourceTree = ""; }; + F17A00000000000000000010 /* FeedInlineTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedInlineTextField.swift; sourceTree = ""; }; + F17A00000000000000000032 /* FeedInlineTextFieldCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedInlineTextFieldCoordinator.swift; sourceTree = ""; }; FEE10000000000000000000D /* FeedItemRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemRow.swift; sourceTree = ""; }; FEE10000000000000000000C /* FeedItemSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemSnapshot.swift; sourceTree = ""; }; FEE100000000000000000007 /* FeedJumpResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedJumpResolver.swift; sourceTree = ""; }; + F17A00000000000000000011 /* FeedJumpTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedJumpTarget.swift; sourceTree = ""; }; FEE10000000000000000000B /* FeedKeyboardFocus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedKeyboardFocus.swift; sourceTree = ""; }; + F17A00000000000000000012 /* FeedKeyboardFocusBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedKeyboardFocusBridge.swift; sourceTree = ""; }; + F17A00000000000000000033 /* FeedKeyboardFocusBridgeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedKeyboardFocusBridgeCoordinator.swift; sourceTree = ""; }; + F17A00000000000000000013 /* FeedKeyboardFocusResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedKeyboardFocusResponder.swift; sourceTree = ""; }; + F17A00000000000000000014 /* FeedLabeledTextRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLabeledTextRow.swift; sourceTree = ""; }; FEE10000000000000000000A /* FeedListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListView.swift; sourceTree = ""; }; + F17A00000000000000000015 /* FeedMarkdownInlineText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedMarkdownInlineText.swift; sourceTree = ""; }; FEE100000000000000000008 /* FeedNotificationPolicyContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedNotificationPolicyContext.swift; sourceTree = ""; }; FEE100000000000000000015 /* FeedNotificationPolicySnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedNotificationPolicySnapshot.swift; sourceTree = ""; }; FEE100000000000000000001 /* FeedOpeningCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedOpeningCoordinator.swift; sourceTree = ""; }; FEED0000000000000000F004 /* FeedPanelView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPanelView.swift; sourceTree = ""; }; FEED0000000000000000F010 /* FeedPanelViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPanelViewModel.swift; sourceTree = ""; }; + F17A00000000000000000016 /* FeedPendingWaiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPendingWaiter.swift; sourceTree = ""; }; FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPermissionActionPolicy.swift; sourceTree = ""; }; FEE10000000000000000000E /* FeedPermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPermissionView.swift; sourceTree = ""; }; + F17A00000000000000000017 /* FeedPlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPlacement.swift; sourceTree = ""; }; + F17A00000000000000000034 /* FeedPlanBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPlanBlock.swift; sourceTree = ""; }; + F17A00000000000000000018 /* FeedPlanBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPlanBodyView.swift; sourceTree = ""; }; + F17A00000000000000000035 /* FeedPlanNumberedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPlanNumberedItem.swift; sourceTree = ""; }; FEE100000000000000000014 /* FeedPresentationSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPresentationSnapshot.swift; sourceTree = ""; }; FEE100000000000000000016 /* FeedPresentationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPresentationStore.swift; sourceTree = ""; }; FEED0000000000000000F00A /* FeedPreviewWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPreviewWindowController.swift; sourceTree = ""; }; FEE100000000000000000010 /* FeedQuestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedQuestionView.swift; sourceTree = ""; }; + F17A00000000000000000019 /* FeedResolvedDivider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedResolvedDivider.swift; sourceTree = ""; }; + F17A00000000000000000020 /* FeedRowActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedRowActions.swift; sourceTree = ""; }; + F17A00000000000000000021 /* FeedRowSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedRowSurface.swift; sourceTree = ""; }; + F17A00000000000000000029 /* FeedScopedKeyboardFocusResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedScopedKeyboardFocusResponder.swift; sourceTree = ""; }; + F17A00000000000000000022 /* FeedScrollContentMargins.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedScrollContentMargins.swift; sourceTree = ""; }; + F17A00000000000000000023 /* FeedScrollRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedScrollRequest.swift; sourceTree = ""; }; FEED0000000000000000F008 /* FeedSidebarUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSidebarUITests.swift; sourceTree = ""; }; FEE100000000000000000009 /* FeedSocketEncoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSocketEncoding.swift; sourceTree = ""; }; + F17A00000000000000000024 /* FeedStopDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStopDraft.swift; sourceTree = ""; }; + F17A00000000000000000025 /* FeedTelemetryActionArea.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedTelemetryActionArea.swift; sourceTree = ""; }; FEE100000000000000000011 /* FeedTelemetryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedTelemetryView.swift; sourceTree = ""; }; FEED0000000000000000F00E /* FeedTextEditorDebugWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedTextEditorDebugWindowController.swift; sourceTree = ""; }; + F17A00000000000000000026 /* FeedTodoListBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedTodoListBody.swift; sourceTree = ""; }; + F17A00000000000000000027 /* FeedWrapHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWrapHStack.swift; sourceTree = ""; }; D0B1001BA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropHintBadgeView.swift; sourceTree = ""; }; D0B10021A1B2C3D4E5F60001 /* FileDropOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayView.swift; sourceTree = ""; }; D0B10023A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayViewHitTesting.swift; sourceTree = ""; }; @@ -3234,6 +3305,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 6313B6A1 /* PaneMemoryWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneMemoryWarning.swift; sourceTree = ""; }; E30760090000000000000001 /* PassthroughWindowOverlayContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassthroughWindowOverlayContainerView.swift; sourceTree = ""; }; A5001424 /* PDFPreviewChromeDebugWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PDFPreviewChromeDebugWindowController.swift; sourceTree = ""; }; + F17A00000000000000000028 /* PermissionInputPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionInputPreview.swift; sourceTree = ""; }; D1F0A00400000000000000D2 /* PhoneForwardingMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneForwardingMode.swift; sourceTree = ""; }; D1F0A00100000000000000A2 /* PhonePushClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PhonePushClient.swift; sourceTree = ""; }; D1F0A00100000000000000A4 /* PhonePushPayload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PhonePushPayload.swift; sourceTree = ""; }; @@ -6107,6 +6179,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */, C9A57402C9A57402C9A57402 /* WorkspaceGroupMoveToMenuStateTests.swift */, FEED49850000000000000002 /* FeedEventClassificationTests.swift */, + F17C00000000000000000001 /* FeedFocusRoutingTests.swift */, 42D69572C8D276745E502B94 /* SessionIndexViewTests.swift */, F1C3F1DBF6BF5D7223C4A30C /* SidebarMarkdownRendererTests.swift */, 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */, @@ -6248,6 +6321,41 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEE100000000000000000013 /* FeedActivitySnapshotGroups.swift */, FEE100000000000000000014 /* FeedPresentationSnapshot.swift */, FEE100000000000000000016 /* FeedPresentationStore.swift */, + F17A00000000000000000001 /* FeedButton.swift */, + F17A00000000000000000002 /* FeedContextBlock.swift */, + F17A00000000000000000003 /* FeedExitPlanAllowedPromptsView.swift */, + F17A00000000000000000004 /* FeedExitPlanPlanFileView.swift */, + F17A00000000000000000005 /* FeedFlowLayout.swift */, + F17A00000000000000000006 /* FeedFocusHostReference.swift */, + F17A00000000000000000007 /* FeedInlineNativeTextView.swift */, + F17A00000000000000000008 /* FeedInlinePassthroughLabel.swift */, + F17A00000000000000000009 /* FeedInlineTextEditorView.swift */, + F17A00000000000000000010 /* FeedInlineTextField.swift */, + F17A00000000000000000011 /* FeedJumpTarget.swift */, + F17A00000000000000000012 /* FeedKeyboardFocusBridge.swift */, + F17A00000000000000000013 /* FeedKeyboardFocusResponder.swift */, + F17A00000000000000000014 /* FeedLabeledTextRow.swift */, + F17A00000000000000000015 /* FeedMarkdownInlineText.swift */, + F17A00000000000000000016 /* FeedPendingWaiter.swift */, + F17A00000000000000000017 /* FeedPlacement.swift */, + F17A00000000000000000018 /* FeedPlanBodyView.swift */, + F17A00000000000000000019 /* FeedResolvedDivider.swift */, + F17A00000000000000000020 /* FeedRowActions.swift */, + F17A00000000000000000021 /* FeedRowSurface.swift */, + F17A00000000000000000022 /* FeedScrollContentMargins.swift */, + F17A00000000000000000023 /* FeedScrollRequest.swift */, + F17A00000000000000000024 /* FeedStopDraft.swift */, + F17A00000000000000000025 /* FeedTelemetryActionArea.swift */, + F17A00000000000000000026 /* FeedTodoListBody.swift */, + F17A00000000000000000027 /* FeedWrapHStack.swift */, + F17A00000000000000000028 /* PermissionInputPreview.swift */, + F17A00000000000000000029 /* FeedScopedKeyboardFocusResponder.swift */, + F17A00000000000000000030 /* FeedButtonKind.swift */, + F17A00000000000000000031 /* FeedButtonSize.swift */, + F17A00000000000000000032 /* FeedInlineTextFieldCoordinator.swift */, + F17A00000000000000000033 /* FeedKeyboardFocusBridgeCoordinator.swift */, + F17A00000000000000000034 /* FeedPlanBlock.swift */, + F17A00000000000000000035 /* FeedPlanNumberedItem.swift */, FEE100000000000000000015 /* FeedNotificationPolicySnapshot.swift */, FEE100000000000000000007 /* FeedJumpResolver.swift */, FEE100000000000000000008 /* FeedNotificationPolicyContext.swift */, @@ -7152,32 +7260,66 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEE200000000000000000013 /* FeedActivitySnapshotGroups.swift in Sources */, FEE200000000000000000012 /* FeedBlockingCallBridge.swift in Sources */, FEE200000000000000000002 /* FeedBlockingWaiterRegistry.swift in Sources */, + F17B00000000000000000001 /* FeedButton.swift in Sources */, + F17B00000000000000000030 /* FeedButtonKind.swift in Sources */, + F17B00000000000000000031 /* FeedButtonSize.swift in Sources */, FEED0000000000000000F00D /* FeedButtonStyleDebugWindowController.swift in Sources */, + F17B00000000000000000002 /* FeedContextBlock.swift in Sources */, FEE200000000000000000003 /* FeedCoordinator+Attention.swift in Sources */, FEE200000000000000000004 /* FeedCoordinator+Notifications.swift in Sources */, FEE200000000000000000005 /* FeedCoordinator+Socket.swift in Sources */, FEED0000000000000000F002 /* FeedCoordinator.swift in Sources */, FEE200000000000000000006 /* FeedCoordinatorState.swift in Sources */, + F17B00000000000000000003 /* FeedExitPlanAllowedPromptsView.swift in Sources */, + F17B00000000000000000004 /* FeedExitPlanPlanFileView.swift in Sources */, FEE20000000000000000000F /* FeedExitPlanView.swift in Sources */, + F17B00000000000000000005 /* FeedFlowLayout.swift in Sources */, + F17B00000000000000000006 /* FeedFocusHostReference.swift in Sources */, + F17B00000000000000000007 /* FeedInlineNativeTextView.swift in Sources */, + F17B00000000000000000008 /* FeedInlinePassthroughLabel.swift in Sources */, + F17B00000000000000000009 /* FeedInlineTextEditorView.swift in Sources */, + F17B00000000000000000010 /* FeedInlineTextField.swift in Sources */, + F17B00000000000000000032 /* FeedInlineTextFieldCoordinator.swift in Sources */, FEE20000000000000000000D /* FeedItemRow.swift in Sources */, FEE20000000000000000000C /* FeedItemSnapshot.swift in Sources */, FEE200000000000000000007 /* FeedJumpResolver.swift in Sources */, + F17B00000000000000000011 /* FeedJumpTarget.swift in Sources */, FEE20000000000000000000B /* FeedKeyboardFocus.swift in Sources */, + F17B00000000000000000012 /* FeedKeyboardFocusBridge.swift in Sources */, + F17B00000000000000000033 /* FeedKeyboardFocusBridgeCoordinator.swift in Sources */, + F17B00000000000000000013 /* FeedKeyboardFocusResponder.swift in Sources */, + F17B00000000000000000014 /* FeedLabeledTextRow.swift in Sources */, FEE20000000000000000000A /* FeedListView.swift in Sources */, + F17B00000000000000000015 /* FeedMarkdownInlineText.swift in Sources */, FEE200000000000000000008 /* FeedNotificationPolicyContext.swift in Sources */, FEE200000000000000000015 /* FeedNotificationPolicySnapshot.swift in Sources */, FEE200000000000000000001 /* FeedOpeningCoordinator.swift in Sources */, FEED0000000000000000F005 /* FeedPanelView.swift in Sources */, FEED0000000000000000F011 /* FeedPanelViewModel.swift in Sources */, + F17B00000000000000000016 /* FeedPendingWaiter.swift in Sources */, FEED0000000000000000F013 /* FeedPermissionActionPolicy.swift in Sources */, FEE20000000000000000000E /* FeedPermissionView.swift in Sources */, + F17B00000000000000000017 /* FeedPlacement.swift in Sources */, + F17B00000000000000000034 /* FeedPlanBlock.swift in Sources */, + F17B00000000000000000018 /* FeedPlanBodyView.swift in Sources */, + F17B00000000000000000035 /* FeedPlanNumberedItem.swift in Sources */, FEE200000000000000000014 /* FeedPresentationSnapshot.swift in Sources */, FEE200000000000000000016 /* FeedPresentationStore.swift in Sources */, FEED0000000000000000F00B /* FeedPreviewWindowController.swift in Sources */, FEE200000000000000000010 /* FeedQuestionView.swift in Sources */, + F17B00000000000000000019 /* FeedResolvedDivider.swift in Sources */, + F17B00000000000000000020 /* FeedRowActions.swift in Sources */, + F17B00000000000000000021 /* FeedRowSurface.swift in Sources */, + F17B00000000000000000029 /* FeedScopedKeyboardFocusResponder.swift in Sources */, + F17B00000000000000000022 /* FeedScrollContentMargins.swift in Sources */, + F17B00000000000000000023 /* FeedScrollRequest.swift in Sources */, FEE200000000000000000009 /* FeedSocketEncoding.swift in Sources */, + F17B00000000000000000024 /* FeedStopDraft.swift in Sources */, + F17B00000000000000000025 /* FeedTelemetryActionArea.swift in Sources */, FEE200000000000000000011 /* FeedTelemetryView.swift in Sources */, FEED0000000000000000F00F /* FeedTextEditorDebugWindowController.swift in Sources */, + F17B00000000000000000026 /* FeedTodoListBody.swift in Sources */, + F17B00000000000000000027 /* FeedWrapHStack.swift in Sources */, D0B1001AA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift in Sources */, D0B10020A1B2C3D4E5F60001 /* FileDropOverlayView.swift in Sources */, D0B10022A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift in Sources */, @@ -7423,6 +7565,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 6313B6A0 /* PaneMemoryWarning.swift in Sources */, E30760090000000000000002 /* PassthroughWindowOverlayContainerView.swift in Sources */, A5001425 /* PDFPreviewChromeDebugWindowController.swift in Sources */, + F17B00000000000000000028 /* PermissionInputPreview.swift in Sources */, D1F0A00400000000000000D1 /* PhoneForwardingMode.swift in Sources */, D1F0A00100000000000000A1 /* PhonePushClient.swift in Sources */, D1F0A00100000000000000A3 /* PhonePushPayload.swift in Sources */, @@ -8388,6 +8531,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEEDC0DEC0DEC0DEC0DE0001 /* FeedCoordinatorTests.swift in Sources */, FEED49850000000000000001 /* FeedEventClassificationTests.swift in Sources */, FEEDC1A50000000000000003 /* FeedEventClassifier.swift in Sources */, + F17D00000000000000000001 /* FeedFocusRoutingTests.swift in Sources */, D0B10018A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift in Sources */, FE002110 /* FileExplorerDoubleClickActionTests.swift in Sources */, FE002112 /* FileExplorerGitStatusProviderTests.swift in Sources */, diff --git a/cmuxTests/FeedFocusRoutingTests.swift b/cmuxTests/FeedFocusRoutingTests.swift new file mode 100644 index 000000000000..86e4327d4040 --- /dev/null +++ b/cmuxTests/FeedFocusRoutingTests.swift @@ -0,0 +1,59 @@ +import AppKit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite struct FeedFocusRoutingTests { + private final class ScopedFeedResponder: NSView, FeedScopedKeyboardFocusResponder { + let feedFocusScopeID: UUID + + init(scopeID: UUID) { + self.feedFocusScopeID = scopeID + super.init(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) + } + + required init?(coder: NSCoder) { + nil + } + + override var acceptsFirstResponder: Bool { true } + } + + @Test func feedPaneResponderDoesNotClaimRightSidebarFocus() { + let controller = MainWindowFocusController( + windowId: UUID(), + window: nil, + tabManager: TabManager(), + fileExplorerState: FileExplorerState() + ) + + #expect(!controller.ownsRightSidebarFocus(ScopedFeedResponder(scopeID: UUID()))) + } + + @Test func rightSidebarFeedHostOwnsOnlyItsFocusScope() { + let controller = MainWindowFocusController( + windowId: UUID(), + window: nil, + tabManager: TabManager(), + fileExplorerState: FileExplorerState() + ) + let host = FeedKeyboardFocusView(frame: NSRect(x: 0, y: 0, width: 1, height: 1)) + let matchingResponder = ScopedFeedResponder(scopeID: host.feedFocusScopeID) + let paneResponder = ScopedFeedResponder(scopeID: UUID()) + controller.registerFeedHost(host) + + #expect(controller.ownsRightSidebarFocus(matchingResponder)) + #expect(!controller.ownsRightSidebarFocus(paneResponder)) + } + + @Test func onlySidebarFeedPlacementUsesSidebarFocusCoordinator() { + #expect(FeedPlacement.rightSidebar.usesRightSidebarFocusCoordinator) + #expect(!FeedPlacement.pane.usesRightSidebarFocusCoordinator) + } +} diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 2680de29c582..55148e876ee9 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -1178,7 +1178,6 @@ final class ShortcutHintModifierPolicyTests: XCTestCase { } -@MainActor final class RightSidebarModeShortcutHintTests: XCTestCase { private let touchedShortcutActions: [KeyboardShortcutSettings.Action] = [ .focusRightSidebar, @@ -1379,35 +1378,9 @@ final class RightSidebarModeShortcutHintTests: XCTestCase { final class MainWindowFocusControllerRightSidebarHideTests: XCTestCase { private final class TestRightSidebarResponder: NSView, FeedKeyboardFocusResponder { - let feedFocusScopeID = UUID() - override var acceptsFirstResponder: Bool { true } } - private final class TestFeedPaneResponder: NSView, FeedKeyboardFocusResponder { - let feedFocusScopeID = UUID() - - override var acceptsFirstResponder: Bool { true } - } - - @MainActor - func testFeedPaneResponderDoesNotClaimRightSidebarFocus() { - let controller = MainWindowFocusController( - windowId: UUID(), - window: nil, - tabManager: TabManager(), - fileExplorerState: FileExplorerState() - ) - let responder = TestFeedPaneResponder(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) - - XCTAssertFalse(controller.ownsRightSidebarFocus(responder)) - } - - func testOnlySidebarFeedPlacementUsesSidebarFocusCoordinator() { - XCTAssertTrue(FeedPlacement.rightSidebar.usesRightSidebarFocusCoordinator) - XCTAssertFalse(FeedPlacement.pane.usesRightSidebarFocusCoordinator) - } - @MainActor func testHiddenRightSidebarClearsFocusIntentWhenNoTerminalCanRestore() { let controller = MainWindowFocusController( @@ -1477,9 +1450,6 @@ final class MainWindowFocusControllerRightSidebarHideTests: XCTestCase { fileExplorerState: FileExplorerState() ) let responder = TestRightSidebarResponder(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) - let feedHost = FeedKeyboardFocusView(frame: NSRect(x: 0, y: 0, width: 1, height: 1)) - feedHost.feedFocusScopeID = responder.feedFocusScopeID - controller.registerFeedHost(feedHost) let workspaceId = UUID() let panelId = UUID() From d3fa8c4cf9447074f6dbdd22ef1e550cd112bd1a Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:43:39 -0700 Subject: [PATCH 16/49] Test Feed reply and hook fallbacks --- cmuxTests/FeedCoordinatorTests.swift | 82 ++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index d8a4d46a5ee2..ab03c2a2380b 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import CMUXAgentLaunch +import CmuxSettings #if canImport(cmux_DEV) @testable import cmux_DEV @@ -10,6 +11,87 @@ import CMUXAgentLaunch @Suite("Feed coordinator", .serialized) struct FeedCoordinatorTests { + @MainActor + @Test func nonBlockingActionableReplyResolvesPendingStoreItem() async throws { + let store = WorkstreamStore(ringCapacity: 10) + FeedCoordinator.shared.install(store: store) + let requestID = "non-blocking-actionable" + let event = WorkstreamEvent( + sessionId: "claude-non-blocking-actionable", + hookEventName: .permissionRequest, + source: "claude", + cwd: "/tmp", + toolName: "Bash", + toolInputJSON: #"{"command":"true"}"#, + requestId: requestID + ) + + let ingest = await FeedCoordinator.shared.ingestBlocking(event: event, waitTimeout: 0) + guard case .acknowledged(let itemID) = ingest else { + Issue.record("expected non-blocking Feed event to be acknowledged") + return + } + let resolvedID = try #require(itemID) + + #expect( + await FeedCoordinator.shared.deliverReply( + requestId: requestID, + decision: .permission(.once) + ) + ) + guard case .resolved(.permission(.once), _) = store.items.first(where: { $0.id == resolvedID })?.status else { + Issue.record("non-blocking actionable item should resolve locally") + return + } + } + + @MainActor + @Test func notificationPolicyUsesGlobalHooksWithoutWorkspaceMapping() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-feed-global-hook-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let configURL = root.appendingPathComponent("cmux.json") + try #"{"notifications":{"hooks":[{"id":"feed-global","command":"cat"}]}}"# + .write(to: configURL, atomically: true, encoding: .utf8) + let configStore = CmuxConfigStore( + globalConfigPath: configURL.path, + startFileWatchers: false + ) + configStore.loadAll() + + let previousAppDelegate = AppDelegate.shared + let appDelegate = AppDelegate() + AppDelegate.shared = appDelegate + defer { AppDelegate.shared = previousAppDelegate } + let manager = TabManager() + let windowID = appDelegate.registerMainWindowContextForTesting( + tabManager: manager, + cmuxConfigStore: configStore + ) + defer { appDelegate.unregisterMainWindowContextForTesting(windowId: windowID) } + + let event = WorkstreamEvent( + sessionId: "claude-no-workspace", + hookEventName: .notification, + source: "claude", + cwd: root.path + ) + let context = await FeedNotificationPolicyContext.make( + event: event, + title: "Done", + body: "Finished", + hookCache: CmuxNotificationHookCache() + ) + + #expect(context.globalConfigPath == configURL.path) + #expect(context.hooks.map(\.id) == ["feed-global"]) + } + } + @Test func blockingBridgeReturnsFallbackAtItsDeadline() { let bridge = FeedBlockingCallBridge() From 832dd8de54efe0b30c523bbd3b488a841315cfcd Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:44:13 -0700 Subject: [PATCH 17/49] Restore Feed reply and notification fallbacks --- Sources/Feed/FeedBlockingWaiterRegistry.swift | 16 ++++++-- Sources/Feed/FeedCoordinator.swift | 39 ++++++++++++++++--- .../Feed/FeedNotificationPolicyContext.swift | 16 ++++---- 3 files changed, 53 insertions(+), 18 deletions(-) diff --git a/Sources/Feed/FeedBlockingWaiterRegistry.swift b/Sources/Feed/FeedBlockingWaiterRegistry.swift index 8059804a1365..fde68901ab45 100644 --- a/Sources/Feed/FeedBlockingWaiterRegistry.swift +++ b/Sources/Feed/FeedBlockingWaiterRegistry.swift @@ -42,15 +42,23 @@ actor FeedBlockingWaiterRegistry { func deliver( _ decision: WorkstreamDecision, requestID: String - ) -> (accepted: Bool, itemID: UUID?, attentionTarget: FeedCoordinator.AttentionTarget?) { - guard var waiter = waiters[requestID], waiter.decision == nil else { - return (false, nil, nil) + ) -> ( + accepted: Bool, + registered: Bool, + itemID: UUID?, + attentionTarget: FeedCoordinator.AttentionTarget? + ) { + guard var waiter = waiters[requestID] else { + return (false, false, nil, nil) + } + guard waiter.decision == nil else { + return (false, true, waiter.itemID, waiter.attentionTarget) } waiter.decision = decision waiters[requestID] = waiter waiter.continuation.yield(decision) waiter.continuation.finish() - return (true, waiter.itemID, waiter.attentionTarget) + return (true, true, waiter.itemID, waiter.attentionTarget) } func remove(requestID: String) -> FeedPendingWaiter? { diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 652a656a252e..6010c52a6379 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -279,24 +279,51 @@ final class FeedCoordinator: @unchecked Sendable { decision, requestID: requestId ) - guard delivery.accepted else { - return false - } // The user decided: conclude the needs-input overlay so the agent's // running/idle state shows through (refcounted so an overlapping // decision on the same panel keeps it lit until it too concludes). - concludeAttentionOnMain(delivery.attentionTarget) + if delivery.accepted { + concludeAttentionOnMain(delivery.attentionTarget) + } - await MainActor.run { - guard let store, let itemID = delivery.itemID else { return } + let resolvedItemID = await MainActor.run { () -> UUID? in + guard let store else { return nil } + let itemID: UUID? + if delivery.accepted { + itemID = delivery.itemID ?? Self.findPendingItemID(for: requestId, in: store.items) + } else if !delivery.registered { + itemID = Self.findPendingItemID(for: requestId, in: store.items) + } else { + itemID = nil + } + guard let itemID else { return nil } store.markResolved(itemID, decision: decision) + return itemID } + guard delivery.accepted || resolvedItemID != nil else { return false } cancelNotification(requestId: requestId) return true } + private static func findPendingItemID( + for requestID: String, + in items: [WorkstreamItem] + ) -> UUID? { + items.reversed().first { item in + guard item.status.isPending else { return false } + switch item.payload { + case .permissionRequest(let candidate, _, _, _), + .exitPlan(let candidate, _, _), + .question(let candidate, _): + return candidate == requestID + default: + return false + } + }?.id + } + func isAwaitingDecision(requestId: String) async -> Bool { await waiterRegistry.isAwaitingDecision(requestID: requestId) } diff --git a/Sources/Feed/FeedNotificationPolicyContext.swift b/Sources/Feed/FeedNotificationPolicyContext.swift index 01fc1f8cdf04..e19921f081f4 100644 --- a/Sources/Feed/FeedNotificationPolicyContext.swift +++ b/Sources/Feed/FeedNotificationPolicyContext.swift @@ -42,9 +42,11 @@ extension FeedNotificationPolicyContext { ) -> FeedNotificationPolicySnapshot { let appDelegate = AppDelegate.shared let workspaceID = event.workspaceId.flatMap(UUID.init(uuidString:)) - let context = workspaceID.flatMap { appDelegate?.contextContainingTabId($0) } + let workspaceContext = workspaceID.flatMap { appDelegate?.contextContainingTabId($0) } + let configContext = workspaceContext + ?? appDelegate?.mainWindowContexts.values.first(where: { $0.cmuxConfigStore != nil }) let workspace = workspaceID.flatMap { id in - context?.tabManager.tabs.first(where: { $0.id == id }) + workspaceContext?.tabManager.tabs.first(where: { $0.id == id }) } let cwd = normalizedCWD(event.cwd) @@ -61,12 +63,10 @@ extension FeedNotificationPolicyContext { effects.paneFlash = false let workspaceIdentity = workspaceID?.uuidString ?? "" - let globalConfigPath = workspace == nil ? nil : context?.cmuxConfigStore?.globalConfigPath - let hookSearchDirectory = workspace.flatMap { workspace in - workspace.isRemoteWorkspace - ? nil - : (normalizedCWD(event.cwd) ?? workspace.surfaceTabBarDirectory) - } + let globalConfigPath = configContext?.cmuxConfigStore?.globalConfigPath + let hookSearchDirectory = workspace?.isRemoteWorkspace == true + ? nil + : (normalizedCWD(event.cwd) ?? workspace?.surfaceTabBarDirectory) return FeedNotificationPolicySnapshot( envelope: TerminalNotificationPolicyEnvelope( notification: TerminalNotificationPolicyPayload( From 75d2c3b38d4ae77cb73d4f8c8c7bba433c5c46cb Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:50:59 -0700 Subject: [PATCH 18/49] Restore Feed editor debug dependency --- Sources/Feed/FeedInlineTextFieldCoordinator.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Feed/FeedInlineTextFieldCoordinator.swift b/Sources/Feed/FeedInlineTextFieldCoordinator.swift index 5f10dd50a4d3..19de9add4051 100644 --- a/Sources/Feed/FeedInlineTextFieldCoordinator.swift +++ b/Sources/Feed/FeedInlineTextFieldCoordinator.swift @@ -1,4 +1,5 @@ import AppKit +import Bonsplit import Foundation @MainActor From f98906199370b69dfdaedac6eba3b6e3506ccbf9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:57:41 -0700 Subject: [PATCH 19/49] Test Feed editor scope transitions --- cmuxTests/FeedFocusRoutingTests.swift | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/cmuxTests/FeedFocusRoutingTests.swift b/cmuxTests/FeedFocusRoutingTests.swift index 86e4327d4040..1181c1239863 100644 --- a/cmuxTests/FeedFocusRoutingTests.swift +++ b/cmuxTests/FeedFocusRoutingTests.swift @@ -56,4 +56,49 @@ import Testing #expect(FeedPlacement.rightSidebar.usesRightSidebarFocusCoordinator) #expect(!FeedPlacement.pane.usesRightSidebarFocusCoordinator) } + + @Test func editorBlurTracksFeedFocusScope() { + let scopeID = UUID() + var blurCount = 0 + let field = FeedInlineTextField( + text: .constant(""), + focusRequest: nil, + placeholder: "", + isEnabled: true, + font: .systemFont(ofSize: 12), + placement: .pane, + focusScopeID: scopeID, + onFocus: {}, + onBlur: { blurCount += 1 }, + onSubmit: nil + ) + let coordinator = FeedInlineTextFieldCoordinator(parent: field) + let editor = FeedInlineTextEditorView(frame: NSRect(x: 0, y: 0, width: 80, height: 24)) + coordinator.view = editor + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 200, height: 100), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + let content = NSView(frame: window.contentView?.bounds ?? .zero) + window.contentView = content + content.addSubview(editor) + + let sameScope = ScopedFeedResponder(scopeID: scopeID) + content.addSubview(sameScope) + #expect(window.makeFirstResponder(sameScope)) + coordinator.textDidEndEditing( + Notification(name: NSText.didEndEditingNotification, object: editor.textView) + ) + #expect(blurCount == 0) + + let otherScope = ScopedFeedResponder(scopeID: UUID()) + content.addSubview(otherScope) + #expect(window.makeFirstResponder(otherScope)) + coordinator.textDidEndEditing( + Notification(name: NSText.didEndEditingNotification, object: editor.textView) + ) + #expect(blurCount == 1) + } } From 39348c5bb0a5da9a016f61b6eaf3020641c42cb3 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:57:53 -0700 Subject: [PATCH 20/49] Scope Feed editor blur handling --- Sources/Feed/FeedInlineTextFieldCoordinator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Feed/FeedInlineTextFieldCoordinator.swift b/Sources/Feed/FeedInlineTextFieldCoordinator.swift index 19de9add4051..04be8e3a6462 100644 --- a/Sources/Feed/FeedInlineTextFieldCoordinator.swift +++ b/Sources/Feed/FeedInlineTextFieldCoordinator.swift @@ -61,7 +61,8 @@ final class FeedInlineTextFieldCoordinator: NSObject, NSTextViewDelegate { return } let responder = window.firstResponder - if !(responder is FeedKeyboardFocusView) && !(responder is FeedInlineNativeTextView) { + let focusedScopeID = (responder as? FeedScopedKeyboardFocusResponder)?.feedFocusScopeID + if focusedScopeID != parent.focusScopeID { parent.onBlur() } } From 3c85d4851747af0bb7c4c08c316f489a7eeaaaae Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:02:44 -0700 Subject: [PATCH 21/49] Test Feed timeout and editor layout boundaries --- cmuxTests/FeedCoordinatorTests.swift | 6 ++++++ cmuxTests/FeedFocusRoutingTests.swift | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index ab03c2a2380b..2631e0fcbadf 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -619,6 +619,12 @@ struct FeedCoordinatorTests { Issue.record("timed-out hook item should be expired") return } + #expect( + !(await FeedCoordinator.shared.deliverReply( + requestId: "timeout-request", + decision: .permission(.once) + )) + ) } @Test func blockingIngestResolvesAfterMainActorIngest() async { diff --git a/cmuxTests/FeedFocusRoutingTests.swift b/cmuxTests/FeedFocusRoutingTests.swift index 1181c1239863..42a7fecfab13 100644 --- a/cmuxTests/FeedFocusRoutingTests.swift +++ b/cmuxTests/FeedFocusRoutingTests.swift @@ -101,4 +101,15 @@ import Testing ) #expect(blurCount == 1) } + + @Test func editorMetricRefreshDefersLayout() { + let editor = FeedInlineTextEditorView( + frame: NSRect(x: 0, y: 0, width: 160, height: 24) + ) + editor.layoutSubtreeIfNeeded() + + editor.refreshMetrics() + + #expect(editor.needsLayout) + } } From 73e0dfaf902108fa0bcc1013327be27ce9adb5b5 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:02:57 -0700 Subject: [PATCH 22/49] Close Feed timeout and layout races --- Sources/Feed/FeedCoordinator.swift | 15 ++++++--------- Sources/Feed/FeedInlineTextEditorView.swift | 2 -- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 6010c52a6379..0f8d4dceaf3d 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -229,8 +229,12 @@ final class FeedCoordinator: @unchecked Sendable { } cancelNotification(requestId: requestId) - concludeAttentionOnMain(waiter?.attentionTarget) - expireTimedOutItem(waiter?.itemID) + await MainActor.run { + self.concludeBlockingDecisionAttentionIfPresent(waiter?.attentionTarget) + if let itemID = waiter?.itemID { + self.store?.markExpired(itemID) + } + } return .timedOut(itemId: waiter?.itemID) } @@ -328,13 +332,6 @@ final class FeedCoordinator: @unchecked Sendable { await waiterRegistry.isAwaitingDecision(requestID: requestId) } - private func expireTimedOutItem(_ itemId: UUID?) { - guard let itemId else { return } - Task { @MainActor [weak self] in - self?.store?.markExpired(itemId) - } - } - enum IngestBlockingResult: Sendable { case acknowledged(itemId: UUID?) case resolved(itemId: UUID?, decision: WorkstreamDecision) diff --git a/Sources/Feed/FeedInlineTextEditorView.swift b/Sources/Feed/FeedInlineTextEditorView.swift index 600691644d55..196ac34219ee 100644 --- a/Sources/Feed/FeedInlineTextEditorView.swift +++ b/Sources/Feed/FeedInlineTextEditorView.swift @@ -118,7 +118,6 @@ final class FeedInlineTextEditorView: NSView { updatePlaceholderVisibility() needsLayout = true invalidateIntrinsicContentSize() - layoutSubtreeIfNeeded() } func focusIfNeeded() { @@ -169,4 +168,3 @@ final class FeedInlineTextEditorView: NSView { placeholderField.isHidden = !textView.string.isEmpty } } - From 028f4cf562588990822cacdc2578ae21c7632df8 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:07:36 -0700 Subject: [PATCH 23/49] Remove stale Feed beta UI coverage --- .../RightSidebarChromeHeightUITests.swift | 1 - .../SettingsSidebarBetaBehaviorUITests.swift | 52 +------------------ 2 files changed, 1 insertion(+), 52 deletions(-) diff --git a/cmuxUITests/RightSidebarChromeHeightUITests.swift b/cmuxUITests/RightSidebarChromeHeightUITests.swift index 2a86ba5cd92a..1530340e329a 100644 --- a/cmuxUITests/RightSidebarChromeHeightUITests.swift +++ b/cmuxUITests/RightSidebarChromeHeightUITests.swift @@ -14,7 +14,6 @@ final class RightSidebarChromeHeightUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_SHOW_RIGHT_SIDEBAR"] = "1" app.launchArguments += ["-workspacePresentationMode", "minimal"] - app.launchArguments += ["-rightSidebar.beta.feed.enabled", "YES"] app.launchArguments += ["-rightSidebar.beta.dock.enabled", "YES"] app.launch() defer { app.terminate() } diff --git a/cmuxUITests/SettingsSidebarBetaBehaviorUITests.swift b/cmuxUITests/SettingsSidebarBetaBehaviorUITests.swift index e09a83172425..321be45e12d4 100644 --- a/cmuxUITests/SettingsSidebarBetaBehaviorUITests.swift +++ b/cmuxUITests/SettingsSidebarBetaBehaviorUITests.swift @@ -3,7 +3,7 @@ import XCTest /// Behavioral UI tests for the Settings **Sidebar** + **Beta Features** /// section, scoped to the controls called out for this section: /// the *Sidebar Branch Layout* picker (vertical vs inline), the active-tab -/// *indicator style*, and the *beta Feed* / *beta Dock* toggles. +/// *indicator style*, and the *beta Dock* toggle. /// /// What is actually assertable through XCUITest here, and why: /// @@ -38,12 +38,10 @@ final class SettingsSidebarBetaBehaviorUITests: SettingsUITestCase { // test so the run starts from the shipped default. // - sidebarBranchVerticalLayout: SidebarCatalogSection.branchVerticalLayout (default true / "Vertical") // - sidebarActiveTabIndicatorStyle: indicator style key (default "leftRail") - // - rightSidebar.beta.feed.enabled: BetaFeaturesCatalogSection.rightSidebarFeed (default false) // - rightSidebar.beta.dock.enabled: BetaFeaturesCatalogSection.rightSidebarDock (default false) private let inScopeDefaultsKeys = [ "sidebarBranchVerticalLayout", "sidebarActiveTabIndicatorStyle", - "rightSidebar.beta.feed.enabled", "rightSidebar.beta.dock.enabled", ] @@ -52,11 +50,6 @@ final class SettingsSidebarBetaBehaviorUITests: SettingsUITestCase { private let branchVerticalSubtitle = "Vertical: each branch appears on its own line." private let branchInlineSubtitle = "Inline: all branches share one line." - // Beta Feed subtitle strings (exact defaultValue copy from - // BetaFeaturesSection.feedRow). - private let feedOffSubtitle = "Hides Feed from the right sidebar until you enable it here." - private let feedOnSubtitle = "Shows Feed in the right sidebar mode switcher for inline agent decisions." - // Beta Dock subtitle strings (exact defaultValue copy from // BetaFeaturesSection.dockRow). private let dockOffSubtitle = "Hides Dock from the right sidebar until you enable it here." @@ -132,49 +125,6 @@ final class SettingsSidebarBetaBehaviorUITests: SettingsUITestCase { ) } - // MARK: - TIER 1: Beta Feed toggle - - /// Toggling the **Beta Features → Feed** switch flips the row's derived - /// subtitle between the "Hides Feed …" (off) and "Shows Feed …" (on) - /// copy. The subtitle is computed from the `rightSidebarFeed` binding, - /// so the label change verifies the toggle drove the live settings store - /// and the dependent view re-rendered. - func testBetaFeedToggleDrivesDerivedSubtitle() { - let app = makeLaunchedApp() - let window = openSettings(app) - defer { closeSettings(app, window) } - - navigate(window, to: "Beta Features") - - let offSubtitle = window.staticTexts[feedOffSubtitle] - let onSubtitle = window.staticTexts[feedOnSubtitle] - - // Default is off → "Hides Feed …" present, "Shows Feed …" absent. - XCTAssertTrue( - poll(timeout: 5.0) { offSubtitle.exists }, - "Expected the default (off) Feed subtitle" - ) - XCTAssertFalse(onSubtitle.exists, "On subtitle should not be shown while Feed is disabled") - - let feedToggle = toggle(window, id: "SettingsBetaFeedToggle") - feedToggle.click() - - // Effect: subtitle flips to the "on" copy. - XCTAssertTrue( - poll(timeout: 5.0) { onSubtitle.exists }, - "Expected the (on) Feed subtitle after enabling Feed" - ) - XCTAssertFalse(offSubtitle.exists, "Off subtitle should be gone once Feed is enabled") - - // Toggle back off to prove the binding is reversible (full round-trip). - feedToggle.click() - XCTAssertTrue( - poll(timeout: 5.0) { offSubtitle.exists }, - "Expected the (off) Feed subtitle after disabling Feed again" - ) - XCTAssertFalse(onSubtitle.exists, "On subtitle should be gone once Feed is disabled again") - } - // MARK: - TIER 1: Beta Dock toggle /// Toggling the **Beta Features → Dock** switch flips the row's derived From 2ae536039abeacc3c384bc4767de653eaebed74b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:37:05 -0700 Subject: [PATCH 24/49] Test Feed routing and history boundaries --- cmuxTests/FeedCoordinatorTests.swift | 73 ++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 2631e0fcbadf..5bef60aa04eb 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -46,7 +46,7 @@ struct FeedCoordinatorTests { } @MainActor - @Test func notificationPolicyUsesGlobalHooksWithoutWorkspaceMapping() async throws { + @Test func notificationPolicyFailsClosedWithoutWorkspaceMapping() async throws { try await AppContextSerialGate.withExclusiveAppContext { let root = FileManager.default.temporaryDirectory.appendingPathComponent( "cmux-feed-global-hook-\(UUID().uuidString)", @@ -87,11 +87,78 @@ struct FeedCoordinatorTests { hookCache: CmuxNotificationHookCache() ) - #expect(context.globalConfigPath == configURL.path) - #expect(context.hooks.map(\.id) == ["feed-global"]) + #expect(context.globalConfigPath == nil) + #expect(context.hooks.isEmpty) } } + @Test func jumpResolverRejectsAgentPathTraversal() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-feed-jump-traversal-\(UUID().uuidString)", + isDirectory: true + ) + let sessionsDirectory = root.appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: sessionsDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let escapedStore = root.appendingPathComponent("escaped-hook-sessions.json") + try #"{"sessions":{"session-1":{"workspaceId":"workspace","surfaceId":"surface"}}}"# + .write(to: escapedStore, atomically: true, encoding: .utf8) + + let resolver = FeedJumpResolver(sessionsDirectory: sessionsDirectory) + #expect(resolver.lookup(agent: "../escaped", sessionId: "session-1") == nil) + } + + @MainActor + @Test func presentationUsesPromptActiveAtEachHistoricalItem() throws { + let store = WorkstreamStore(ringCapacity: 10) + FeedCoordinator.shared.install(store: store) + store.ingest(WorkstreamEvent( + sessionId: "claude-history", + hookEventName: .userPromptSubmit, + source: "claude", + cwd: "/tmp", + toolInputJSON: #"{"prompt":"first prompt"}"# + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-history", + hookEventName: .permissionRequest, + source: "claude", + cwd: "/tmp", + toolName: "Bash", + toolInputJSON: #"{"command":"one"}"#, + requestId: "request-1" + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-history", + hookEventName: .userPromptSubmit, + source: "claude", + cwd: "/tmp", + toolInputJSON: #"{"prompt":"second prompt"}"# + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-history", + hookEventName: .permissionRequest, + source: "claude", + cwd: "/tmp", + toolName: "Bash", + toolInputJSON: #"{"command":"two"}"#, + requestId: "request-2" + )) + + let snapshots = FeedCoordinator.shared.presentationStore.presentation.actionable + let older = try #require(snapshots.first { snapshot in + guard case .permissionRequest(let requestID, _, _, _) = snapshot.payload else { return false } + return requestID == "request-1" + }) + let newer = try #require(snapshots.first { snapshot in + guard case .permissionRequest(let requestID, _, _, _) = snapshot.payload else { return false } + return requestID == "request-2" + }) + #expect(older.userPromptEcho == "first prompt") + #expect(newer.userPromptEcho == "second prompt") + } + @Test func blockingBridgeReturnsFallbackAtItsDeadline() { let bridge = FeedBlockingCallBridge() From 50a1c2b6627e7fb7990febd0f774558c02af548e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:48:01 -0700 Subject: [PATCH 25/49] Fix Feed review findings and test isolation --- .../Wire/ControlCommandExecutionPolicy.swift | 1 + .../ControlCommandExecutionPolicyTests.swift | 4 +- Sources/AppDelegate.swift | 2 +- Sources/Feed/FeedBlockingWaiterRegistry.swift | 57 +++++++++++++++++-- .../Feed/FeedCoordinator+Notifications.swift | 4 +- Sources/Feed/FeedCoordinator.swift | 53 ++++++++--------- Sources/Feed/FeedInlineNativeTextView.swift | 26 ++------- Sources/Feed/FeedJumpResolver.swift | 8 +++ Sources/Feed/FeedListView.swift | 3 +- .../Feed/FeedNotificationPolicyContext.swift | 15 +++-- Sources/Feed/FeedPanelView.swift | 4 +- Sources/Feed/FeedPanelViewModel.swift | 1 + Sources/Feed/FeedPlanBodyView.swift | 2 + Sources/Feed/FeedPresentationStore.swift | 21 ++++--- Sources/Feed/FeedRowActions.swift | 42 ++++++++++---- Sources/TerminalController.swift | 16 ++++++ cmuxTests/FeedCoordinatorTests.swift | 26 +++++++-- .../ShortcutAndCommandPaletteTests.swift | 1 + 18 files changed, 195 insertions(+), 91 deletions(-) diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index e56f10296c05..1a77a8a646b7 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -87,6 +87,7 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "feed.permission.reply", "feed.question.reply", "feed.exit_plan.reply", + "feed.jump", "browser.download.wait", "browser.profiles.list", "browser.profiles.create", diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index ca1c7e3fe99b..15133c9be7b1 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -29,7 +29,7 @@ struct ControlCommandExecutionPolicyTests { @Test func fixedWorkerSetRunsOnTheSocketWorker() { for method in [ "system.ping", "system.capabilities", "auth.status", "auth.sign_in_url", - "feed.push", "browser.download.wait", "system.top", "system.memory", + "feed.push", "feed.jump", "browser.download.wait", "system.top", "system.memory", "workspace.remote.pty_bridge", "workspace.env", "sidebar.custom.reload", "sidebar.custom.open", "debug.sidebar.simulate_drag", "mobile.attach_ticket.create", @@ -57,7 +57,7 @@ struct ControlCommandExecutionPolicyTests { for method in [ "workspace.create", "browser.url.get", "browser.open_split", "browser.get.title", "browser.frame.main", - "mobile.terminal.create", "feed.jump", "vmx.create", "", + "mobile.terminal.create", "vmx.create", "", // Focus-intent verbs stay on the main lane until the mutations // tranche decides them deliberately. "surface.focus", "workspace.select", "pane.focus", "window.focus", diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 3f06331f2f06..77228a84977e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -4727,7 +4727,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func locateBonsplitSurface(tabId: UUID) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { let bonsplitTabId = TabID(uuid: tabId) - for context in mainWindowContexts.values { + for context in Array(mainWindowContexts.values) { for workspace in context.tabManager.tabs { if let panelId = workspace.panelIdFromSurfaceId(bonsplitTabId) { return (context.windowId, workspace.id, panelId, context.tabManager) diff --git a/Sources/Feed/FeedBlockingWaiterRegistry.swift b/Sources/Feed/FeedBlockingWaiterRegistry.swift index fde68901ab45..30d7561d5831 100644 --- a/Sources/Feed/FeedBlockingWaiterRegistry.swift +++ b/Sources/Feed/FeedBlockingWaiterRegistry.swift @@ -4,13 +4,21 @@ import Foundation /// Actor-owned request/reply state for blocking Feed socket calls. /// /// Each request receives one buffered decision stream. Delivery is -/// first-writer-wins, and timeout removal finishes the stream so no suspended -/// consumer or continuation outlives its socket request. +/// first-writer-wins. Timeout finalization retains a short-lived sentinel until +/// the corresponding store item is expired, closing the reply/timeout race. actor FeedBlockingWaiterRegistry { private var waiters: [String: FeedPendingWaiter] = [:] + private var timedOutRequestIDs: Set = [] + + enum Completion { + case resolved(FeedPendingWaiter, WorkstreamDecision) + case timedOut(FeedPendingWaiter) + case missing + } func register(requestID: String) -> AsyncStream? { guard waiters[requestID] == nil else { return nil } + timedOutRequestIDs.remove(requestID) let (stream, continuation) = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(1) ) @@ -45,30 +53,67 @@ actor FeedBlockingWaiterRegistry { ) -> ( accepted: Bool, registered: Bool, + timedOut: Bool, itemID: UUID?, attentionTarget: FeedCoordinator.AttentionTarget? ) { guard var waiter = waiters[requestID] else { - return (false, false, nil, nil) + return (false, false, false, nil, nil) + } + if timedOutRequestIDs.contains(requestID) { + return (false, true, true, waiter.itemID, waiter.attentionTarget) } guard waiter.decision == nil else { - return (false, true, waiter.itemID, waiter.attentionTarget) + return (false, true, false, waiter.itemID, waiter.attentionTarget) } waiter.decision = decision waiters[requestID] = waiter waiter.continuation.yield(decision) waiter.continuation.finish() - return (true, true, waiter.itemID, waiter.attentionTarget) + return (true, true, false, waiter.itemID, waiter.attentionTarget) + } + + /// Atomically chooses the terminal state after the decision/timeout race. + /// A decision already accepted by the actor wins; otherwise the waiter is + /// retained as timed out until its store item finishes expiring, so a late + /// reply cannot reopen the decision during that main-actor transition. + func completeAfterWait(requestID: String) -> Completion { + guard let waiter = waiters[requestID] else { + return .missing + } + waiter.continuation.finish() + if let decision = waiter.decision { + waiters.removeValue(forKey: requestID) + return .resolved(waiter, decision) + } + timedOutRequestIDs.insert(requestID) + return .timedOut(waiter) + } + + func expire(requestID: String) -> FeedPendingWaiter? { + let waiter = waiters[requestID] + waiter?.continuation.finish() + if waiter != nil { + timedOutRequestIDs.insert(requestID) + } + return waiter + } + + func finalizeExpiration(requestID: String) { + waiters.removeValue(forKey: requestID) + timedOutRequestIDs.remove(requestID) } func remove(requestID: String) -> FeedPendingWaiter? { + timedOutRequestIDs.remove(requestID) let waiter = waiters.removeValue(forKey: requestID) waiter?.continuation.finish() return waiter } func isAwaitingDecision(requestID: String) -> Bool { - guard let waiter = waiters[requestID] else { return false } + guard !timedOutRequestIDs.contains(requestID), + let waiter = waiters[requestID] else { return false } return waiter.decision == nil } } diff --git a/Sources/Feed/FeedCoordinator+Notifications.swift b/Sources/Feed/FeedCoordinator+Notifications.swift index b9d7faa7bd08..6d1a4b7cbead 100644 --- a/Sources/Feed/FeedCoordinator+Notifications.swift +++ b/Sources/Feed/FeedCoordinator+Notifications.swift @@ -125,7 +125,9 @@ extension FeedCoordinator { } private static func permissionNotificationCategoryId(for event: WorkstreamEvent) -> String { - let source = WorkstreamSource(wireName: event.source) ?? .claude + guard let source = WorkstreamSource(wireName: event.source) else { + return "CMUXFeedPermissionDeny" + } let supportsOnce = FeedPermissionActionPolicy.supportsOncePermissionMode( source: source, toolInputJSON: event.toolInputJSON diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 0f8d4dceaf3d..35528a6c3633 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -193,7 +193,7 @@ final class FeedCoordinator: @unchecked Sendable { // inactive, offer the same decision through a native banner. postNotificationIfStillAwaiting(event: event, requestId: requestId) - let deliveredDecision = await withTaskGroup(of: WorkstreamDecision?.self) { group in + _ = await withTaskGroup(of: WorkstreamDecision?.self) { group in group.addTask { for await decision in decisions { return decision @@ -209,9 +209,8 @@ final class FeedCoordinator: @unchecked Sendable { return first } - let waiter = await waiterRegistry.remove(requestID: requestId) - guard !Task.isCancelled else { + let waiter = await waiterRegistry.expire(requestID: requestId) cancelNotification(requestId: requestId) await MainActor.run { self.concludeBlockingDecisionAttentionIfPresent(waiter?.attentionTarget) @@ -219,23 +218,27 @@ final class FeedCoordinator: @unchecked Sendable { self.store?.markExpired(itemID) } } + await waiterRegistry.finalizeExpiration(requestID: requestId) return .timedOut(itemId: waiter?.itemID) } - if let decision = deliveredDecision ?? waiter?.decision { - // A decision that wins at the timeout boundary remains terminal - // even when Dispatch reports the timeout result first. - return .resolved(itemId: waiter?.itemID, decision: decision) - } - - cancelNotification(requestId: requestId) - await MainActor.run { - self.concludeBlockingDecisionAttentionIfPresent(waiter?.attentionTarget) - if let itemID = waiter?.itemID { - self.store?.markExpired(itemID) + switch await waiterRegistry.completeAfterWait(requestID: requestId) { + case .resolved(let waiter, let decision): + return .resolved(itemId: waiter.itemID, decision: decision) + case .timedOut(let waiter): + cancelNotification(requestId: requestId) + await MainActor.run { + self.concludeBlockingDecisionAttentionIfPresent(waiter.attentionTarget) + if let itemID = waiter.itemID { + self.store?.markExpired(itemID) + } } + await waiterRegistry.finalizeExpiration(requestID: requestId) + return .timedOut(itemId: waiter.itemID) + case .missing: + cancelNotification(requestId: requestId) + return .timedOut(itemId: nil) } - return .timedOut(itemId: waiter?.itemID) } private func cancelIngest( @@ -243,7 +246,7 @@ final class FeedCoordinator: @unchecked Sendable { itemID: UUID?, attentionTarget: AttentionTarget? ) async { - _ = await waiterRegistry.remove(requestID: requestID) + _ = await waiterRegistry.expire(requestID: requestID) cancelNotification(requestId: requestID) await MainActor.run { self.concludeBlockingDecisionAttentionIfPresent(attentionTarget) @@ -251,21 +254,15 @@ final class FeedCoordinator: @unchecked Sendable { self.store?.markExpired(itemID) } } + await waiterRegistry.finalizeExpiration(requestID: requestID) } /// Concludes an attention overlay (if any) on the main actor, hopping if /// called from the socket worker thread. - private func concludeAttentionOnMain(_ target: AttentionTarget?) { + private func concludeAttentionOnMain(_ target: AttentionTarget?) async { guard let target else { return } - let conclude: @Sendable () -> Void = { [target] in - MainActor.assumeIsolated { - FeedCoordinator.shared.concludeBlockingDecisionAttention(target) - } - } - if Thread.isMainThread { - conclude() - } else { - Task { @MainActor in conclude() } + await MainActor.run { + self.concludeBlockingDecisionAttention(target) } } @@ -288,7 +285,7 @@ final class FeedCoordinator: @unchecked Sendable { // running/idle state shows through (refcounted so an overlapping // decision on the same panel keeps it lit until it too concludes). if delivery.accepted { - concludeAttentionOnMain(delivery.attentionTarget) + await concludeAttentionOnMain(delivery.attentionTarget) } let resolvedItemID = await MainActor.run { () -> UUID? in @@ -296,7 +293,7 @@ final class FeedCoordinator: @unchecked Sendable { let itemID: UUID? if delivery.accepted { itemID = delivery.itemID ?? Self.findPendingItemID(for: requestId, in: store.items) - } else if !delivery.registered { + } else if !delivery.registered && !delivery.timedOut { itemID = Self.findPendingItemID(for: requestId, in: store.items) } else { itemID = nil diff --git a/Sources/Feed/FeedInlineNativeTextView.swift b/Sources/Feed/FeedInlineNativeTextView.swift index 62b4a8f31d5e..f8ca9b2d15b3 100644 --- a/Sources/Feed/FeedInlineNativeTextView.swift +++ b/Sources/Feed/FeedInlineNativeTextView.swift @@ -6,32 +6,18 @@ import Foundation import SwiftUI final class FeedInlineNativeTextView: NSTextView, FeedScopedKeyboardFocusResponder { - private static weak var activeEditor: FeedInlineNativeTextView? - var onActivate: (() -> Void)? var onEscape: (() -> Void)? var onSubmit: (() -> Void)? var feedFocusScopeID = UUID() static func blurActiveEditor(in hostWindow: NSWindow?) { - guard let activeEditor else { return } - guard let window = activeEditor.window else { - if Self.activeEditor === activeEditor { - Self.activeEditor = nil - } - return - } - guard window === hostWindow else { return } - guard window.firstResponder === activeEditor else { - if Self.activeEditor === activeEditor { - Self.activeEditor = nil - } - return - } + guard let hostWindow, + hostWindow.firstResponder is FeedInlineNativeTextView else { return } #if DEBUG - dlog("feed.editor.blurActive fr=\(feedDebugResponderSummary(window.firstResponder))") + dlog("feed.editor.blurActive fr=\(feedDebugResponderSummary(hostWindow.firstResponder))") #endif - window.makeFirstResponder(nil) + hostWindow.makeFirstResponder(nil) } override func mouseDown(with event: NSEvent) { @@ -72,7 +58,6 @@ final class FeedInlineNativeTextView: NSTextView, FeedScopedKeyboardFocusRespond override func becomeFirstResponder() -> Bool { let didBecomeFirstResponder = super.becomeFirstResponder() if didBecomeFirstResponder { - Self.activeEditor = self onActivate?() } #if DEBUG @@ -83,9 +68,6 @@ final class FeedInlineNativeTextView: NSTextView, FeedScopedKeyboardFocusRespond override func resignFirstResponder() -> Bool { let didResignFirstResponder = super.resignFirstResponder() - if didResignFirstResponder, Self.activeEditor === self { - Self.activeEditor = nil - } #if DEBUG dlog("feed.editor.resign result=\(didResignFirstResponder ? 1 : 0) fr=\(feedDebugResponderSummary(window?.firstResponder))") #endif diff --git a/Sources/Feed/FeedJumpResolver.swift b/Sources/Feed/FeedJumpResolver.swift index 052a86cf2ef6..cf0e48bfe6ca 100644 --- a/Sources/Feed/FeedJumpResolver.swift +++ b/Sources/Feed/FeedJumpResolver.swift @@ -21,6 +21,7 @@ final class FeedJumpResolver: @unchecked Sendable { } func lookup(agent: String, sessionId: String) -> FeedJumpTarget? { + guard isValidAgentIdentifier(agent) else { return nil } let file = sessionsDirectory .appendingPathComponent("\(agent)-hook-sessions.json", isDirectory: false) guard let data = try? Data(contentsOf: file), @@ -42,6 +43,13 @@ final class FeedJumpResolver: @unchecked Sendable { return FeedJumpTarget(workspaceId: workspaceId, surfaceId: surfaceId) } + private func isValidAgentIdentifier(_ agent: String) -> Bool { + guard !agent.isEmpty, agent != ".", agent != ".." else { return false } + return agent.unicodeScalars.allSatisfy { scalar in + CharacterSet.alphanumerics.contains(scalar) || scalar.value == 45 || scalar.value == 95 + } + } + func resolve(_ workstreamId: String) -> FeedJumpTarget? { guard let parsed = parse(workstreamId) else { return nil } return lookup(agent: parsed.agent, sessionId: parsed.sessionId) diff --git a/Sources/Feed/FeedListView.swift b/Sources/Feed/FeedListView.swift index adeedc6c1fdf..51d382904f98 100644 --- a/Sources/Feed/FeedListView.swift +++ b/Sources/Feed/FeedListView.swift @@ -20,11 +20,12 @@ struct FeedListView: View { @State private var stopDrafts: [UUID: FeedStopDraft] = [:] @State private var focusHostRequest = 0 @State private var focusHostReference = FeedFocusHostReference() + @State private var actionTaskStore = FeedRowActions.TaskStore() var body: some View { let activityGroups = filter == .activity ? presentation.activity : nil let snapshots = activityGroups?.ordered ?? presentation.actionable - let rowActions = FeedRowActions.bound() + let rowActions = FeedRowActions.bound(taskStore: actionTaskStore) ScrollViewReader { proxy in Group { if snapshots.isEmpty && !shouldShowActivityHistoryLoader { diff --git a/Sources/Feed/FeedNotificationPolicyContext.swift b/Sources/Feed/FeedNotificationPolicyContext.swift index e19921f081f4..ab56349d9683 100644 --- a/Sources/Feed/FeedNotificationPolicyContext.swift +++ b/Sources/Feed/FeedNotificationPolicyContext.swift @@ -13,10 +13,16 @@ extension FeedNotificationPolicyContext { event: WorkstreamEvent, title: String, body: String, - hookCache: CmuxNotificationHookCache + hookCache: CmuxNotificationHookCache, + defaultGlobalConfigPath: String = CmuxConfigStore.defaultGlobalConfigPath() ) async -> FeedNotificationPolicyContext { let snapshot: FeedNotificationPolicySnapshot = await MainActor.run { - Self.snapshot(event: event, title: title, body: body) + Self.snapshot( + event: event, + title: title, + body: body, + defaultGlobalConfigPath: defaultGlobalConfigPath + ) } let hooks: [CmuxResolvedNotificationHook] if let globalConfigPath = snapshot.globalConfigPath { @@ -38,13 +44,13 @@ extension FeedNotificationPolicyContext { private static func snapshot( event: WorkstreamEvent, title: String, - body: String + body: String, + defaultGlobalConfigPath: String ) -> FeedNotificationPolicySnapshot { let appDelegate = AppDelegate.shared let workspaceID = event.workspaceId.flatMap(UUID.init(uuidString:)) let workspaceContext = workspaceID.flatMap { appDelegate?.contextContainingTabId($0) } let configContext = workspaceContext - ?? appDelegate?.mainWindowContexts.values.first(where: { $0.cmuxConfigStore != nil }) let workspace = workspaceID.flatMap { id in workspaceContext?.tabManager.tabs.first(where: { $0.id == id }) } @@ -64,6 +70,7 @@ extension FeedNotificationPolicyContext { let workspaceIdentity = workspaceID?.uuidString ?? "" let globalConfigPath = configContext?.cmuxConfigStore?.globalConfigPath + ?? defaultGlobalConfigPath let hookSearchDirectory = workspace?.isRemoteWorkspace == true ? nil : (normalizedCWD(event.cwd) ?? workspace?.surfaceTabBarDirectory) diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index a6c4fa65c5f4..adc8cdec2d74 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -118,9 +118,7 @@ struct FeedPanelView: View { #endif } .rightSidebarChromeBar( - leadingPadding: placement == .pane - ? HeaderChromeControlMetrics.titlebarControlsLeadingPadding - : RightSidebarChromeMetrics.barHorizontalPadding + leadingPadding: RightSidebarChromeMetrics.barHorizontalPadding ) .rightSidebarChromeBottomBorder() .reportRightSidebarChromeGeometryForBonsplitUITest(role: .secondaryBar, isVisible: true, titlebarHeight: RightSidebarChromeMetrics.secondaryBarHeight) diff --git a/Sources/Feed/FeedPanelViewModel.swift b/Sources/Feed/FeedPanelViewModel.swift index 621efb2012db..ec11ec1e56b8 100644 --- a/Sources/Feed/FeedPanelViewModel.swift +++ b/Sources/Feed/FeedPanelViewModel.swift @@ -42,6 +42,7 @@ final class FeedPanelViewModel { guard !isLoadingOlderItems, hasMorePersistedItems else { return } loadOlderTask?.cancel() + let presentationStore = presentationStore loadOlderTask = Task { @MainActor in await presentationStore.loadOlderItems() } diff --git a/Sources/Feed/FeedPlanBodyView.swift b/Sources/Feed/FeedPlanBodyView.swift index 2238772905e3..b9148c4e9455 100644 --- a/Sources/Feed/FeedPlanBodyView.swift +++ b/Sources/Feed/FeedPlanBodyView.swift @@ -143,6 +143,8 @@ struct PlanBodyView: View { bulleted.append(text) continue } + flushNumbered() + flushBulleted() buffer.append(line) } flushParagraph(); flushNumbered(); flushBulleted() diff --git a/Sources/Feed/FeedPresentationStore.swift b/Sources/Feed/FeedPresentationStore.swift index fe73cdad5d74..5191da50b6a7 100644 --- a/Sources/Feed/FeedPresentationStore.swift +++ b/Sources/Feed/FeedPresentationStore.swift @@ -51,24 +51,29 @@ final class FeedPresentationStore { private static func makePresentation(items: [WorkstreamItem]) -> FeedPresentationSnapshot { var lastPromptByWorkstream: [String: String] = [:] + var snapshots: [(item: WorkstreamItem, snapshot: FeedItemSnapshot)] = [] + snapshots.reserveCapacity(items.count) for item in items { if case .userPrompt(let text) = item.payload, !text.isEmpty { lastPromptByWorkstream[item.workstreamId] = text } + snapshots.append(( + item: item, + snapshot: FeedItemSnapshot( + item: item, + userPromptEcho: lastPromptByWorkstream[item.workstreamId] + ) + )) } var actionable: [FeedItemSnapshot] = [] var activityStable: [FeedItemSnapshot] = [] var activityHistory: [FeedItemSnapshot] = [] - actionable.reserveCapacity(items.count) - activityStable.reserveCapacity(items.count) - activityHistory.reserveCapacity(items.count) + actionable.reserveCapacity(snapshots.count) + activityStable.reserveCapacity(snapshots.count) + activityHistory.reserveCapacity(snapshots.count) - for item in items.reversed() { - let snapshot = FeedItemSnapshot( - item: item, - userPromptEcho: lastPromptByWorkstream[item.workstreamId] - ) + for (item, snapshot) in snapshots.reversed() { if item.kind.isActionable { actionable.append(snapshot) } diff --git a/Sources/Feed/FeedRowActions.swift b/Sources/Feed/FeedRowActions.swift index 456f0355d2ea..4ec50951d9cd 100644 --- a/Sources/Feed/FeedRowActions.swift +++ b/Sources/Feed/FeedRowActions.swift @@ -2,19 +2,39 @@ import CMUXAgentLaunch /// Closure bundle; binds to `FeedCoordinator` by default. struct FeedRowActions { - let approvePermission: (String, WorkstreamPermissionMode) -> Void - let replyQuestion: (String, [String]) -> Void - let approveExitPlan: (String, WorkstreamExitPlanMode, String?) -> Void - let jump: (String) -> Void + let approvePermission: @MainActor (String, WorkstreamPermissionMode) -> Void + let replyQuestion: @MainActor (String, [String]) -> Void + let approveExitPlan: @MainActor (String, WorkstreamExitPlanMode, String?) -> Void + let jump: @MainActor (String) -> Void /// Types the user's reply into the agent's terminal surface and /// presses Return. Used by Stop-kind cards so the user can nudge /// Claude without switching focus to the terminal. - let sendText: (String, String) -> Void + let sendText: @MainActor (String, String) -> Void - static func bound() -> FeedRowActions { + @MainActor + final class TaskStore { + private var tasks: [UUID: Task] = [:] + + deinit { + for task in tasks.values { + task.cancel() + } + } + + func run(_ operation: @escaping @MainActor () async -> Void) { + let id = UUID() + tasks[id] = Task { [weak self] in + await operation() + self?.tasks.removeValue(forKey: id) + } + } + } + + @MainActor + static func bound(taskStore: TaskStore) -> FeedRowActions { FeedRowActions( approvePermission: { requestID, mode in - Task { + taskStore.run { await FeedCoordinator.shared.deliverReply( requestId: requestID, decision: .permission(mode) @@ -22,7 +42,7 @@ struct FeedRowActions { } }, replyQuestion: { requestID, selections in - Task { + taskStore.run { await FeedCoordinator.shared.deliverReply( requestId: requestID, decision: .question(selections: selections) @@ -30,7 +50,7 @@ struct FeedRowActions { } }, approveExitPlan: { requestID, mode, feedback in - Task { + taskStore.run { await FeedCoordinator.shared.deliverReply( requestId: requestID, decision: .exitPlan(mode, feedback: feedback) @@ -38,12 +58,12 @@ struct FeedRowActions { } }, jump: { workstreamId in - Task { + taskStore.run { _ = await FeedCoordinator.shared.focusIfPossible(workstreamId: workstreamId) } }, sendText: { workstreamId, text in - Task { + taskStore.run { await FeedCoordinator.shared.sendTextToWorkstream( workstreamId: workstreamId, text: text diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 9281fe37eb5a..f55eacf86f85 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1207,6 +1207,8 @@ class TerminalController { return v2Result(id: request.id, v2FeedQuestionReply(params: request.params)) case "feed.exit_plan.reply": return v2Result(id: request.id, v2FeedExitPlanReply(params: request.params)) + case "feed.jump": + return v2Result(id: request.id, v2FeedJumpOffMain(params: request.params)) case "browser.download.wait": return v2Result(id: request.id, v2BrowserDownloadWaitOnSocketWorker(params: request.params)) case "browser.navigate", "browser.back", "browser.forward", "browser.reload", @@ -5642,6 +5644,20 @@ class TerminalController { return .ok(FeedCoordinator.shared.socketEncoder.payload(for: result)) } + private nonisolated func v2FeedJumpOffMain(params: [String: Any]) -> V2CallResult { + guard let workstreamID = params["workstream_id"] as? String else { + return .err( + code: "invalid_params", + message: "feed.jump requires workstream_id", + data: nil + ) + } + return .ok([ + "workstream_id": workstreamID, + "matched": FeedCoordinator.shared.resolvePossibleSurface(for: workstreamID), + ]) + } + private nonisolated func v2ApplyIMessageModeSideEffects(for event: WorkstreamEvent) { guard event.hookEventName == .userPromptSubmit || event.hookEventName == .stop, let rawWorkspaceId = event.workspaceId?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 5bef60aa04eb..eca25928a258 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -46,7 +46,7 @@ struct FeedCoordinatorTests { } @MainActor - @Test func notificationPolicyFailsClosedWithoutWorkspaceMapping() async throws { + @Test func notificationPolicyUsesGlobalHooksWithoutWorkspaceMapping() async throws { try await AppContextSerialGate.withExclusiveAppContext { let root = FileManager.default.temporaryDirectory.appendingPathComponent( "cmux-feed-global-hook-\(UUID().uuidString)", @@ -84,11 +84,12 @@ struct FeedCoordinatorTests { event: event, title: "Done", body: "Finished", - hookCache: CmuxNotificationHookCache() + hookCache: CmuxNotificationHookCache(), + defaultGlobalConfigPath: configURL.path ) - #expect(context.globalConfigPath == nil) - #expect(context.hooks.isEmpty) + #expect(context.globalConfigPath == configURL.path) + #expect(context.hooks.map(\.id) == ["feed-global"]) } } @@ -229,6 +230,23 @@ struct FeedCoordinatorTests { #expect(await registry.remove(requestID: requestID)?.decision == .permission(.once)) } + @Test func blockingWaiterRejectsReplyWhileTimeoutIsBeingFinalized() async throws { + let registry = FeedBlockingWaiterRegistry() + let requestID = "timeout-finalization-race" + _ = try #require(await registry.register(requestID: requestID)) + + guard case .timedOut = await registry.completeAfterWait(requestID: requestID) else { + Issue.record("expected the unanswered waiter to enter its timeout state") + return + } + let delivery = await registry.deliver(.permission(.once), requestID: requestID) + + #expect(!delivery.accepted) + #expect(delivery.registered) + #expect(delivery.timedOut) + await registry.finalizeExpiration(requestID: requestID) + } + @Test func codexTeamsResolvesExplicitWorkingDirectoryFlags() { let base = "/tmp/cmux-base" diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 55148e876ee9..f9f26adbecd4 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -1178,6 +1178,7 @@ final class ShortcutHintModifierPolicyTests: XCTestCase { } +@MainActor final class RightSidebarModeShortcutHintTests: XCTestCase { private let touchedShortcutActions: [KeyboardShortcutSettings.Action] = [ .focusRightSidebar, From f36af5479d9c792951baaeb9a5b670e8a031c8f2 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:54:33 -0700 Subject: [PATCH 26/49] Split Feed waiter completion state --- Sources/Feed/FeedBlockingWaiterCompletion.swift | 7 +++++++ Sources/Feed/FeedBlockingWaiterRegistry.swift | 8 +------- cmux.xcodeproj/project.pbxproj | 4 ++++ 3 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 Sources/Feed/FeedBlockingWaiterCompletion.swift diff --git a/Sources/Feed/FeedBlockingWaiterCompletion.swift b/Sources/Feed/FeedBlockingWaiterCompletion.swift new file mode 100644 index 000000000000..53e4f921d41b --- /dev/null +++ b/Sources/Feed/FeedBlockingWaiterCompletion.swift @@ -0,0 +1,7 @@ +import CMUXAgentLaunch + +enum FeedBlockingWaiterCompletion { + case resolved(FeedPendingWaiter, WorkstreamDecision) + case timedOut(FeedPendingWaiter) + case missing +} diff --git a/Sources/Feed/FeedBlockingWaiterRegistry.swift b/Sources/Feed/FeedBlockingWaiterRegistry.swift index 30d7561d5831..5cf5e8d3445e 100644 --- a/Sources/Feed/FeedBlockingWaiterRegistry.swift +++ b/Sources/Feed/FeedBlockingWaiterRegistry.swift @@ -10,12 +10,6 @@ actor FeedBlockingWaiterRegistry { private var waiters: [String: FeedPendingWaiter] = [:] private var timedOutRequestIDs: Set = [] - enum Completion { - case resolved(FeedPendingWaiter, WorkstreamDecision) - case timedOut(FeedPendingWaiter) - case missing - } - func register(requestID: String) -> AsyncStream? { guard waiters[requestID] == nil else { return nil } timedOutRequestIDs.remove(requestID) @@ -77,7 +71,7 @@ actor FeedBlockingWaiterRegistry { /// A decision already accepted by the actor wins; otherwise the waiter is /// retained as timed out until its store item finishes expiring, so a late /// reply cannot reopen the decision during that main-actor transition. - func completeAfterWait(requestID: String) -> Completion { + func completeAfterWait(requestID: String) -> FeedBlockingWaiterCompletion { guard let waiter = waiters[requestID] else { return .missing } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3193e55bf43f..f7aa5466aeff 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -789,6 +789,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources FEED0A00000000000000F007 /* feed-tui in Resources */ = {isa = PBXBuildFile; fileRef = FEED0A00000000000000F006 /* feed-tui */; }; FEE200000000000000000013 /* FeedActivitySnapshotGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000013 /* FeedActivitySnapshotGroups.swift */; }; FEE200000000000000000012 /* FeedBlockingCallBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000012 /* FeedBlockingCallBridge.swift */; }; + FEE200000000000000000017 /* FeedBlockingWaiterCompletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000017 /* FeedBlockingWaiterCompletion.swift */; }; FEE200000000000000000002 /* FeedBlockingWaiterRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000002 /* FeedBlockingWaiterRegistry.swift */; }; F17B00000000000000000001 /* FeedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000001 /* FeedButton.swift */; }; F17B00000000000000000030 /* FeedButtonKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000030 /* FeedButtonKind.swift */; }; @@ -2879,6 +2880,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEED0A00000000000000F006 /* feed-tui */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; path = "feed-tui"; sourceTree = ""; }; FEE100000000000000000013 /* FeedActivitySnapshotGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedActivitySnapshotGroups.swift; sourceTree = ""; }; FEE100000000000000000012 /* FeedBlockingCallBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedBlockingCallBridge.swift; sourceTree = ""; }; + FEE100000000000000000017 /* FeedBlockingWaiterCompletion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedBlockingWaiterCompletion.swift; sourceTree = ""; }; FEE100000000000000000002 /* FeedBlockingWaiterRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedBlockingWaiterRegistry.swift; sourceTree = ""; }; F17A00000000000000000001 /* FeedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedButton.swift; sourceTree = ""; }; F17A00000000000000000030 /* FeedButtonKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedButtonKind.swift; sourceTree = ""; }; @@ -6317,6 +6319,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEE100000000000000000005 /* FeedCoordinator+Socket.swift */, FEE100000000000000000006 /* FeedCoordinatorState.swift */, FEE100000000000000000002 /* FeedBlockingWaiterRegistry.swift */, + FEE100000000000000000017 /* FeedBlockingWaiterCompletion.swift */, FEE100000000000000000012 /* FeedBlockingCallBridge.swift */, FEE100000000000000000013 /* FeedActivitySnapshotGroups.swift */, FEE100000000000000000014 /* FeedPresentationSnapshot.swift */, @@ -7259,6 +7262,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DEA7710000000000000007 /* FeatureFlags.swift in Sources */, FEE200000000000000000013 /* FeedActivitySnapshotGroups.swift in Sources */, FEE200000000000000000012 /* FeedBlockingCallBridge.swift in Sources */, + FEE200000000000000000017 /* FeedBlockingWaiterCompletion.swift in Sources */, FEE200000000000000000002 /* FeedBlockingWaiterRegistry.swift in Sources */, F17B00000000000000000001 /* FeedButton.swift in Sources */, F17B00000000000000000030 /* FeedButtonKind.swift in Sources */, From 63f37058e1a27705566b63c8b57cee67cb4052d5 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:02:08 -0700 Subject: [PATCH 27/49] Test Feed reply delivery failures --- cmuxTests/FeedFocusRoutingTests.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmuxTests/FeedFocusRoutingTests.swift b/cmuxTests/FeedFocusRoutingTests.swift index 42a7fecfab13..2057a399821b 100644 --- a/cmuxTests/FeedFocusRoutingTests.swift +++ b/cmuxTests/FeedFocusRoutingTests.swift @@ -112,4 +112,10 @@ import Testing #expect(editor.needsLayout) } + + @Test func feedTextRoutingReportsRejectedSurface() { + let appDelegate = AppDelegate() + + #expect(!appDelegate.routeFeedText(surfaceId: "not-a-surface-id", text: "retry this")) + } } From 9083f1917841a84ef73c631dd22ce4c485f17092 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:04:01 -0700 Subject: [PATCH 28/49] Preserve failed Feed replies --- Resources/Localizable.xcstrings | 17 ++++++ Sources/AppDelegate.swift | 60 ++++++++++--------- Sources/Feed/FeedItemRow.swift | 4 +- .../Feed/FeedPreviewWindowController.swift | 5 +- Sources/Feed/FeedRowActions.swift | 8 ++- Sources/Feed/FeedStopDraft.swift | 8 ++- Sources/Feed/FeedTelemetryView.swift | 25 ++++++-- cmuxTests/FeedFocusRoutingTests.swift | 14 +++++ 8 files changed, 103 insertions(+), 38 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 325014685b2c..34e5e22e4d2d 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -104737,6 +104737,23 @@ } } }, + "feed.stop.sendFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t reach the original terminal. Your reply is still here." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元のターミナルに接続できませんでした。返信内容は保持されています。" + } + } + } + }, "feed.telemetry.error": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 77228a84977e..4372db4f3b76 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9328,24 +9328,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Invoke the existing V2 commands so the Feed-layer focus request // goes through the same code path as a socket-initiated focus. // Serialize through JSON so we reuse the v2 command parser. - let controller = TerminalController.shared - let invoke: (String, [String: Any]) -> Void = { method, params in - let payload: [String: Any] = [ - "id": UUID().uuidString, - "method": method, - "params": params - ] - guard let data = try? JSONSerialization.data(withJSONObject: payload), - let line = String(data: data, encoding: .utf8) - else { return } - _ = controller.handleSocketLine(line) + guard invokeFeedSocketCommand( + "workspace.select", + params: ["workspace_id": workspaceId] + ), invokeFeedSocketCommand( + "surface.focus", + params: ["surface_id": surfaceId] + ) else { + return false } - invoke("workspace.select", ["workspace_id": workspaceId]) - invoke("surface.focus", ["surface_id": surfaceId]) // Flash the terminal's own focus ring (same visual as // cmd+shift+H / Flash Focused Panel) so the user's eye is // pulled to the terminal content the Feed jumped to. - invoke("surface.trigger_flash", ["surface_id": surfaceId]) + _ = invokeFeedSocketCommand( + "surface.trigger_flash", + params: ["surface_id": surfaceId] + ) return true } @@ -9353,25 +9351,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func routeFeedText(surfaceId: String, text: String) -> Bool { guard !text.isEmpty else { return false } - let controller = TerminalController.shared - let invoke: (String, [String: Any]) -> Void = { method, params in - let payload: [String: Any] = [ - "id": UUID().uuidString, - "method": method, - "params": params, - ] - guard let data = try? JSONSerialization.data(withJSONObject: payload), - let line = String(data: data, encoding: .utf8) - else { return } - _ = controller.handleSocketLine(line) - } // Terminal-mode Return is CR. sendNamedKey "Return" also works // but one send_text is atomic, so append CR directly. - invoke("surface.send_text", [ + return invokeFeedSocketCommand("surface.send_text", params: [ "surface_id": surfaceId, "text": text + "\r", ]) - return true + } + + private func invokeFeedSocketCommand( + _ method: String, + params: [String: Any] + ) -> Bool { + let payload: [String: Any] = [ + "id": UUID().uuidString, + "method": method, + "params": params, + ] + guard let requestData = try? JSONSerialization.data(withJSONObject: payload), + let requestLine = String(data: requestData, encoding: .utf8), + let responseData = TerminalController.shared.handleSocketLine(requestLine).data(using: .utf8), + let response = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] + else { + return false + } + return response["ok"] as? Bool == true } @objc private func handleFeedFeatureFlagChange(_ notification: Notification) { diff --git a/Sources/Feed/FeedItemRow.swift b/Sources/Feed/FeedItemRow.swift index f983786664f6..fae96066023a 100644 --- a/Sources/Feed/FeedItemRow.swift +++ b/Sources/Feed/FeedItemRow.swift @@ -318,7 +318,9 @@ struct FeedItemRow: View, Equatable { onFocusRow: onControlFocus, onActionRow: onControlAction, onBlurRow: onControlBlur, - onSend: { text in actions.sendText(snapshot.workstreamId, text) } + onSend: { text, completion in + actions.sendText(snapshot.workstreamId, text, completion) + } ) default: TelemetryActionArea(snapshot: snapshot) diff --git a/Sources/Feed/FeedPreviewWindowController.swift b/Sources/Feed/FeedPreviewWindowController.swift index 8e72073697e9..ba21ec01b64e 100644 --- a/Sources/Feed/FeedPreviewWindowController.swift +++ b/Sources/Feed/FeedPreviewWindowController.swift @@ -152,7 +152,10 @@ private enum FeedPreviewActions { print("preview.exitPlan \(id) \(mode) feedback=\(feedback ?? "nil")") }, jump: { ws in print("preview.jump \(ws)") }, - sendText: { ws, text in print("preview.sendText \(ws) \(text)") } + sendText: { ws, text, completion in + print("preview.sendText \(ws) \(text)") + completion(true) + } ) } } diff --git a/Sources/Feed/FeedRowActions.swift b/Sources/Feed/FeedRowActions.swift index 4ec50951d9cd..a323b85f446b 100644 --- a/Sources/Feed/FeedRowActions.swift +++ b/Sources/Feed/FeedRowActions.swift @@ -9,7 +9,7 @@ struct FeedRowActions { /// Types the user's reply into the agent's terminal surface and /// presses Return. Used by Stop-kind cards so the user can nudge /// Claude without switching focus to the terminal. - let sendText: @MainActor (String, String) -> Void + let sendText: @MainActor (String, String, @escaping @MainActor (Bool) -> Void) -> Void @MainActor final class TaskStore { @@ -62,12 +62,14 @@ struct FeedRowActions { _ = await FeedCoordinator.shared.focusIfPossible(workstreamId: workstreamId) } }, - sendText: { workstreamId, text in + sendText: { workstreamId, text, completion in taskStore.run { - await FeedCoordinator.shared.sendTextToWorkstream( + let sent = await FeedCoordinator.shared.sendTextToWorkstream( workstreamId: workstreamId, text: text ) + guard !Task.isCancelled else { return } + completion(sent) } } ) diff --git a/Sources/Feed/FeedStopDraft.swift b/Sources/Feed/FeedStopDraft.swift index 64ad7530c078..17b3588147f7 100644 --- a/Sources/Feed/FeedStopDraft.swift +++ b/Sources/Feed/FeedStopDraft.swift @@ -10,5 +10,11 @@ struct FeedStopDraft: Equatable { var isPristine: Bool { reply.isEmpty } -} + mutating func finishSend(submittedReply: String, succeeded: Bool) { + guard succeeded, + reply.trimmingCharacters(in: .whitespacesAndNewlines) == submittedReply + else { return } + reply = "" + } +} diff --git a/Sources/Feed/FeedTelemetryView.swift b/Sources/Feed/FeedTelemetryView.swift index e20706138389..d57e1ddbfe4b 100644 --- a/Sources/Feed/FeedTelemetryView.swift +++ b/Sources/Feed/FeedTelemetryView.swift @@ -13,12 +13,15 @@ struct StopActionArea: View { let onFocusRow: () -> Void let onActionRow: () -> Void let onBlurRow: () -> Void - let onSend: (String) -> Void + let onSend: (String, @escaping @MainActor (Bool) -> Void) -> Void + + @State private var isSending = false + @State private var sendFailed = false private var trimmed: String { draft.reply.trimmingCharacters(in: .whitespacesAndNewlines) } - private var canSend: Bool { !trimmed.isEmpty } + private var canSend: Bool { !trimmed.isEmpty && !isSending } @Environment(\.cmuxGlobalFontMagnificationPercent) private var globalFontPercent private var replyFont: NSFont { GlobalFontMagnification.systemFont(ofSize: 12) } private var replyBinding: Binding { @@ -71,6 +74,14 @@ struct StopActionArea: View { onFocusRow() requestReplyFocus() } + if sendFailed { + Text(String( + localized: "feed.stop.sendFailed", + defaultValue: "Couldn’t reach the original terminal. Your reply is still here." + )) + .cmuxFont(size: 10, weight: .medium) + .foregroundStyle(.red) + } FeedButton( label: String(localized: "feed.stop.send", defaultValue: "Send to Claude"), leadingIcon: "arrow.up.circle.fill", @@ -92,8 +103,14 @@ struct StopActionArea: View { private func sendReply() { guard canSend else { return } - onSend(trimmed) - draft.reply = "" + let submittedReply = trimmed + isSending = true + sendFailed = false + onSend(submittedReply) { sent in + isSending = false + sendFailed = !sent + draft.finishSend(submittedReply: submittedReply, succeeded: sent) + } } } diff --git a/cmuxTests/FeedFocusRoutingTests.swift b/cmuxTests/FeedFocusRoutingTests.swift index 2057a399821b..9f2a46a23cb5 100644 --- a/cmuxTests/FeedFocusRoutingTests.swift +++ b/cmuxTests/FeedFocusRoutingTests.swift @@ -118,4 +118,18 @@ import Testing #expect(!appDelegate.routeFeedText(surfaceId: "not-a-surface-id", text: "retry this")) } + + @Test func stopDraftClearsOnlyAfterMatchingSuccessfulDelivery() { + var failed = FeedStopDraft(reply: "retry this") + failed.finishSend(submittedReply: "retry this", succeeded: false) + #expect(failed.reply == "retry this") + + var edited = FeedStopDraft(reply: "newer text") + edited.finishSend(submittedReply: "older text", succeeded: true) + #expect(edited.reply == "newer text") + + var sent = FeedStopDraft(reply: " delivered ") + sent.finishSend(submittedReply: "delivered", succeeded: true) + #expect(sent.reply.isEmpty) + } } From 3b1869c091d8056f167803225f65c65bc09a0a83 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:05:00 -0700 Subject: [PATCH 29/49] Import Feed task identifiers --- Sources/Feed/FeedRowActions.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Feed/FeedRowActions.swift b/Sources/Feed/FeedRowActions.swift index a323b85f446b..e649b44bfce0 100644 --- a/Sources/Feed/FeedRowActions.swift +++ b/Sources/Feed/FeedRowActions.swift @@ -1,4 +1,5 @@ import CMUXAgentLaunch +import Foundation /// Closure bundle; binds to `FeedCoordinator` by default. struct FeedRowActions { From 8738372101b229356a7480dc065237b2556e792c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:54:11 -0700 Subject: [PATCH 30/49] Test Feed focus pair validation --- cmuxTests/FeedFocusRoutingTests.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cmuxTests/FeedFocusRoutingTests.swift b/cmuxTests/FeedFocusRoutingTests.swift index 9f2a46a23cb5..b33f718d6c00 100644 --- a/cmuxTests/FeedFocusRoutingTests.swift +++ b/cmuxTests/FeedFocusRoutingTests.swift @@ -119,6 +119,24 @@ import Testing #expect(!appDelegate.routeFeedText(surfaceId: "not-a-surface-id", text: "retry this")) } + @Test func feedFocusRejectsMismatchedWorkspaceWithoutChangingSelection() throws { + let appDelegate = AppDelegate() + let manager = TabManager() + let origin = try #require(manager.selectedWorkspace) + let panel = try #require(origin.focusedTerminalPanel) + let surfaceID = try #require(origin.surfaceIdFromPanelId(panel.id)?.uuid) + let other = manager.addWorkspace() + let windowID = appDelegate.registerMainWindowContextForTesting(tabManager: manager) + defer { appDelegate.unregisterMainWindowContextForTesting(windowId: windowID) } + let selectedBeforeRoute = manager.selectedTabId + + #expect(!appDelegate.routeFeedFocus( + workspaceId: other.id.uuidString, + surfaceId: surfaceID.uuidString + )) + #expect(manager.selectedTabId == selectedBeforeRoute) + } + @Test func stopDraftClearsOnlyAfterMatchingSuccessfulDelivery() { var failed = FeedStopDraft(reply: "retry this") failed.finishSend(submittedReply: "retry this", succeeded: false) From e14b8ab1fcdf1372a35e1f2f900f31fd6409ccd2 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:54:36 -0700 Subject: [PATCH 31/49] Validate Feed focus targets before routing --- Sources/AppDelegate.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 4372db4f3b76..7ff1383bb87e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9325,6 +9325,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func routeFeedFocus(workspaceId: String, surfaceId: String) -> Bool { + guard let claimedWorkspaceID = UUID(uuidString: workspaceId), + let claimedSurfaceID = UUID(uuidString: surfaceId), + let located = locateBonsplitSurface(tabId: claimedSurfaceID), + located.workspaceId == claimedWorkspaceID + else { + return false + } // Invoke the existing V2 commands so the Feed-layer focus request // goes through the same code path as a socket-initiated focus. // Serialize through JSON so we reuse the v2 command parser. From 7301411678fc66cbe0f13ff5a42ad765d3e3a0fc Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:00:43 -0700 Subject: [PATCH 32/49] Make Feed rollout fail closed in Release --- Sources/FeatureFlags.swift | 13 +++++++++---- cmuxTests/FeedCoordinatorTests.swift | 14 ++++++++++++++ cmuxTests/PostHogAnalyticsPropertiesTests.swift | 8 ++++++-- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index da7a2d4a5e07..2c4b5170c88d 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -48,7 +48,12 @@ final class CmuxFeatureFlags { private static let sidebarWorkspaceAgentSpinnerDefault = false private static let workspaceTodoControlsDefault = false private static let appKitSidebarListDefault = false - private static let feedUIDefault = true + + #if DEBUG + private static let feedUIDefault = true + #else + private static let feedUIDefault = false + #endif private static let overrideKeyPrefix = "cmux.flags.override." @@ -172,10 +177,10 @@ final class CmuxFeatureFlags { ) // FLAG(key: feed-ui-enabled-release, owner: lawrencecchen, - // reviewBy: 2026-10-01, defaultWhenUnavailable: true) + // reviewBy: 2026-10-01, defaultWhenUnavailable: false) // Shows Feed in the right sidebar, new-workspace menu, and command - // palette. The default ships Feed to everyone while retaining a - // remote kill switch for operational regressions. + // palette. Release builds hide it until PostHog enables the rollout; + // DEBUG keeps it visible for dogfood when remote flags are unavailable. private static let feedDefinition = CmuxFeatureFlagDefinition( key: "feed-ui-enabled-release", title: String(localized: "featureFlags.feed.title", defaultValue: "Feed UI"), diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index eca25928a258..a59f7d03dc1e 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -194,6 +194,20 @@ struct FeedCoordinatorTests { #expect(workspace.focusedPanelId == panel.id) } + @MainActor + @Test func disabledFeedOpeningCoordinatorCannotCreateEntryPoints() { + let manager = TabManager() + let workspace = manager.addWorkspace() + let workspaceCount = manager.tabs.count + let panelCount = workspace.panels.count + let coordinator = FeedOpeningCoordinator(isEnabled: { false }) + + #expect(coordinator.openPinnedWorkspace(in: manager) == nil) + #expect(coordinator.openPane(in: workspace) == nil) + #expect(manager.tabs.count == workspaceCount) + #expect(workspace.panels.count == panelCount) + } + @MainActor @Test func disabledFeedCannotRestoreOrCreatePaneSurface() throws { let flags = CmuxFeatureFlags.shared diff --git a/cmuxTests/PostHogAnalyticsPropertiesTests.swift b/cmuxTests/PostHogAnalyticsPropertiesTests.swift index 5ff0eebbc580..cecfabef9235 100644 --- a/cmuxTests/PostHogAnalyticsPropertiesTests.swift +++ b/cmuxTests/PostHogAnalyticsPropertiesTests.swift @@ -7,8 +7,8 @@ import Testing @Suite(.serialized) struct PostHogAnalyticsPropertiesTests { @MainActor - @Test("Feed feature flag defaults on and supports a remote kill switch") - func feedFeatureFlagDefaultsOnAndSupportsRemoteKillSwitch() throws { + @Test("Feed feature flag defaults on in Debug and follows remote rollout values") + func feedFeatureFlagDefaultsOnInDebugAndFollowsRemoteRolloutValues() throws { let definition = try #require(CmuxFeatureFlags.allFlags.first { $0.key == "feed-ui-enabled-release" }) #expect(definition.defaultWhenUnavailable) @@ -25,6 +25,10 @@ struct PostHogAnalyticsPropertiesTests { remoteValue = false flags.applyLoadedFlags() #expect(!flags.isFeedUIEnabled) + + remoteValue = true + flags.applyLoadedFlags() + #expect(flags.isFeedUIEnabled) } @Test("feature flag bool coercion accepts PostHog bool-like values") From a698bfefd98e118d2f78c5c43066ddd62cf8d952 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:44:46 -0700 Subject: [PATCH 33/49] Add Feed interaction regression coverage --- .../Workstream/WorkstreamStoreTests.swift | 36 +++++++++++++++++++ cmuxTests/FeedFocusRoutingTests.swift | 35 ++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift index e5a7c58d19e2..6f8612010c49 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift @@ -73,6 +73,42 @@ struct WorkstreamStoreTests { #expect(!store.hasMorePersistedItems) } + @Test("removing an item hides it immediately and after restart") + func removeItemPersists() async throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-workstream-store-remove-\(UUID().uuidString).jsonl") + defer { + try? FileManager.default.removeItem(at: tmp) + try? FileManager.default.removeItem( + at: WorkstreamPersistence.removedItemsFileURL(for: tmp) + ) + } + let persistence = WorkstreamPersistence(fileURL: tmp) + let kept = WorkstreamItem( + workstreamId: "kept", + source: .opencode, + kind: .sessionStart, + payload: .sessionStart + ) + let removed = WorkstreamItem( + workstreamId: "removed", + source: .opencode, + kind: .sessionEnd, + payload: .sessionEnd + ) + try await persistence.append(kept) + try await persistence.append(removed) + + let store = WorkstreamStore(persistence: persistence, ringCapacity: 10) + await store.start() + #expect(try await store.removeItem(id: removed.id)) + #expect(store.items.map(\.id) == [kept.id]) + + let restored = WorkstreamStore(persistence: persistence, ringCapacity: 10) + await restored.start() + #expect(restored.items.map(\.id) == [kept.id]) + } + @Test("expireAbandonedItems expires items whose agent PID is dead") func expireAbandoned() { let clock = TestClock(initial: Date(timeIntervalSince1970: 0)) diff --git a/cmuxTests/FeedFocusRoutingTests.swift b/cmuxTests/FeedFocusRoutingTests.swift index b33f718d6c00..9b5d79a1e695 100644 --- a/cmuxTests/FeedFocusRoutingTests.swift +++ b/cmuxTests/FeedFocusRoutingTests.swift @@ -137,6 +137,41 @@ import Testing #expect(manager.selectedTabId == selectedBeforeRoute) } + @Test func feedFocusSelectsAndFlashesTheOriginatingTerminal() throws { + let appDelegate = AppDelegate() + let manager = TabManager() + let targetWorkspace = try #require(manager.selectedWorkspace) + let targetPanel = try #require(targetWorkspace.focusedTerminalPanel) + let targetSurfaceID = try #require( + targetWorkspace.surfaceIdFromPanelId(targetPanel.id)?.uuid + ) + let feedWorkspace = manager.addWorkspace() + let windowID = appDelegate.registerMainWindowContextForTesting(tabManager: manager) + defer { appDelegate.unregisterMainWindowContextForTesting(windowId: windowID) } + GhosttySurfaceScrollView.resetFlashCounts() + + #expect(manager.selectedTabId == feedWorkspace.id) + #expect(appDelegate.routeFeedFocus( + workspaceId: targetWorkspace.id.uuidString, + surfaceId: targetSurfaceID.uuidString + )) + #expect(manager.selectedTabId == targetWorkspace.id) + #expect(targetWorkspace.focusedPanelId == targetPanel.id) + #expect(GhosttySurfaceScrollView.flashCount(for: targetPanel.id) == 1) + } + + @Test func resolvedQuestionSelectionsRemainAvailableForPresentation() { + let status = WorkstreamStatus.resolved( + .question(selections: ["Answering a question"]), + at: Date(timeIntervalSince1970: 1) + ) + + #expect( + QuestionActionArea.resolvedSelections(in: status) + == ["Answering a question"] + ) + } + @Test func stopDraftClearsOnlyAfterMatchingSuccessfulDelivery() { var failed = FeedStopDraft(reply: "retry this") failed.finishSend(submittedReply: "retry this", succeeded: false) From 956a371a700f31a179bb39f337ee413572c31d64 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:47:34 -0700 Subject: [PATCH 34/49] Fix Feed card interaction and history actions --- .../Workstream/WorkstreamPersistence.swift | 96 +++++++++++++++---- .../Workstream/WorkstreamStore.swift | 13 +++ Resources/Localizable.xcstrings | 51 ++++++++++ Sources/AppDelegate.swift | 32 +++---- Sources/Feed/FeedCoordinator+Socket.swift | 8 ++ Sources/Feed/FeedItemRow.swift | 5 + Sources/Feed/FeedLabeledTextRow.swift | 4 +- .../Feed/FeedPreviewWindowController.swift | 1 + Sources/Feed/FeedQuestionView.swift | 34 ++++++- Sources/Feed/FeedRowActions.swift | 6 ++ Sources/Feed/FeedRowSurface.swift | 21 +++- 11 files changed, 232 insertions(+), 39 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift index 74e509496500..de234264b61d 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift @@ -26,12 +26,16 @@ public actor WorkstreamPersistence { } private let fileURL: URL + private let removedItemsFileURL: URL private let encoder: JSONEncoder private let decoder: JSONDecoder private var handle: FileHandle? + private var removedItemsHandle: FileHandle? + private var cachedRemovedItemIDs: Set? public init(fileURL: URL) { self.fileURL = fileURL + self.removedItemsFileURL = Self.removedItemsFileURL(for: fileURL) let enc = JSONEncoder() enc.dateEncodingStrategy = .iso8601 self.encoder = enc @@ -48,6 +52,10 @@ public actor WorkstreamPersistence { .appendingPathComponent("workstream.jsonl", isDirectory: false) } + public static func removedItemsFileURL(for fileURL: URL) -> URL { + fileURL.deletingPathExtension().appendingPathExtension("removed.jsonl") + } + /// Appends a single item as a JSON line. Creates the file and parent /// directory lazily on first write. public func append(_ item: WorkstreamItem) throws { @@ -59,6 +67,21 @@ public actor WorkstreamPersistence { try fh.write(contentsOf: line) } + /// Persists an item tombstone without rewriting the unbounded event log. + /// Readers apply tombstones while paging, so user removals survive restart + /// and existing byte cursors remain stable while new events are appended. + public func remove(_ itemID: UUID) throws { + var removedItemIDs = try loadRemovedItemIDs() + guard removedItemIDs.insert(itemID).inserted else { return } + + var line = Data(itemID.uuidString.utf8) + line.append(0x0A) + let fh = try handleForRemovedItemsWriting() + try fh.seekToEnd() + try fh.write(contentsOf: line) + cachedRemovedItemIDs = removedItemIDs + } + /// Loads the last `limit` items from the file. Order in the returned /// array is oldest-first. Missing file returns empty. public func loadRecent(limit: Int) throws -> [WorkstreamItem] { @@ -79,6 +102,7 @@ public actor WorkstreamPersistence { guard FileManager.default.fileExists(atPath: fileURL.path) else { return Page(items: [], hasMoreBefore: false, startOffset: nil) } + let removedItemIDs = try loadRemovedItemIDs() let fh = try FileHandle(forReadingFrom: fileURL) defer { try? fh.close() } let fileSize = try fh.seekToEnd() @@ -100,7 +124,7 @@ public actor WorkstreamPersistence { } tail.insert(contentsOf: chunk, at: 0) lineRanges = Self.lineRanges(in: tail, baseOffset: offset) - if lineRanges.count > limit { + if lineRanges.count > limit + removedItemIDs.count { break } } @@ -108,18 +132,20 @@ public actor WorkstreamPersistence { if lineRanges.isEmpty { lineRanges = Self.lineRanges(in: tail, baseOffset: offset) } - let selectedRanges = lineRanges.suffix(limit) - var out: [WorkstreamItem] = [] - out.reserveCapacity(selectedRanges.count) - for lineRange in selectedRanges { + var decoded: [(item: WorkstreamItem, startOffset: UInt64)] = [] + decoded.reserveCapacity(lineRanges.count) + for lineRange in lineRanges { let slice = tail.subdata(in: lineRange.range) - if let item = try? decoder.decode(WorkstreamItem.self, from: slice) { - out.append(item) + if let item = try? decoder.decode(WorkstreamItem.self, from: slice), + !removedItemIDs.contains(item.id) { + decoded.append((item, lineRange.startOffset)) } // Malformed lines are dropped silently; the audit log is // append-only and we don't want a corrupt row to block startup. } - let startOffset = selectedRanges.first?.startOffset + let selected = decoded.suffix(limit) + let out = selected.map(\.item) + let startOffset = selected.first?.startOffset return Page( items: out, hasMoreBefore: (startOffset ?? 0) > 0, @@ -132,16 +158,23 @@ public actor WorkstreamPersistence { if let fh = handle { try fh.close() } + if let fh = removedItemsHandle { + try fh.close() + } handle = nil - do { - try FileManager.default.removeItem(at: fileURL) - } catch { - let nsError = error as NSError - if nsError.domain == NSCocoaErrorDomain, - nsError.code == NSFileNoSuchFileError { - return + removedItemsHandle = nil + cachedRemovedItemIDs = [] + for url in [fileURL, removedItemsFileURL] { + do { + try FileManager.default.removeItem(at: url) + } catch { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain, + nsError.code == NSFileNoSuchFileError { + continue + } + throw error } - throw error } } @@ -160,6 +193,37 @@ public actor WorkstreamPersistence { return fh } + private func handleForRemovedItemsWriting() throws -> FileHandle { + if let removedItemsHandle { return removedItemsHandle } + let fm = FileManager.default + try fm.createDirectory( + at: removedItemsFileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if !fm.fileExists(atPath: removedItemsFileURL.path) { + fm.createFile(atPath: removedItemsFileURL.path, contents: nil) + } + let fh = try FileHandle(forWritingTo: removedItemsFileURL) + removedItemsHandle = fh + return fh + } + + private func loadRemovedItemIDs() throws -> Set { + if let cachedRemovedItemIDs { return cachedRemovedItemIDs } + guard FileManager.default.fileExists(atPath: removedItemsFileURL.path) else { + cachedRemovedItemIDs = [] + return [] + } + let data = try Data(contentsOf: removedItemsFileURL) + let removedItemIDs = Set( + String(decoding: data, as: UTF8.self) + .split(whereSeparator: \.isNewline) + .compactMap { UUID(uuidString: String($0)) } + ) + cachedRemovedItemIDs = removedItemIDs + return removedItemIDs + } + private static func lineRanges( in data: Data, baseOffset: UInt64 diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift index 358786ea65d9..e5f496247c16 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift @@ -150,6 +150,19 @@ public final class WorkstreamStore { applyResolution(for: action) } + /// Removes one Feed item immediately and records a durable tombstone so + /// it does not return when persisted history is loaded again. + @discardableResult + public func removeItem(id: UUID) async throws -> Bool { + guard items.contains(where: { $0.id == id }) else { return false } + if let persistence { + try await persistence.remove(id) + } + items.removeAll { $0.id == id } + rebuildContextIndex() + return true + } + /// Marks the local item resolved without sending. Used when the reply /// channel is being driven by another layer (e.g. an inbound socket /// resolution event). diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 149cb2769108..a92dbd9ad5ed 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -103019,6 +103019,40 @@ } } }, + "feed.contextMenu.openTerminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを開く" + } + } + } + }, + "feed.contextMenu.remove": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove from Feed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードから削除" + } + } + } + }, "feed.divider.resolved": { "extractionState": "manual", "localizations": { @@ -104425,6 +104459,23 @@ } } }, + "feed.question.answered": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Answered:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "回答:" + } + } + } + }, "feed.question.answerAll": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7ff1383bb87e..cd0e2fa3dbd3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9332,26 +9332,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent else { return false } - // Invoke the existing V2 commands so the Feed-layer focus request - // goes through the same code path as a socket-initiated focus. - // Serialize through JSON so we reuse the v2 command parser. - guard invokeFeedSocketCommand( - "workspace.select", - params: ["workspace_id": workspaceId] - ), invokeFeedSocketCommand( - "surface.focus", - params: ["surface_id": surfaceId] - ) else { - return false - } - // Flash the terminal's own focus ring (same visual as - // cmd+shift+H / Flash Focused Panel) so the user's eye is - // pulled to the terminal content the Feed jumped to. - _ = invokeFeedSocketCommand( - "surface.trigger_flash", - params: ["surface_id": surfaceId] + guard let workspace = located.tabManager.tabs.first(where: { + $0.id == claimedWorkspaceID + }) else { return false } + + _ = focusMainWindow(windowId: located.windowId) + TerminalController.shared.setActiveTabManager(located.tabManager) + located.tabManager.focusTab( + claimedWorkspaceID, + surfaceId: located.panelId, + suppressFlash: true ) - return true + workspace.triggerFocusFlash(panelId: located.panelId) + return located.tabManager.selectedTabId == claimedWorkspaceID + && workspace.focusedPanelId == located.panelId } @discardableResult diff --git a/Sources/Feed/FeedCoordinator+Socket.swift b/Sources/Feed/FeedCoordinator+Socket.swift index 10bcfbb2bf87..bdb2964570f6 100644 --- a/Sources/Feed/FeedCoordinator+Socket.swift +++ b/Sources/Feed/FeedCoordinator+Socket.swift @@ -11,6 +11,14 @@ extension FeedCoordinator { return pendingOnly ? store.pending : store.items } + /// Removes a non-blocking Feed item from the shared store and its + /// persisted projection. Pending decisions stay protected by the UI. + @MainActor + func removeItem(id: UUID) async -> Bool { + guard let store else { return false } + return (try? await store.removeItem(id: id)) == true + } + /// Synchronous socket-only availability probe. Socket handlers execute on /// their worker queue, so this path never performs file I/O on MainActor. /// diff --git a/Sources/Feed/FeedItemRow.swift b/Sources/Feed/FeedItemRow.swift index fae96066023a..2731c6724118 100644 --- a/Sources/Feed/FeedItemRow.swift +++ b/Sources/Feed/FeedItemRow.swift @@ -51,6 +51,11 @@ struct FeedItemRow: View, Equatable { } .buttonStyle(.plain) actionArea + .contentShape(Rectangle()) + .onTapGesture { + guard !snapshot.status.isPending else { return } + onActivate() + } } .padding(.horizontal, 12) .padding(.vertical, 10) diff --git a/Sources/Feed/FeedLabeledTextRow.swift b/Sources/Feed/FeedLabeledTextRow.swift index 50f1921a2b5e..9b70c7bbf918 100644 --- a/Sources/Feed/FeedLabeledTextRow.swift +++ b/Sources/Feed/FeedLabeledTextRow.swift @@ -12,7 +12,9 @@ struct FeedLabeledTextRow: View { Text(label) .cmuxFont(size: 10, weight: .semibold) .foregroundColor(labelColor) - .frame(width: 48, alignment: .leading) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .frame(minWidth: 48, alignment: .leading) if rendersMarkdown { FeedMarkdownInlineText( text: text, diff --git a/Sources/Feed/FeedPreviewWindowController.swift b/Sources/Feed/FeedPreviewWindowController.swift index ba21ec01b64e..6f2c19210de5 100644 --- a/Sources/Feed/FeedPreviewWindowController.swift +++ b/Sources/Feed/FeedPreviewWindowController.swift @@ -152,6 +152,7 @@ private enum FeedPreviewActions { print("preview.exitPlan \(id) \(mode) feedback=\(feedback ?? "nil")") }, jump: { ws in print("preview.jump \(ws)") }, + remove: { id in print("preview.remove \(id)") }, sendText: { ws, text, completion in print("preview.sendText \(ws) \(text)") completion(true) diff --git a/Sources/Feed/FeedQuestionView.swift b/Sources/Feed/FeedQuestionView.swift index 38c1fb65b347..faf9d56fde46 100644 --- a/Sources/Feed/FeedQuestionView.swift +++ b/Sources/Feed/FeedQuestionView.swift @@ -41,6 +41,17 @@ struct QuestionActionArea: View { questionBlock(index: idx + 1, question: q) } } + if !resolvedSelections.isEmpty { + FeedLabeledTextRow( + label: String( + localized: "feed.question.answered", + defaultValue: "Answered:" + ), + text: resolvedSelections.formatted(), + labelColor: .blue, + textColor: .primary.opacity(0.95) + ) + } if shouldShowSkipInterviewCTA { HStack(spacing: 8) { skipInterviewCTA @@ -57,6 +68,15 @@ struct QuestionActionArea: View { } } + static func resolvedSelections(in status: WorkstreamStatus) -> [String] { + guard case .resolved(.question(let selections), _) = status else { return [] } + return selections + } + + private var resolvedSelections: [String] { + Self.resolvedSelections(in: status) + } + private var shouldRenderLongForm: Bool { // Long-form: single question whose options carry descriptions // (e.g. Claude's AskUserQuestion with `header` + per-option @@ -109,7 +129,7 @@ struct QuestionActionArea: View { index: Int, option: WorkstreamQuestionOption ) -> some View { - let selected = selections[questionId]?.contains(option.id) == true + let selected = isOptionSelected(questionId: questionId, option: option) return Button { guard status.isPending else { return } onActionRow() @@ -368,7 +388,7 @@ struct QuestionActionArea: View { option: WorkstreamQuestionOption, multi: Bool ) -> some View { - let selected = selections[questionId]?.contains(option.id) == true + let selected = isOptionSelected(questionId: questionId, option: option) let leading: String? = multi ? (selected ? "checkmark.square.fill" : "square") : nil @@ -394,6 +414,16 @@ struct QuestionActionArea: View { } } + private func isOptionSelected( + questionId: String, + option: WorkstreamQuestionOption + ) -> Bool { + if status.isPending { + return selections[questionId]?.contains(option.id) == true + } + return resolvedSelections.contains(option.label) + } + /// One answer string per question: the user's free-form text if /// they typed any, otherwise the labels of the selected options /// joined by ", ". Questions with no answer are omitted entirely diff --git a/Sources/Feed/FeedRowActions.swift b/Sources/Feed/FeedRowActions.swift index e649b44bfce0..a887924c38bd 100644 --- a/Sources/Feed/FeedRowActions.swift +++ b/Sources/Feed/FeedRowActions.swift @@ -7,6 +7,7 @@ struct FeedRowActions { let replyQuestion: @MainActor (String, [String]) -> Void let approveExitPlan: @MainActor (String, WorkstreamExitPlanMode, String?) -> Void let jump: @MainActor (String) -> Void + let remove: @MainActor (UUID) -> Void /// Types the user's reply into the agent's terminal surface and /// presses Return. Used by Stop-kind cards so the user can nudge /// Claude without switching focus to the terminal. @@ -63,6 +64,11 @@ struct FeedRowActions { _ = await FeedCoordinator.shared.focusIfPossible(workstreamId: workstreamId) } }, + remove: { itemID in + taskStore.run { + _ = await FeedCoordinator.shared.removeItem(id: itemID) + } + }, sendText: { workstreamId, text, completion in taskStore.run { let sent = await FeedCoordinator.shared.sendTextToWorkstream( diff --git a/Sources/Feed/FeedRowSurface.swift b/Sources/Feed/FeedRowSurface.swift index 62f168ba2fb0..559dd9fa21d9 100644 --- a/Sources/Feed/FeedRowSurface.swift +++ b/Sources/Feed/FeedRowSurface.swift @@ -57,6 +57,26 @@ struct FeedRowSurface: View { isHovered = hovering } } + .contextMenu { + Button { + onActivate() + } label: { + Label( + String(localized: "feed.contextMenu.openTerminal", defaultValue: "Open Terminal"), + systemImage: "terminal" + ) + } + Divider() + Button(role: .destructive) { + actions.remove(snapshot.id) + } label: { + Label( + String(localized: "feed.contextMenu.remove", defaultValue: "Remove from Feed"), + systemImage: "trash" + ) + } + .disabled(snapshot.status.isPending) + } } private var rowBackgroundFill: Color { @@ -87,4 +107,3 @@ struct FeedRowSurface: View { } } } - From bf414a8ef999c1e3b3fdd0f76f02f522660748e1 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:01:48 -0700 Subject: [PATCH 35/49] Cover restored Feed jump targets --- cmuxTests/FeedFocusRoutingTests.swift | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cmuxTests/FeedFocusRoutingTests.swift b/cmuxTests/FeedFocusRoutingTests.swift index 9b5d79a1e695..f2b495502db7 100644 --- a/cmuxTests/FeedFocusRoutingTests.swift +++ b/cmuxTests/FeedFocusRoutingTests.swift @@ -160,6 +160,29 @@ import Testing #expect(GhosttySurfaceScrollView.flashCount(for: targetPanel.id) == 1) } + @Test func feedFocusUsesLiveWorkspaceWhenPersistedWorkspaceNoLongerExists() throws { + let appDelegate = AppDelegate() + let manager = TabManager() + let targetWorkspace = try #require(manager.selectedWorkspace) + let targetPanel = try #require(targetWorkspace.focusedTerminalPanel) + let targetSurfaceID = try #require( + targetWorkspace.surfaceIdFromPanelId(targetPanel.id)?.uuid + ) + let feedWorkspace = manager.addWorkspace() + let windowID = appDelegate.registerMainWindowContextForTesting(tabManager: manager) + defer { appDelegate.unregisterMainWindowContextForTesting(windowId: windowID) } + GhosttySurfaceScrollView.resetFlashCounts() + + #expect(manager.selectedTabId == feedWorkspace.id) + #expect(appDelegate.routeFeedFocus( + workspaceId: UUID().uuidString, + surfaceId: targetSurfaceID.uuidString + )) + #expect(manager.selectedTabId == targetWorkspace.id) + #expect(targetWorkspace.focusedPanelId == targetPanel.id) + #expect(GhosttySurfaceScrollView.flashCount(for: targetPanel.id) == 1) + } + @Test func resolvedQuestionSelectionsRemainAvailableForPresentation() { let status = WorkstreamStatus.resolved( .question(selections: ["Answering a question"]), From e2b97cc7c9c321190e9220793f9cde867da4b24b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:02:06 -0700 Subject: [PATCH 36/49] Resolve restored Feed jump destinations --- Sources/AppDelegate.swift | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index cd0e2fa3dbd3..c8c5c114515c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9327,24 +9327,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func routeFeedFocus(workspaceId: String, surfaceId: String) -> Bool { guard let claimedWorkspaceID = UUID(uuidString: workspaceId), let claimedSurfaceID = UUID(uuidString: surfaceId), - let located = locateBonsplitSurface(tabId: claimedSurfaceID), - located.workspaceId == claimedWorkspaceID + let located = locateBonsplitSurface(tabId: claimedSurfaceID) else { return false } + // Workspace IDs can change when a persisted terminal surface is + // restored. The stable surface ID is authoritative only when the + // claimed workspace no longer exists; if both workspaces are live, + // retain the mismatch rejection so stale routing cannot redirect a + // Feed card into another active workspace. + if located.workspaceId != claimedWorkspaceID, + tabManagerFor(tabId: claimedWorkspaceID) != nil { + return false + } + let targetWorkspaceID = located.workspaceId guard let workspace = located.tabManager.tabs.first(where: { - $0.id == claimedWorkspaceID + $0.id == targetWorkspaceID }) else { return false } _ = focusMainWindow(windowId: located.windowId) TerminalController.shared.setActiveTabManager(located.tabManager) located.tabManager.focusTab( - claimedWorkspaceID, + targetWorkspaceID, surfaceId: located.panelId, suppressFlash: true ) workspace.triggerFocusFlash(panelId: located.panelId) - return located.tabManager.selectedTabId == claimedWorkspaceID + return located.tabManager.selectedTabId == targetWorkspaceID && workspace.focusedPanelId == located.panelId } From a9864fa2254c57a6cadf6c147475ee03295cd5be Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:18:45 -0700 Subject: [PATCH 37/49] Add Feed retention regression coverage --- .../WorkstreamPersistenceTests.swift | 26 +++++++ .../Workstream/WorkstreamStoreTests.swift | 68 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamPersistenceTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamPersistenceTests.swift index 7f013b34cd39..7bc600da4ede 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamPersistenceTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamPersistenceTests.swift @@ -152,4 +152,30 @@ struct WorkstreamPersistenceTests { try await persistence.clear() #expect(!FileManager.default.fileExists(atPath: tmp.path)) } + + @Test("Persisted Feed state stays below the disk byte ceiling") + func persistedStateHasByteCeiling() async throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-workstream-bounded-\(UUID().uuidString).jsonl") + defer { try? FileManager.default.removeItem(at: tmp) } + let persistence = WorkstreamPersistence(fileURL: tmp) + let largeInput = String(repeating: "x", count: 64 * 1024) + for i in 0..<64 { + try await persistence.append(WorkstreamItem( + workstreamId: "s\(i)", + source: .claude, + kind: .permissionRequest, + payload: .permissionRequest( + requestId: "r\(i)", + toolName: "Bash", + toolInputJSON: largeInput, + pattern: nil + ) + )) + } + + let attributes = try FileManager.default.attributesOfItem(atPath: tmp.path) + let fileSize = try #require(attributes[.size] as? NSNumber).intValue + #expect(fileSize <= 2 * 1024 * 1024) + } } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift index 6f8612010c49..fdda1f959b3b 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift @@ -156,6 +156,74 @@ struct WorkstreamStoreTests { #expect(store.items[0].kind == .toolUse) } + @Test("Telemetry enriches actionable cards without entering retained Feed state") + func telemetryOnlyEnrichesActionableItems() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "s1", + hookEventName: .userPromptSubmit, + source: "claude", + toolInputJSON: #"{"prompt":"keep this context only"}"# + )) + store.ingest(WorkstreamEvent( + sessionId: "s1", + hookEventName: .preToolUse, + source: "claude", + toolName: "Read", + toolInputJSON: #"{"path":"/tmp/file"}"# + )) + store.ingest(.permission("s1", requestId: "r1")) + + #expect(store.items.count == 1) + #expect(store.items[0].kind == .permissionRequest) + #expect(store.items[0].context?.lastUserMessage == "keep this context only") + } + + @Test("Default Feed retention keeps memory bounded") + func defaultRetentionIsBounded() { + let store = WorkstreamStore() + for i in 0..<500 { + store.ingest(.permission("s\(i)", requestId: "r\(i)")) + } + + #expect(WorkstreamDefaultRingCapacity <= 200) + #expect(store.items.count == WorkstreamDefaultRingCapacity) + #expect(store.items.last?.workstreamId == "s499") + } + + @Test("Restart restores pending decisions only and compacts legacy activity") + func restartRestoresOnlyPendingDecisions() async throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-workstream-store-compact-\(UUID().uuidString).jsonl") + defer { try? FileManager.default.removeItem(at: tmp) } + let persistence = WorkstreamPersistence(fileURL: tmp) + try await persistence.append(WorkstreamItem( + workstreamId: "telemetry", + source: .claude, + kind: .toolUse, + payload: .toolUse(toolName: "Read", toolInputJSON: "{}") + )) + try await persistence.append(WorkstreamItem( + workstreamId: "resolved", + source: .claude, + kind: .question, + status: .resolved(.question(selections: ["Done"]), at: Date()), + payload: .question(requestId: "resolved", questions: []) + )) + try await persistence.append(WorkstreamItem( + workstreamId: "pending", + source: .claude, + kind: .question, + payload: .question(requestId: "pending", questions: []) + )) + + let store = WorkstreamStore(persistence: persistence, ringCapacity: 10) + await store.start() + + #expect(store.items.map(\.workstreamId) == ["pending"]) + #expect(try await persistence.loadRecent(limit: 10).map(\.workstreamId) == ["pending"]) + } + @Test("Codex CLI lifecycle feed events stay telemetry") func codexLifecycleFeedEventsStayTelemetry() { let store = WorkstreamStore( From 510b4ba70cc0809333ca63960b5d538fb4d347a3 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:25:50 -0700 Subject: [PATCH 38/49] Bound Feed retention to pending decisions --- .../Workstream/WorkstreamItem.swift | 128 ++++++++ .../Workstream/WorkstreamKind.swift | 7 +- .../Workstream/WorkstreamPersistence.swift | 301 ++++++------------ .../Workstream/WorkstreamStore.swift | 136 ++++---- .../WorkstreamPersistenceTests.swift | 235 +++++++------- .../Workstream/WorkstreamStoreTests.swift | 126 ++------ Resources/Localizable.xcstrings | 153 --------- Sources/Feed/FeedActivitySnapshotGroups.swift | 12 - Sources/Feed/FeedCoordinator.swift | 7 +- Sources/Feed/FeedListView.swift | 111 +------ Sources/Feed/FeedPanelView.swift | 115 +------ Sources/Feed/FeedPanelViewModel.swift | 51 --- Sources/Feed/FeedPresentationSnapshot.swift | 6 +- Sources/Feed/FeedPresentationStore.swift | 65 +--- cmux.xcodeproj/project.pbxproj | 4 - .../RightSidebarChromeHeightUITests.swift | 16 +- 16 files changed, 481 insertions(+), 992 deletions(-) delete mode 100644 Sources/Feed/FeedActivitySnapshotGroups.swift diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamItem.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamItem.swift index 09706d73c60e..531c226c7285 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamItem.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamItem.swift @@ -79,3 +79,131 @@ public struct WorkstreamItem: Identifiable, Codable, Sendable, Equatable { self.ppid = ppid } } + +extension WorkstreamItem { + /// Returns a copy whose variable-sized fields have deterministic limits. + /// This bounds both the in-memory ring and the pending-item disk snapshot. + func retainedForFeed() -> WorkstreamItem { + WorkstreamItem( + id: id, + workstreamId: workstreamId.feedPrefix(512), + source: source, + kind: kind, + createdAt: createdAt, + updatedAt: updatedAt, + cwd: cwd?.feedPrefix(4_096), + title: title?.feedPrefix(1_024), + status: status.retainedForFeed(), + payload: payload.retainedForFeed(), + context: context?.retainedForFeed(), + ppid: ppid + ) + } +} + +private extension WorkstreamStatus { + func retainedForFeed() -> WorkstreamStatus { + switch self { + case .resolved(.question(let selections), let date): + return .resolved( + .question(selections: selections.prefix(20).map { $0.feedPrefix(1_024) }), + at: date + ) + case .resolved(.exitPlan(let mode, let feedback), let date): + return .resolved(.exitPlan(mode, feedback: feedback?.feedPrefix(8_192)), at: date) + default: + return self + } + } +} + +private extension WorkstreamPayload { + func retainedForFeed() -> WorkstreamPayload { + switch self { + case .permissionRequest(let requestId, let toolName, let toolInputJSON, let pattern): + return .permissionRequest( + requestId: requestId.feedPrefix(512), + toolName: toolName.feedPrefix(512), + toolInputJSON: toolInputJSON.feedPrefix(32_768), + pattern: pattern?.feedPrefix(4_096) + ) + case .exitPlan(let requestId, let plan, let defaultMode): + return .exitPlan( + requestId: requestId.feedPrefix(512), + plan: plan.feedPrefix(65_536), + defaultMode: defaultMode + ) + case .question(let requestId, let questions): + return .question( + requestId: requestId.feedPrefix(512), + questions: questions.prefix(4).map { question in + WorkstreamQuestionPrompt( + id: question.id.feedPrefix(512), + header: question.header?.feedPrefix(1_024), + prompt: question.prompt.feedPrefix(8_192), + multiSelect: question.multiSelect, + options: question.options.prefix(12).map { option in + WorkstreamQuestionOption( + id: option.id.feedPrefix(512), + label: option.label.feedPrefix(1_024), + description: option.description?.feedPrefix(2_048) + ) + } + ) + } + ) + case .toolUse(let toolName, let toolInputJSON): + return .toolUse( + toolName: toolName.feedPrefix(512), + toolInputJSON: toolInputJSON.feedPrefix(32_768) + ) + case .toolResult(let toolName, let resultJSON, let isError): + return .toolResult( + toolName: toolName.feedPrefix(512), + resultJSON: resultJSON.feedPrefix(32_768), + isError: isError + ) + case .userPrompt(let text): + return .userPrompt(text: text.feedPrefix(16_384)) + case .assistantMessage(let text): + return .assistantMessage(text: text.feedPrefix(16_384)) + case .stop(let reason): + return .stop(reason: reason?.feedPrefix(4_096)) + case .todos(let todos): + return .todos(todos.prefix(100).map { todo in + WorkstreamTaskTodo( + id: todo.id.feedPrefix(512), + content: todo.content.feedPrefix(2_048), + state: todo.state + ) + }) + case .sessionStart, .sessionEnd: + return self + } + } +} + +extension WorkstreamContext { + func retainedForFeed() -> WorkstreamContext { + WorkstreamContext( + lastUserMessage: lastUserMessage?.feedPrefix(16_384), + assistantPreamble: assistantPreamble?.feedPrefix(16_384), + planSummary: planSummary?.feedPrefix(8_192), + allowedPrompts: allowedPrompts.prefix(20).map { prompt in + WorkstreamAllowedPrompt( + tool: prompt.tool.feedPrefix(512), + prompt: prompt.prompt.feedPrefix(2_048) + ) + }, + toolSummary: toolSummary?.feedPrefix(8_192), + permissionMode: permissionMode?.feedPrefix(256) + ) + } +} + +private extension String { + func feedPrefix(_ maximumCharacters: Int) -> String { + guard count > maximumCharacters else { return self } + return String(prefix(maximumCharacters)) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamKind.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamKind.swift index 9559afcf7330..12a0467c35ed 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamKind.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamKind.swift @@ -1,15 +1,14 @@ import Foundation -/// Classifies a `WorkstreamItem`. Actionable kinds are surfaced in the -/// default Feed view; telemetry kinds are stored but hidden behind the -/// "All" filter toggle. +/// Classifies a `WorkstreamItem`. Actionable kinds enter the Feed. Telemetry +/// kinds can enrich nearby actionable context but are not retained. public enum WorkstreamKind: String, Codable, Sendable, CaseIterable, Equatable { // Actionable — shown by default. case permissionRequest case exitPlan case question - // Telemetry — stored, hidden by default. + // Telemetry — transient context only. case toolUse case toolResult case userPrompt diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift index de234264b61d..eee3f847e02a 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamPersistence.swift @@ -1,41 +1,36 @@ import Foundation -/// Append-only JSONL persistence for `WorkstreamItem`. One item per line, -/// unbounded on disk. The in-memory ring buffer on `WorkstreamStore` is -/// the only cap on working set size; this layer exists for restart -/// recovery and long-term audit. +public let WorkstreamDefaultPersistedItemLimit = 100 +public let WorkstreamDefaultPersistedByteLimit = 2 * 1024 * 1024 +public let WorkstreamDefaultLegacyReadByteLimit = 8 * 1024 * 1024 + +/// Bounded restart recovery for pending Feed decisions. /// -/// Writes are serialized through an actor so the store can fire them off -/// without awaiting disk IO; reads happen on the caller's executor since -/// load runs once per process at launch. +/// The file remains JSONL for backward compatibility, but it is now an atomic +/// snapshot instead of an append-only activity log. Every write keeps pending +/// actionable items only, caps both row count and encoded bytes, and removes +/// the legacy tombstone sidecar. public actor WorkstreamPersistence { - public struct Page: Sendable, Equatable { - public let items: [WorkstreamItem] - public let hasMoreBefore: Bool - public let startOffset: UInt64? - - public init( - items: [WorkstreamItem], - hasMoreBefore: Bool, - startOffset: UInt64? - ) { - self.items = items - self.hasMoreBefore = hasMoreBefore - self.startOffset = startOffset - } - } - private let fileURL: URL private let removedItemsFileURL: URL + private let maximumItemCount: Int + private let maximumFileBytes: Int + private let maximumLegacyReadBytes: Int private let encoder: JSONEncoder private let decoder: JSONDecoder - private var handle: FileHandle? - private var removedItemsHandle: FileHandle? - private var cachedRemovedItemIDs: Set? + private var latestGeneration: UInt64 = 0 - public init(fileURL: URL) { + public init( + fileURL: URL, + maximumItemCount: Int = WorkstreamDefaultPersistedItemLimit, + maximumFileBytes: Int = WorkstreamDefaultPersistedByteLimit, + maximumLegacyReadBytes: Int = WorkstreamDefaultLegacyReadByteLimit + ) { self.fileURL = fileURL self.removedItemsFileURL = Self.removedItemsFileURL(for: fileURL) + self.maximumItemCount = max(0, maximumItemCount) + self.maximumFileBytes = max(0, maximumFileBytes) + self.maximumLegacyReadBytes = max(0, maximumLegacyReadBytes) let enc = JSONEncoder() enc.dateEncodingStrategy = .iso8601 self.encoder = enc @@ -44,214 +39,122 @@ public actor WorkstreamPersistence { self.decoder = dec } - /// Default JSONL path in the user's cmuxterm state directory. + /// Default snapshot path in the user's cmuxterm state directory. public static func defaultFileURL() -> URL { - let home = FileManager.default.homeDirectoryForCurrentUser - return home + FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".cmuxterm", isDirectory: true) .appendingPathComponent("workstream.jsonl", isDirectory: false) } + /// Legacy removal log path, retained only so migration can delete it. public static func removedItemsFileURL(for fileURL: URL) -> URL { fileURL.deletingPathExtension().appendingPathExtension("removed.jsonl") } - /// Appends a single item as a JSON line. Creates the file and parent - /// directory lazily on first write. - public func append(_ item: WorkstreamItem) throws { - let data = try encoder.encode(item.redactedForPersistence()) - var line = data - line.append(0x0A) // "\n" - let fh = try handleForWriting() - try fh.seekToEnd() - try fh.write(contentsOf: line) - } + /// Loads pending actionable rows from the bounded tail of either the new + /// snapshot or a legacy append-only file. Order is oldest-first. + public func loadPendingItems(limit: Int) throws -> [WorkstreamItem] { + guard limit > 0, + maximumLegacyReadBytes > 0, + FileManager.default.fileExists(atPath: fileURL.path) else { return [] } - /// Persists an item tombstone without rewriting the unbounded event log. - /// Readers apply tombstones while paging, so user removals survive restart - /// and existing byte cursors remain stable while new events are appended. - public func remove(_ itemID: UUID) throws { - var removedItemIDs = try loadRemovedItemIDs() - guard removedItemIDs.insert(itemID).inserted else { return } + let handle = try FileHandle(forReadingFrom: fileURL) + defer { try? handle.close() } + let fileSize = try handle.seekToEnd() + let readSize = min(Int(fileSize), maximumLegacyReadBytes) + guard readSize > 0 else { return [] } + try handle.seek(toOffset: fileSize - UInt64(readSize)) + guard let tail = try handle.read(upToCount: readSize), !tail.isEmpty else { return [] } - var line = Data(itemID.uuidString.utf8) - line.append(0x0A) - let fh = try handleForRemovedItemsWriting() - try fh.seekToEnd() - try fh.write(contentsOf: line) - cachedRemovedItemIDs = removedItemIDs + let removedItemIDs = try loadLegacyRemovedItemIDs() + let startsMidLine = UInt64(readSize) < fileSize + var lines = tail.split(separator: 0x0A, omittingEmptySubsequences: true) + if startsMidLine, !lines.isEmpty { + lines.removeFirst() + } + return lines.compactMap { line -> WorkstreamItem? in + guard let item = try? decoder.decode(WorkstreamItem.self, from: Data(line)), + item.kind.isActionable, + item.status.isPending, + !removedItemIDs.contains(item.id) else { return nil } + return item.retainedForFeed() + } + .suffix(min(limit, maximumItemCount)) + .map { $0 } } - /// Loads the last `limit` items from the file. Order in the returned - /// array is oldest-first. Missing file returns empty. - public func loadRecent(limit: Int) throws -> [WorkstreamItem] { - try loadPage(endingBefore: nil, limit: limit).items - } + /// Atomically replaces persisted state. Stale asynchronous writes are + /// ignored by generation so an older snapshot cannot resurrect a decision. + public func replacePendingItems( + _ items: [WorkstreamItem], + generation: UInt64 + ) throws { + guard generation >= latestGeneration else { return } + latestGeneration = generation - /// Loads up to `limit` items ending before `endOffset`. Order in the - /// returned array is oldest-first. `startOffset` can be passed back - /// as `endOffset` to page older history without depending on line - /// counts, which keeps the cursor stable while new rows are appended. - public func loadPage( - endingBefore endOffset: UInt64? = nil, - limit: Int - ) throws -> Page { - guard limit > 0 else { - return Page(items: [], hasMoreBefore: false, startOffset: nil) - } - guard FileManager.default.fileExists(atPath: fileURL.path) else { - return Page(items: [], hasMoreBefore: false, startOffset: nil) - } - let removedItemIDs = try loadRemovedItemIDs() - let fh = try FileHandle(forReadingFrom: fileURL) - defer { try? fh.close() } - let fileSize = try fh.seekToEnd() - let pageEnd = min(endOffset ?? fileSize, fileSize) - guard fileSize > 0, pageEnd > 0 else { - return Page(items: [], hasMoreBefore: false, startOffset: nil) - } + let candidates = items + .filter { $0.kind.isActionable && $0.status.isPending } + .suffix(maximumItemCount) + .map { $0.retainedForFeed().redactedForPersistence() } - let chunkSize = 64 * 1024 - var offset = pageEnd - var tail = Data() - var lineRanges: [(range: Range, startOffset: UInt64)] = [] - while offset > 0 { - let readSize = min(chunkSize, Int(offset)) - offset -= UInt64(readSize) - try fh.seek(toOffset: offset) - guard let chunk = try fh.read(upToCount: readSize), !chunk.isEmpty else { - break - } - tail.insert(contentsOf: chunk, at: 0) - lineRanges = Self.lineRanges(in: tail, baseOffset: offset) - if lineRanges.count > limit + removedItemIDs.count { - break - } + var selectedLines: [Data] = [] + var selectedByteCount = 0 + for item in candidates.reversed() { + var line = try encoder.encode(item) + line.append(0x0A) + guard line.count <= maximumFileBytes else { continue } + guard selectedByteCount + line.count <= maximumFileBytes else { break } + selectedLines.append(line) + selectedByteCount += line.count } - if lineRanges.isEmpty { - lineRanges = Self.lineRanges(in: tail, baseOffset: offset) - } - var decoded: [(item: WorkstreamItem, startOffset: UInt64)] = [] - decoded.reserveCapacity(lineRanges.count) - for lineRange in lineRanges { - let slice = tail.subdata(in: lineRange.range) - if let item = try? decoder.decode(WorkstreamItem.self, from: slice), - !removedItemIDs.contains(item.id) { - decoded.append((item, lineRange.startOffset)) - } - // Malformed lines are dropped silently; the audit log is - // append-only and we don't want a corrupt row to block startup. + let fileManager = FileManager.default + try? fileManager.removeItem(at: removedItemsFileURL) + guard !selectedLines.isEmpty else { + try? fileManager.removeItem(at: fileURL) + return } - let selected = decoded.suffix(limit) - let out = selected.map(\.item) - let startOffset = selected.first?.startOffset - return Page( - items: out, - hasMoreBefore: (startOffset ?? 0) > 0, - startOffset: startOffset + try fileManager.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true ) + var snapshot = Data(capacity: selectedByteCount) + for line in selectedLines.reversed() { + snapshot.append(line) + } + try snapshot.write(to: fileURL, options: .atomic) } - /// Truncates the JSONL file. Used by `cmux feed clear`. + /// Compatibility read used by diagnostics and focused tests. + public func loadRecent(limit: Int) throws -> [WorkstreamItem] { + try loadPendingItems(limit: limit) + } + + /// Removes the snapshot and any legacy tombstones. public func clear() throws { - if let fh = handle { - try fh.close() - } - if let fh = removedItemsHandle { - try fh.close() - } - handle = nil - removedItemsHandle = nil - cachedRemovedItemIDs = [] + latestGeneration &+= 1 for url in [fileURL, removedItemsFileURL] { do { try FileManager.default.removeItem(at: url) } catch { let nsError = error as NSError - if nsError.domain == NSCocoaErrorDomain, - nsError.code == NSFileNoSuchFileError { - continue - } - throw error + guard nsError.domain == NSCocoaErrorDomain, + nsError.code == NSFileNoSuchFileError else { throw error } } } } - private func handleForWriting() throws -> FileHandle { - if let handle { return handle } - let fm = FileManager.default - try fm.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - if !fm.fileExists(atPath: fileURL.path) { - fm.createFile(atPath: fileURL.path, contents: nil) - } - let fh = try FileHandle(forWritingTo: fileURL) - handle = fh - return fh - } - - private func handleForRemovedItemsWriting() throws -> FileHandle { - if let removedItemsHandle { return removedItemsHandle } - let fm = FileManager.default - try fm.createDirectory( - at: removedItemsFileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - if !fm.fileExists(atPath: removedItemsFileURL.path) { - fm.createFile(atPath: removedItemsFileURL.path, contents: nil) - } - let fh = try FileHandle(forWritingTo: removedItemsFileURL) - removedItemsHandle = fh - return fh - } - - private func loadRemovedItemIDs() throws -> Set { - if let cachedRemovedItemIDs { return cachedRemovedItemIDs } - guard FileManager.default.fileExists(atPath: removedItemsFileURL.path) else { - cachedRemovedItemIDs = [] - return [] - } + private func loadLegacyRemovedItemIDs() throws -> Set { + guard FileManager.default.fileExists(atPath: removedItemsFileURL.path) else { return [] } + let attributes = try FileManager.default.attributesOfItem(atPath: removedItemsFileURL.path) + let fileSize = (attributes[.size] as? NSNumber)?.intValue ?? 0 + guard fileSize <= maximumLegacyReadBytes else { return [] } let data = try Data(contentsOf: removedItemsFileURL) - let removedItemIDs = Set( + return Set( String(decoding: data, as: UTF8.self) .split(whereSeparator: \.isNewline) .compactMap { UUID(uuidString: String($0)) } ) - cachedRemovedItemIDs = removedItemIDs - return removedItemIDs - } - - private static func lineRanges( - in data: Data, - baseOffset: UInt64 - ) -> [(range: Range, startOffset: UInt64)] { - var ranges: [(range: Range, startOffset: UInt64)] = [] - ranges.reserveCapacity(128) - var lineStart = 0 - for (idx, byte) in data.enumerated() { - guard byte == 0x0A else { continue } - if lineStart < idx { - ranges.append( - ( - range: lineStart.. Date private let titleProvider: (WorkstreamEvent) -> String? - private var oldestLoadedPersistenceOffset: UInt64? + private var persistenceGeneration: UInt64 = 0 /// Last known conversational context for each workstream. Tool hooks /// usually arrive without the surrounding user prompt, so the store /// carries forward prompt/preamble context from nearby telemetry rows. private var lastContextByWorkstream: [String: WorkstreamContext] = [:] + private var contextWorkstreamOrder: [String] = [] /// Creates a store for Feed workstream items. /// /// - Parameters: /// - transport: Source and reply transport for live Feed events. - /// - persistence: Optional JSONL persistence for event history. + /// - persistence: Optional bounded persistence for pending decisions. /// - ringCapacity: Maximum in-memory item count. - /// - initialLoadLimit: Maximum persisted item count loaded at startup. - /// - historyPageSize: Page size for older persisted history. /// - clock: Clock used for timestamps and expiry checks. /// - titleProvider: App boundary hook for localized display titles. public init( transport: any WorkstreamTransport = NullWorkstreamTransport(), persistence: WorkstreamPersistence? = nil, ringCapacity: Int = WorkstreamDefaultRingCapacity, - initialLoadLimit: Int = WorkstreamDefaultInitialLoadLimit, - historyPageSize: Int = WorkstreamDefaultHistoryPageSize, clock: @escaping @Sendable () -> Date = { Date() }, titleProvider: @escaping (WorkstreamEvent) -> String? = { _ in nil } ) { self.transport = transport self.persistence = persistence - self.ringCapacity = ringCapacity - self.initialLoadLimit = initialLoadLimit - self.historyPageSize = historyPageSize + self.ringCapacity = max(0, ringCapacity) + self.contextCapacity = max(0, ringCapacity) self.clock = clock self.titleProvider = titleProvider } public func start() async { if let persistence { - if let page = try? await persistence.loadPage(limit: min(initialLoadLimit, ringCapacity)) { - items = page.items - hasMorePersistedItems = page.hasMoreBefore - oldestLoadedPersistenceOffset = page.startOffset + if let restored = try? await persistence.loadPendingItems(limit: ringCapacity) { + items = restored.suffix(ringCapacity).map { $0.retainedForFeed() } rebuildContextIndex() } + persistenceGeneration &+= 1 + try? await persistence.replacePendingItems(items, generation: persistenceGeneration) } do { try await transport.subscribe { [weak self] event in @@ -97,48 +88,22 @@ public final class WorkstreamStore { } } - public func loadOlderItems() async { - guard !isLoadingOlderItems, hasMorePersistedItems else { return } - guard let persistence, let oldestLoadedPersistenceOffset else { - hasMorePersistedItems = false - return - } - - isLoadingOlderItems = true - defer { isLoadingOlderItems = false } - - guard let page = try? await persistence.loadPage( - endingBefore: oldestLoadedPersistenceOffset, - limit: historyPageSize - ), !page.items.isEmpty else { - hasMorePersistedItems = false - return - } - - let existingIds = Set(items.map(\.id)) - let olderItems = page.items.filter { !existingIds.contains($0.id) } - if !olderItems.isEmpty { - items.insert(contentsOf: olderItems, at: 0) - } - self.oldestLoadedPersistenceOffset = page.startOffset ?? oldestLoadedPersistenceOffset - hasMorePersistedItems = page.hasMoreBefore - rebuildContextIndex() - } - // MARK: - Ingest /// Applies an inbound wire frame. Creates or updates a - /// `WorkstreamItem`, enforces the ring-buffer cap, and appends to - /// the JSONL log. - public func ingest(_ event: WorkstreamEvent) { - let item = makeItem(from: event) - insert(item) + /// `WorkstreamItem` for actionable events. Telemetry updates a bounded + /// context cache only and is never retained in memory or on disk. + @discardableResult + public func ingest(_ event: WorkstreamEvent) -> UUID? { + let item = makeItem(from: event).retainedForFeed() updateContextIndex(with: item) - if let persistence { - Task { [persistence, item] in - try? await persistence.append(item) - } + if event.hookEventName == .sessionEnd { + removeContext(for: event.sessionId) } + guard item.kind.isActionable else { return nil } + insert(item) + schedulePendingSnapshot() + return item.id } // MARK: - Actions @@ -150,16 +115,16 @@ public final class WorkstreamStore { applyResolution(for: action) } - /// Removes one Feed item immediately and records a durable tombstone so - /// it does not return when persisted history is loaded again. + /// Removes one Feed item immediately and replaces the pending snapshot. @discardableResult public func removeItem(id: UUID) async throws -> Bool { guard items.contains(where: { $0.id == id }) else { return false } - if let persistence { - try await persistence.remove(id) - } items.removeAll { $0.id == id } rebuildContextIndex() + if let persistence { + persistenceGeneration &+= 1 + try await persistence.replacePendingItems(items, generation: persistenceGeneration) + } return true } @@ -172,6 +137,8 @@ public final class WorkstreamStore { let now = clock() items[idx].status = .resolved(decision, at: now) items[idx].updatedAt = now + items[idx] = items[idx].retainedForFeed() + schedulePendingSnapshot() } /// Marks one still-pending item expired. @@ -181,31 +148,49 @@ public final class WorkstreamStore { let now = clock() items[idx].status = .expired(at: now) items[idx].updatedAt = now + schedulePendingSnapshot() } /// Marks every still-pending item created before `threshold` as /// expired. Call periodically to clean stale items. public func expirePending(olderThan threshold: TimeInterval) { let now = clock() + var didChange = false for idx in items.indices { guard items[idx].status.isPending else { continue } if now.timeIntervalSince(items[idx].createdAt) > threshold { items[idx].status = .expired(at: now) items[idx].updatedAt = now + didChange = true } } + if didChange { schedulePendingSnapshot() } } // MARK: - Private helpers private func insert(_ item: WorkstreamItem) { - items.append(item) + guard ringCapacity > 0 else { return } + items.append(item.retainedForFeed()) if items.count > ringCapacity { let overflow = items.count - ringCapacity items.removeFirst(overflow) } } + private func schedulePendingSnapshot() { + guard let persistence else { return } + persistenceGeneration &+= 1 + let generation = persistenceGeneration + let pendingItems = items.filter { $0.status.isPending } + Task { [persistence, pendingItems] in + try? await persistence.replacePendingItems( + pendingItems, + generation: generation + ) + } + } + private func applyResolution(for action: WorkstreamAction) { switch action { case .approvePermission(let itemId, let mode): @@ -244,12 +229,15 @@ public final class WorkstreamStore { /// so the exact moment an agent dies, its pending cards close. public func expireItems(forPpid ppid: Int) { let now = clock() + var didChange = false for idx in items.indices { guard items[idx].status.isPending, items[idx].ppid == ppid else { continue } items[idx].status = .expired(at: now) items[idx].updatedAt = now + didChange = true } + if didChange { schedulePendingSnapshot() } } /// Marks every pending item whose emitting agent process is no @@ -262,14 +250,17 @@ public final class WorkstreamStore { isProcessAlive: (Int) -> Bool = WorkstreamStore.defaultIsProcessAlive ) { let now = clock() + var didChange = false for idx in items.indices { guard items[idx].status.isPending else { continue } guard let ppid = items[idx].ppid, ppid > 0 else { continue } if !isProcessAlive(ppid) { items[idx].status = .expired(at: now) items[idx].updatedAt = now + didChange = true } } + if didChange { schedulePendingSnapshot() } } /// Default liveness probe: `kill(pid, 0)` returns 0 if the @@ -435,6 +426,7 @@ public final class WorkstreamStore { private func rebuildContextIndex() { lastContextByWorkstream.removeAll(keepingCapacity: true) + contextWorkstreamOrder.removeAll(keepingCapacity: true) for item in items.sorted(by: { $0.createdAt < $1.createdAt }) { updateContextIndex(with: item) } @@ -481,8 +473,20 @@ public final class WorkstreamStore { break } - guard let next, !next.isEmpty else { return } - lastContextByWorkstream[item.workstreamId] = next + guard let next, !next.isEmpty, contextCapacity > 0 else { return } + let workstreamID = item.workstreamId + contextWorkstreamOrder.removeAll { $0 == workstreamID } + contextWorkstreamOrder.append(workstreamID) + lastContextByWorkstream[workstreamID] = next.retainedForFeed() + while contextWorkstreamOrder.count > contextCapacity { + let evicted = contextWorkstreamOrder.removeFirst() + lastContextByWorkstream.removeValue(forKey: evicted) + } + } + + private func removeContext(for workstreamID: String) { + lastContextByWorkstream.removeValue(forKey: workstreamID) + contextWorkstreamOrder.removeAll { $0 == workstreamID } } private static func carriedContext(from context: WorkstreamContext) -> WorkstreamContext? { diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamPersistenceTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamPersistenceTests.swift index 7bc600da4ede..902305055f9e 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamPersistenceTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamPersistenceTests.swift @@ -4,94 +4,83 @@ import Testing @Suite("WorkstreamPersistence") struct WorkstreamPersistenceTests { - @Test("Append + loadRecent round-trips items oldest-first") - func appendAndLoad() async throws { - let tmp = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-workstream-test-\(UUID().uuidString).jsonl") - defer { try? FileManager.default.removeItem(at: tmp) } - let persistence = WorkstreamPersistence(fileURL: tmp) - let items = (0..<5).map { i in - WorkstreamItem( - workstreamId: "s\(i)", - source: .claude, - kind: .permissionRequest, - payload: .permissionRequest( - requestId: "r\(i)", - toolName: "Write", - toolInputJSON: "{}", - pattern: nil - ) - ) - } - for item in items { - try await persistence.append(item) - } + @Test("Pending snapshot round-trips items oldest-first") + func snapshotRoundTrip() async throws { + let fixture = Fixture() + defer { fixture.remove() } + let persistence = WorkstreamPersistence(fileURL: fixture.url) + let items = (0..<5).map(Self.pendingItem) + + try await persistence.replacePendingItems(items, generation: 1) + let loaded = try await persistence.loadRecent(limit: 10) - #expect(loaded.count == 5) - #expect(loaded.first?.workstreamId == "s0") - #expect(loaded.last?.workstreamId == "s4") + #expect(loaded.map(\.workstreamId) == ["s0", "s1", "s2", "s3", "s4"]) } - @Test("loadRecent with limit returns the most recent suffix") - func loadRecentLimit() async throws { - let tmp = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-workstream-test-\(UUID().uuidString).jsonl") - defer { try? FileManager.default.removeItem(at: tmp) } - let persistence = WorkstreamPersistence(fileURL: tmp) - for i in 0..<5 { - try await persistence.append(WorkstreamItem( - workstreamId: "s\(i)", - source: .claude, - kind: .permissionRequest, - payload: .permissionRequest(requestId: "r\(i)", toolName: "t", toolInputJSON: "{}", pattern: nil) - )) - } - let loaded = try await persistence.loadRecent(limit: 2) - #expect(loaded.count == 2) - #expect(loaded.first?.workstreamId == "s3") - #expect(loaded.last?.workstreamId == "s4") + @Test("Snapshot item limit keeps the most recent pending suffix") + func snapshotItemLimit() async throws { + let fixture = Fixture() + defer { fixture.remove() } + let persistence = WorkstreamPersistence( + fileURL: fixture.url, + maximumItemCount: 2 + ) + + try await persistence.replacePendingItems( + (0..<5).map(Self.pendingItem), + generation: 1 + ) + + let loaded = try await persistence.loadRecent(limit: 10) + #expect(loaded.map(\.workstreamId) == ["s3", "s4"]) } - @Test("loadPage pages older rows before a stable byte cursor") - func loadPageBeforeCursor() async throws { - let tmp = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-workstream-page-\(UUID().uuidString).jsonl") - defer { try? FileManager.default.removeItem(at: tmp) } - let persistence = WorkstreamPersistence(fileURL: tmp) - for i in 0..<5 { - try await persistence.append(WorkstreamItem( - workstreamId: "s\(i)", - source: .claude, - kind: .permissionRequest, - payload: .permissionRequest(requestId: "r\(i)", toolName: "t", toolInputJSON: "{}", pattern: nil) - )) - } + @Test("Stale asynchronous generations cannot resurrect older pending state") + func staleGenerationIgnored() async throws { + let fixture = Fixture() + defer { fixture.remove() } + let persistence = WorkstreamPersistence(fileURL: fixture.url) + + try await persistence.replacePendingItems([Self.pendingItem(1)], generation: 2) + try await persistence.replacePendingItems([Self.pendingItem(0)], generation: 1) - let newest = try await persistence.loadPage(limit: 2) - #expect(newest.items.map(\.workstreamId) == ["s3", "s4"]) - #expect(newest.hasMoreBefore) - let cursor = try #require(newest.startOffset) + #expect(try await persistence.loadRecent(limit: 10).map(\.workstreamId) == ["s1"]) + } - try await persistence.append(WorkstreamItem( - workstreamId: "s5", + @Test("Snapshot filters telemetry and resolved history") + func snapshotFiltersHistory() async throws { + let fixture = Fixture() + defer { fixture.remove() } + let persistence = WorkstreamPersistence(fileURL: fixture.url) + let resolved = WorkstreamItem( + workstreamId: "resolved", source: .claude, - kind: .permissionRequest, - payload: .permissionRequest(requestId: "r5", toolName: "t", toolInputJSON: "{}", pattern: nil) - )) + kind: .question, + status: .resolved(.question(selections: ["Done"]), at: Date()), + payload: .question(requestId: "resolved", questions: []) + ) + let telemetry = WorkstreamItem( + workstreamId: "telemetry", + source: .claude, + kind: .toolUse, + payload: .toolUse(toolName: "Read", toolInputJSON: "{}") + ) - let older = try await persistence.loadPage(endingBefore: cursor, limit: 2) - #expect(older.items.map(\.workstreamId) == ["s1", "s2"]) - #expect(older.hasMoreBefore) + try await persistence.replacePendingItems( + [telemetry, resolved, Self.pendingItem(0)], + generation: 1 + ) + + #expect(try await persistence.loadRecent(limit: 10).map(\.workstreamId) == ["s0"]) } - @Test("append redacts sensitive tool input before writing JSONL") - func appendRedactsSensitiveToolInput() async throws { - let tmp = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-workstream-redact-\(UUID().uuidString).jsonl") - defer { try? FileManager.default.removeItem(at: tmp) } - let persistence = WorkstreamPersistence(fileURL: tmp) + @Test("Snapshot redacts sensitive tool input") + func snapshotRedactsSensitiveToolInput() async throws { + let fixture = Fixture() + defer { fixture.remove() } + let persistence = WorkstreamPersistence(fileURL: fixture.url) let homePath = FileManager.default.homeDirectoryForCurrentUser.path - try await persistence.append(WorkstreamItem( + let item = WorkstreamItem( workstreamId: "s", source: .claude, kind: .permissionRequest, @@ -101,7 +90,9 @@ struct WorkstreamPersistenceTests { toolInputJSON: #"{"command":"OPENAI_API_KEY=sk-test node \#(homePath)/app.js","env":{"SECRET":"value"}}"#, pattern: nil ) - )) + ) + + try await persistence.replacePendingItems([item], generation: 1) let loaded = try await persistence.loadRecent(limit: 1) guard case .permissionRequest(_, _, let toolInputJSON, _) = loaded[0].payload else { @@ -118,50 +109,35 @@ struct WorkstreamPersistenceTests { #expect((object["command"] as? String)?.contains("~/app.js") == true) } - @Test("Missing file returns empty") - func missingFileEmpty() async throws { - let tmp = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-workstream-missing-\(UUID().uuidString).jsonl") - let persistence = WorkstreamPersistence(fileURL: tmp) - let loaded = try await persistence.loadRecent(limit: 10) - #expect(loaded.isEmpty) - } - - @Test("Non-positive limit returns empty") - func nonPositiveLimitEmpty() async throws { - let tmp = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-workstream-limit-\(UUID().uuidString).jsonl") - defer { try? FileManager.default.removeItem(at: tmp) } - let persistence = WorkstreamPersistence(fileURL: tmp) - try await persistence.append(WorkstreamItem( - workstreamId: "s", source: .claude, kind: .sessionStart, payload: .sessionStart - )) - let loaded = try await persistence.loadRecent(limit: 0) - #expect(loaded.isEmpty) + @Test("Missing file and non-positive limits return empty") + func emptyReads() async throws { + let fixture = Fixture() + defer { fixture.remove() } + let persistence = WorkstreamPersistence(fileURL: fixture.url) + #expect(try await persistence.loadRecent(limit: 10).isEmpty) + try await persistence.replacePendingItems([Self.pendingItem(0)], generation: 1) + #expect(try await persistence.loadRecent(limit: 0).isEmpty) } - @Test("clear removes the backing file") + @Test("Clear removes the backing file") func clearRemovesFile() async throws { - let tmp = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-workstream-clear-\(UUID().uuidString).jsonl") - let persistence = WorkstreamPersistence(fileURL: tmp) - try await persistence.append(WorkstreamItem( - workstreamId: "s", source: .claude, kind: .sessionStart, payload: .sessionStart - )) - #expect(FileManager.default.fileExists(atPath: tmp.path)) + let fixture = Fixture() + defer { fixture.remove() } + let persistence = WorkstreamPersistence(fileURL: fixture.url) + try await persistence.replacePendingItems([Self.pendingItem(0)], generation: 1) + #expect(FileManager.default.fileExists(atPath: fixture.url.path)) try await persistence.clear() - #expect(!FileManager.default.fileExists(atPath: tmp.path)) + #expect(!FileManager.default.fileExists(atPath: fixture.url.path)) } @Test("Persisted Feed state stays below the disk byte ceiling") func persistedStateHasByteCeiling() async throws { - let tmp = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-workstream-bounded-\(UUID().uuidString).jsonl") - defer { try? FileManager.default.removeItem(at: tmp) } - let persistence = WorkstreamPersistence(fileURL: tmp) + let fixture = Fixture() + defer { fixture.remove() } + let persistence = WorkstreamPersistence(fileURL: fixture.url) let largeInput = String(repeating: "x", count: 64 * 1024) - for i in 0..<64 { - try await persistence.append(WorkstreamItem( + let items = (0..<100).map { i in + WorkstreamItem( workstreamId: "s\(i)", source: .claude, kind: .permissionRequest, @@ -171,11 +147,40 @@ struct WorkstreamPersistenceTests { toolInputJSON: largeInput, pattern: nil ) - )) + ) } - let attributes = try FileManager.default.attributesOfItem(atPath: tmp.path) + try await persistence.replacePendingItems(items, generation: 1) + + let attributes = try FileManager.default.attributesOfItem(atPath: fixture.url.path) let fileSize = try #require(attributes[.size] as? NSNumber).intValue - #expect(fileSize <= 2 * 1024 * 1024) + #expect(fileSize <= WorkstreamDefaultPersistedByteLimit) + #expect(try await persistence.loadRecent(limit: 100).last?.workstreamId == "s99") + } + + private static func pendingItem(_ index: Int) -> WorkstreamItem { + WorkstreamItem( + workstreamId: "s\(index)", + source: .claude, + kind: .permissionRequest, + payload: .permissionRequest( + requestId: "r\(index)", + toolName: "Write", + toolInputJSON: "{}", + pattern: nil + ) + ) + } +} + +private struct Fixture { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-workstream-\(UUID().uuidString).jsonl") + + func remove() { + try? FileManager.default.removeItem(at: url) + try? FileManager.default.removeItem( + at: WorkstreamPersistence.removedItemsFileURL(for: url) + ) } } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift index fdda1f959b3b..3db54b5464ad 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamStoreTests.swift @@ -39,38 +39,28 @@ struct WorkstreamStoreTests { #expect(store.items.last?.workstreamId == "s4") } - @Test("start loads a small recent slice and pages older persisted rows on demand") - func lazyLoadPersistedHistory() async throws { + @Test("start restores only the newest pending items within the memory ring") + func boundedPendingRestore() async throws { let tmp = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-workstream-store-page-\(UUID().uuidString).jsonl") defer { try? FileManager.default.removeItem(at: tmp) } let persistence = WorkstreamPersistence(fileURL: tmp) - for i in 0..<5 { - try await persistence.append(WorkstreamItem( + let items = (0..<5).map { i in + WorkstreamItem( workstreamId: "s\(i)", source: .claude, kind: .permissionRequest, payload: .permissionRequest(requestId: "r\(i)", toolName: "t", toolInputJSON: "{}", pattern: nil) - )) + ) } + try await persistence.replacePendingItems(items, generation: 1) let store = WorkstreamStore( persistence: persistence, - ringCapacity: 10, - initialLoadLimit: 2, - historyPageSize: 2 + ringCapacity: 2 ) await store.start() #expect(store.items.map(\.workstreamId) == ["s3", "s4"]) - #expect(store.hasMorePersistedItems) - - await store.loadOlderItems() - #expect(store.items.map(\.workstreamId) == ["s1", "s2", "s3", "s4"]) - #expect(store.hasMorePersistedItems) - - await store.loadOlderItems() - #expect(store.items.map(\.workstreamId) == ["s0", "s1", "s2", "s3", "s4"]) - #expect(!store.hasMorePersistedItems) } @Test("removing an item hides it immediately and after restart") @@ -87,17 +77,16 @@ struct WorkstreamStoreTests { let kept = WorkstreamItem( workstreamId: "kept", source: .opencode, - kind: .sessionStart, - payload: .sessionStart + kind: .question, + payload: .question(requestId: "kept", questions: []) ) let removed = WorkstreamItem( workstreamId: "removed", source: .opencode, - kind: .sessionEnd, - payload: .sessionEnd + kind: .question, + payload: .question(requestId: "removed", questions: []) ) - try await persistence.append(kept) - try await persistence.append(removed) + try await persistence.replacePendingItems([kept, removed], generation: 1) let store = WorkstreamStore(persistence: persistence, ringCapacity: 10) await store.start() @@ -151,9 +140,8 @@ struct WorkstreamStoreTests { source: "claude", toolName: "Read" )) - #expect(store.items.count == 1) + #expect(store.items.isEmpty) #expect(store.pending.isEmpty) - #expect(store.items[0].kind == .toolUse) } @Test("Telemetry enriches actionable cards without entering retained Feed state") @@ -197,25 +185,24 @@ struct WorkstreamStoreTests { .appendingPathComponent("cmux-workstream-store-compact-\(UUID().uuidString).jsonl") defer { try? FileManager.default.removeItem(at: tmp) } let persistence = WorkstreamPersistence(fileURL: tmp) - try await persistence.append(WorkstreamItem( + let legacyItems = [WorkstreamItem( workstreamId: "telemetry", source: .claude, kind: .toolUse, payload: .toolUse(toolName: "Read", toolInputJSON: "{}") - )) - try await persistence.append(WorkstreamItem( + ), WorkstreamItem( workstreamId: "resolved", source: .claude, kind: .question, status: .resolved(.question(selections: ["Done"]), at: Date()), payload: .question(requestId: "resolved", questions: []) - )) - try await persistence.append(WorkstreamItem( + ), WorkstreamItem( workstreamId: "pending", source: .claude, kind: .question, payload: .question(requestId: "pending", questions: []) - )) + )] + try writeLegacyItems(legacyItems, to: tmp) let store = WorkstreamStore(persistence: persistence, ringCapacity: 10) await store.start() @@ -255,51 +242,8 @@ struct WorkstreamStoreTests { )) } - #expect(store.items.count == events.count) + #expect(store.items.isEmpty) #expect(store.pending.isEmpty) - #expect(store.items.allSatisfy { $0.status == .telemetry }) - #expect(store.items.map(\.title).contains("Compaction")) - #expect(store.items.map(\.title).contains("Subagent")) - #expect(!store.items.map(\.title).contains("PreCompact")) - #expect(!store.items.map(\.title).contains("PostCompact")) - #expect(!store.items.map(\.title).contains("SubagentStart")) - #expect(!store.items.contains { $0.kind == .sessionStart }) - #expect(!store.items.contains { $0.kind == .stop }) - if let compactionStartItem = store.items.first(where: { - $0.title == "Compaction" && $0.kind == .toolUse - }) { - if case .toolUse(let toolName, _) = compactionStartItem.payload { - #expect(toolName == "Compaction") - } else { - Issue.record("expected PreCompact to decode as toolUse telemetry") - } - } else { - Issue.record("expected PreCompact item") - } - if let subagentStartItem = store.items.first(where: { - $0.title == "Subagent" && $0.kind == .toolUse - }) { - #expect(subagentStartItem.kind == .toolUse) - if case .toolUse(let toolName, _) = subagentStartItem.payload { - #expect(toolName == "Subagent") - } else { - Issue.record("expected SubagentStart to decode as toolUse telemetry") - } - } else { - Issue.record("expected SubagentStart item") - } - if let subagentStopItem = store.items.first(where: { - $0.title == "Subagent" && $0.kind == .toolResult - }) { - #expect(subagentStopItem.kind == .toolResult) - if case .toolResult(let toolName, _, _) = subagentStopItem.payload { - #expect(toolName == "Subagent") - } else { - Issue.record("expected SubagentStop to decode as toolResult telemetry") - } - } else { - Issue.record("expected SubagentStop item") - } } @Test("Telemetry payloads preserve prompt, stop, and todo content") @@ -324,22 +268,7 @@ struct WorkstreamStoreTests { toolInputJSON: #"{"todos":[{"id":"t1","content":"test","status":"in_progress"}]}"# )) - if case .userPrompt(let text) = store.items[0].payload { - #expect(text == "ship it") - } else { - Issue.record("expected user prompt payload") - } - if case .stop(let reason) = store.items[1].payload { - #expect(reason == "done") - } else { - Issue.record("expected stop payload") - } - if case .todos(let todos) = store.items[2].payload { - #expect(todos.first?.content == "test") - #expect(todos.first?.state == .inProgress) - } else { - Issue.record("expected todos payload") - } + #expect(store.items.isEmpty) } @Test("Prompt context carries into later permission requests") @@ -361,8 +290,8 @@ struct WorkstreamStoreTests { requestId: "r1" )) - #expect(store.items[1].context?.lastUserMessage == "demo the permission UI") - #expect(store.items[1].context?.permissionMode == "plan") + #expect(store.items[0].context?.lastUserMessage == "demo the permission UI") + #expect(store.items[0].context?.permissionMode == "plan") } @Test("Exit plan context parses plan JSON") @@ -394,6 +323,17 @@ struct WorkstreamStoreTests { } } +private func writeLegacyItems(_ items: [WorkstreamItem], to url: URL) throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + var data = Data() + for item in items { + data.append(try encoder.encode(item)) + data.append(0x0A) + } + try data.write(to: url) +} + /// Mutable clock wrapper safe to capture by a `@Sendable` closure in tests. private final class TestClock: @unchecked Sendable { private let lock = NSLock() diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index a92dbd9ad5ed..8af26145fb64 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -103104,74 +103104,6 @@ } } }, - "feed.empty.activity.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Agent decisions and todo-list updates will appear here." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェントの判断とTodoリストの更新がここに表示されます。" - } - } - } - }, - "feed.empty.activity.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No activity yet" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アクティビティはまだありません" - } - } - } - }, - "feed.empty.all.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Tool use, messages, and session events will appear here." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ツールの利用、メッセージ、セッションイベントがここに表示されます。" - } - } - } - }, - "feed.empty.all.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No feed activity yet" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フィードのアクティビティはまだありません" - } - } - } - }, "feed.exitplan.allowedPrompts": { "extractionState": "manual", "localizations": { @@ -103427,91 +103359,6 @@ } } }, - "feed.filter.actionable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Actionable" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "要対応" - } - } - } - }, - "feed.filter.activity": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "All Activity" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "すべてのアクティビティ" - } - } - } - }, - "feed.filter.all": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "All" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "すべて" - } - } - } - }, - "feed.history.loadOlder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Load older activity" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "古いアクティビティを読み込む" - } - } - } - }, - "feed.history.loadingOlder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Loading older activity..." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "古いアクティビティを読み込み中..." - } - } - } - }, "feed.kind.exitPlan": { "extractionState": "manual", "localizations": { diff --git a/Sources/Feed/FeedActivitySnapshotGroups.swift b/Sources/Feed/FeedActivitySnapshotGroups.swift deleted file mode 100644 index 6cd7a4e18f9b..000000000000 --- a/Sources/Feed/FeedActivitySnapshotGroups.swift +++ /dev/null @@ -1,12 +0,0 @@ -/// Stable and historical Feed rows prepared outside SwiftUI rendering. -struct FeedActivitySnapshotGroups: Equatable { - let stable: [FeedItemSnapshot] - let history: [FeedItemSnapshot] - let ordered: [FeedItemSnapshot] - - init(stable: [FeedItemSnapshot], history: [FeedItemSnapshot]) { - self.stable = stable - self.history = history - self.ordered = stable + history - } -} diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 35528a6c3633..c332058d2e6f 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -108,11 +108,11 @@ final class FeedCoordinator: @unchecked Sendable { guard let requestId = event.requestId, waitTimeout > 0 else { let itemID = await MainActor.run { () -> UUID? in guard !Task.isCancelled else { return nil } - self.store.ingest(event) + let itemID = self.store.ingest(event) if let ppid = event.ppid, ppid > 0 { self.armPidWatcher(ppid: ppid) } - return self.store.items.last?.id + return itemID } return Task.isCancelled ? .timedOut(itemId: itemID) : .acknowledged(itemId: itemID) } @@ -137,8 +137,7 @@ final class FeedCoordinator: @unchecked Sendable { : nil let ingestResult: (itemID: UUID?, attentionTarget: AttentionTarget?) = await MainActor.run { guard !Task.isCancelled else { return (nil, nil) } - self.store.ingest(event) - let itemID = self.store.items.last?.id + let itemID = self.store.ingest(event) if let ppid = event.ppid, ppid > 0 { self.armPidWatcher(ppid: ppid) } diff --git a/Sources/Feed/FeedListView.swift b/Sources/Feed/FeedListView.swift index 51d382904f98..d5077528c428 100644 --- a/Sources/Feed/FeedListView.swift +++ b/Sources/Feed/FeedListView.swift @@ -5,14 +5,10 @@ import Foundation import SwiftUI struct FeedListView: View { - let filter: FeedPanelView.Filter let presentation: FeedPresentationSnapshot let placement: FeedPlacement let focusScopeID: UUID let onFocusHostChange: (FeedKeyboardFocusView?) -> Void - let hasMorePersistedItems: Bool - let isLoadingOlderItems: Bool - let onLoadOlderItems: () -> Void @State private var focusSnapshot = FeedFocusSnapshot() @State private var scrollRequest: FeedScrollRequest? @@ -23,19 +19,14 @@ struct FeedListView: View { @State private var actionTaskStore = FeedRowActions.TaskStore() var body: some View { - let activityGroups = filter == .activity ? presentation.activity : nil - let snapshots = activityGroups?.ordered ?? presentation.actionable + let snapshots = presentation.actionable let rowActions = FeedRowActions.bound(taskStore: actionTaskStore) ScrollViewReader { proxy in Group { - if snapshots.isEmpty && !shouldShowActivityHistoryLoader { + if snapshots.isEmpty { emptyState } else { - contentBody( - snapshots: snapshots, - activityGroups: activityGroups, - actions: rowActions - ) + stableScrollSurface(snapshots: snapshots, actions: rowActions) } } .onChange(of: scrollRequest) { _, request in @@ -93,27 +84,6 @@ struct FeedListView: View { } } - @ViewBuilder - private func contentBody( - snapshots: [FeedItemSnapshot], - activityGroups: FeedActivitySnapshotGroups?, - actions: FeedRowActions - ) -> some View { - switch filter { - case .actionable: - stableScrollSurface( - snapshots: snapshots, - actions: actions - ) - case .activity: - activityScrollSurface( - groups: activityGroups ?? FeedActivitySnapshotGroups(stable: [], history: []), - actions: actions, - showsLoadMore: hasMorePersistedItems - ) - } - } - private func stableScrollSurface( snapshots: [FeedItemSnapshot], actions: FeedRowActions @@ -134,56 +104,6 @@ struct FeedListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } - private func activityScrollSurface( - groups: FeedActivitySnapshotGroups, - actions: FeedRowActions, - showsLoadMore: Bool - ) -> some View { - List { - ForEach(groups.stable, id: \.id) { snapshot in - rowSurface( - snapshot: snapshot, - actions: actions, - showsDivider: snapshot.id != groups.stable.last?.id - ) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - if !groups.stable.isEmpty && (!groups.history.isEmpty || showsLoadMore) { - rowSeparator - .id("feed.activity.separator") - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - ForEach(groups.history, id: \.id) { snapshot in - rowSurface( - snapshot: snapshot, - actions: actions, - showsDivider: snapshot.id != groups.history.last?.id - ) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - if showsLoadMore { - FeedHistoryLoadMoreRow( - isLoading: isLoadingOlderItems, - action: onLoadOlderItems - ) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .feedZeroScrollContentMargins() - .environment(\.defaultMinListRowHeight, 0) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - private func rowSurface( snapshot: FeedItemSnapshot, actions: FeedRowActions, @@ -235,10 +155,6 @@ struct FeedListView: View { ) } - private var shouldShowActivityHistoryLoader: Bool { - filter == .activity && hasMorePersistedItems - } - private func selectRow(_ id: UUID, focusFeed: Bool) { let selectionChanged = focusSnapshot.selectedItemId != id let window = activeFeedWindow() @@ -399,27 +315,14 @@ struct FeedListView: View { focusSnapshot = controller.feedFocusSnapshot() } - private var rowSeparator: some View { - Rectangle() - .fill(Color.primary.opacity(0.08)) - .frame(maxWidth: .infinity) - .frame(height: 1) - } - private var emptyState: some View { VStack(spacing: 4) { - Text(filter == .actionable - ? String(localized: "feed.empty.actionable.title", - defaultValue: "No pending decisions") - : String(localized: "feed.empty.activity.title", - defaultValue: "No activity yet")) + Text(String(localized: "feed.empty.actionable.title", + defaultValue: "No pending decisions")) .cmuxFont(size: 12) .foregroundColor(.secondary) - Text(filter == .actionable - ? String(localized: "feed.empty.actionable.subtitle", - defaultValue: "Permission, plan, and question requests from AI agents will appear here.") - : String(localized: "feed.empty.activity.subtitle", - defaultValue: "Agent decisions and todo-list updates will appear here.")) + Text(String(localized: "feed.empty.actionable.subtitle", + defaultValue: "Permission, plan, and question requests from AI agents will appear here.")) .cmuxFont(size: 11) .foregroundColor(.secondary.opacity(0.7)) .multilineTextAlignment(.center) diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index adc8cdec2d74..9c32977c5b5c 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -44,38 +44,12 @@ extension WorkstreamExitPlanMode { } } /// Right-sidebar Feed view. Matches the Sessions page visual language: -/// compact rows with SF Symbol + 13pt title + secondary metadata, -/// full-width hover backgrounds, and control-bar pill buttons styled -/// like `GroupingButton` in `SessionIndexView`. -/// -/// Pending items float above resolved; telemetry is hidden unless the -/// user flips the Actionable / All filter. Rows receive immutable -/// snapshots + closure action bundles only (snapshot-boundary rule). +/// compact rows with SF Symbol + 13pt title + secondary metadata and +/// full-width hover backgrounds. Only actionable cards are retained. struct FeedPanelView: View { - enum Filter: String, CaseIterable, Identifiable { - case actionable - case activity - var id: String { rawValue } - var label: String { - switch self { - case .actionable: - return String(localized: "feed.filter.actionable", defaultValue: "Actionable") - case .activity: - return String(localized: "feed.filter.activity", defaultValue: "All Activity") - } - } - var symbolName: String { - switch self { - case .actionable: return "exclamationmark.circle" - case .activity: return "checklist" - } - } - } - let placement: FeedPlacement let onFocusHostChange: (FeedKeyboardFocusView?) -> Void - @State private var filter: Filter = .actionable @State private var viewModel = FeedPanelViewModel() @State private var focusScopeID = UUID() @@ -88,87 +62,12 @@ struct FeedPanelView: View { } var body: some View { - VStack(spacing: 0) { - controlBar - FeedListView( - filter: filter, - presentation: viewModel.presentation, - placement: placement, - focusScopeID: focusScopeID, - onFocusHostChange: onFocusHostChange, - hasMorePersistedItems: viewModel.hasMorePersistedItems, - isLoadingOlderItems: viewModel.isLoadingOlderItems, - onLoadOlderItems: viewModel.loadOlderItems - ) - } - } - - private var controlBar: some View { - Group { - #if compiler(>=6.2) - if #available(macOS 26.0, *) { - GlassEffectContainer(spacing: 6) { - controlBarContent - } - } else { - controlBarContent - } - #else - controlBarContent - #endif - } - .rightSidebarChromeBar( - leadingPadding: RightSidebarChromeMetrics.barHorizontalPadding + FeedListView( + presentation: viewModel.presentation, + placement: placement, + focusScopeID: focusScopeID, + onFocusHostChange: onFocusHostChange ) - .rightSidebarChromeBottomBorder() - .reportRightSidebarChromeGeometryForBonsplitUITest(role: .secondaryBar, isVisible: true, titlebarHeight: RightSidebarChromeMetrics.secondaryBarHeight) - } - - private var controlBarContent: some View { - HStack(spacing: 6) { - ForEach(Filter.allCases) { f in - FeedSecondaryFilterButton( - filter: f, - isSelected: filter == f - ) { - filter = f - } - } - Spacer(minLength: 4) - } - } -} - -private struct FeedSecondaryFilterButton: View { - let filter: FeedPanelView.Filter - let isSelected: Bool - let action: () -> Void - @State private var isHovered = false - - var body: some View { - Button(action: action) { - HStack(spacing: 3) { - Image(systemName: filter.symbolName) - .symbolRenderingMode(.monochrome) - .cmuxFont( - size: RightSidebarChromeControlStyle.secondaryIconSize, - weight: RightSidebarChromeControlStyle.iconWeight - ) - Text(filter.label) - .cmuxFont( - size: RightSidebarChromeControlStyle.labelSize, - weight: RightSidebarChromeControlStyle.labelWeight - ) - } - .rightSidebarChromePill( - isSelected: isSelected, - isHovered: isHovered, - geometryKeyPrefix: "rightSidebarSecondaryControl_feed_\(filter.rawValue)" - ) - } - .buttonStyle(.plain) - .onHover { isHovered = $0 } - .help(filter.label) } } diff --git a/Sources/Feed/FeedPanelViewModel.swift b/Sources/Feed/FeedPanelViewModel.swift index ec11ec1e56b8..46300da3e5da 100644 --- a/Sources/Feed/FeedPanelViewModel.swift +++ b/Sources/Feed/FeedPanelViewModel.swift @@ -10,27 +10,18 @@ import SwiftUI final class FeedPanelViewModel { private(set) var items: [WorkstreamItem] = [] private(set) var presentation = FeedPresentationSnapshot.empty - private(set) var hasMorePersistedItems = false - private(set) var isLoadingOlderItems = false @ObservationIgnored private let presentationStore: FeedPresentationStore - @ObservationIgnored private var loadOlderTask: Task? init(coordinator: FeedCoordinator = .shared) { self.presentationStore = coordinator.presentationStore arm() } - deinit { - loadOlderTask?.cancel() - } - private func arm() { withObservationTracking { items = presentationStore.items presentation = presentationStore.presentation - hasMorePersistedItems = presentationStore.hasMorePersistedItems - isLoadingOlderItems = presentationStore.isLoadingOlderItems } onChange: { [weak self] in Task { @MainActor in self?.arm() @@ -38,46 +29,4 @@ final class FeedPanelViewModel { } } - func loadOlderItems() { - guard !isLoadingOlderItems, - hasMorePersistedItems else { return } - loadOlderTask?.cancel() - let presentationStore = presentationStore - loadOlderTask = Task { @MainActor in - await presentationStore.loadOlderItems() - } - } -} - -struct FeedHistoryLoadMoreRow: View { - let isLoading: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: 6) { - if isLoading { - ProgressView() - .controlSize(.small) - .scaleEffect(0.6) - .frame(width: 12, height: 12) - } - Text(label) - .cmuxFont(size: 11, weight: .medium) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - } - .buttonStyle(.plain) - .disabled(isLoading) - } - - private var label: String { - if isLoading { - return String(localized: "feed.history.loadingOlder", defaultValue: "Loading older activity...") - } - return String(localized: "feed.history.loadOlder", defaultValue: "Load older activity") - } - } diff --git a/Sources/Feed/FeedPresentationSnapshot.swift b/Sources/Feed/FeedPresentationSnapshot.swift index 7803e8f0303e..763f615db8e3 100644 --- a/Sources/Feed/FeedPresentationSnapshot.swift +++ b/Sources/Feed/FeedPresentationSnapshot.swift @@ -1,10 +1,6 @@ /// Immutable presentation data consumed by the Feed list. struct FeedPresentationSnapshot: Equatable { - static let empty = FeedPresentationSnapshot( - actionable: [], - activity: FeedActivitySnapshotGroups(stable: [], history: []) - ) + static let empty = FeedPresentationSnapshot(actionable: []) let actionable: [FeedItemSnapshot] - let activity: FeedActivitySnapshotGroups } diff --git a/Sources/Feed/FeedPresentationStore.swift b/Sources/Feed/FeedPresentationStore.swift index 5191da50b6a7..e682c62e8cda 100644 --- a/Sources/Feed/FeedPresentationStore.swift +++ b/Sources/Feed/FeedPresentationStore.swift @@ -7,8 +7,6 @@ import Observation final class FeedPresentationStore { private(set) var items: [WorkstreamItem] = [] private(set) var presentation = FeedPresentationSnapshot.empty - private(set) var hasMorePersistedItems = false - private(set) var isLoadingOlderItems = false @ObservationIgnored private weak var source: WorkstreamStore? @ObservationIgnored private var observationGeneration = 0 @@ -17,11 +15,6 @@ final class FeedPresentationStore { self.source = source observationGeneration &+= 1 armItemsObservation(generation: observationGeneration) - armPagingObservation(generation: observationGeneration) - } - - func loadOlderItems() async { - await source?.loadOlderItems() } private func armItemsObservation(generation: Int) { @@ -37,61 +30,15 @@ final class FeedPresentationStore { } } - private func armPagingObservation(generation: Int) { - guard generation == observationGeneration, let source else { return } - withObservationTracking { - hasMorePersistedItems = source.hasMorePersistedItems - isLoadingOlderItems = source.isLoadingOlderItems - } onChange: { [weak self] in - Task { @MainActor in - self?.armPagingObservation(generation: generation) - } - } - } - private static func makePresentation(items: [WorkstreamItem]) -> FeedPresentationSnapshot { - var lastPromptByWorkstream: [String: String] = [:] - var snapshots: [(item: WorkstreamItem, snapshot: FeedItemSnapshot)] = [] - snapshots.reserveCapacity(items.count) - for item in items { - if case .userPrompt(let text) = item.payload, !text.isEmpty { - lastPromptByWorkstream[item.workstreamId] = text - } - snapshots.append(( + var actionable: [FeedItemSnapshot] = [] + actionable.reserveCapacity(items.count) + for item in items.reversed() where item.kind.isActionable { + actionable.append(FeedItemSnapshot( item: item, - snapshot: FeedItemSnapshot( - item: item, - userPromptEcho: lastPromptByWorkstream[item.workstreamId] - ) + userPromptEcho: item.context?.lastUserMessage )) } - - var actionable: [FeedItemSnapshot] = [] - var activityStable: [FeedItemSnapshot] = [] - var activityHistory: [FeedItemSnapshot] = [] - actionable.reserveCapacity(snapshots.count) - activityStable.reserveCapacity(snapshots.count) - activityHistory.reserveCapacity(snapshots.count) - - for (item, snapshot) in snapshots.reversed() { - if item.kind.isActionable { - actionable.append(snapshot) - } - guard item.kind.isActionable || item.kind == .todos || item.kind == .stop else { - continue - } - if snapshot.status.isPending || snapshot.kind == .stop { - activityStable.append(snapshot) - } else { - activityHistory.append(snapshot) - } - } - return FeedPresentationSnapshot( - actionable: actionable, - activity: FeedActivitySnapshotGroups( - stable: activityStable, - history: activityHistory - ) - ) + return FeedPresentationSnapshot(actionable: actionable) } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 95e50f1c28f3..25a172df8a8f 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -792,7 +792,6 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources E50320010000000000000001 /* ExtensionWorktreeSpawnArgsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50320010000000000000002 /* ExtensionWorktreeSpawnArgsTests.swift */; }; C0DEA7710000000000000007 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA7710000000000000008 /* FeatureFlags.swift */; }; FEED0A00000000000000F007 /* feed-tui in Resources */ = {isa = PBXBuildFile; fileRef = FEED0A00000000000000F006 /* feed-tui */; }; - FEE200000000000000000013 /* FeedActivitySnapshotGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000013 /* FeedActivitySnapshotGroups.swift */; }; FEE200000000000000000012 /* FeedBlockingCallBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000012 /* FeedBlockingCallBridge.swift */; }; FEE200000000000000000017 /* FeedBlockingWaiterCompletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000017 /* FeedBlockingWaiterCompletion.swift */; }; FEE200000000000000000002 /* FeedBlockingWaiterRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE100000000000000000002 /* FeedBlockingWaiterRegistry.swift */; }; @@ -2890,7 +2889,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E50320010000000000000002 /* ExtensionWorktreeSpawnArgsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionWorktreeSpawnArgsTests.swift; sourceTree = ""; }; C0DEA7710000000000000008 /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; FEED0A00000000000000F006 /* feed-tui */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; path = "feed-tui"; sourceTree = ""; }; - FEE100000000000000000013 /* FeedActivitySnapshotGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedActivitySnapshotGroups.swift; sourceTree = ""; }; FEE100000000000000000012 /* FeedBlockingCallBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedBlockingCallBridge.swift; sourceTree = ""; }; FEE100000000000000000017 /* FeedBlockingWaiterCompletion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedBlockingWaiterCompletion.swift; sourceTree = ""; }; FEE100000000000000000002 /* FeedBlockingWaiterRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedBlockingWaiterRegistry.swift; sourceTree = ""; }; @@ -6342,7 +6340,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEE100000000000000000002 /* FeedBlockingWaiterRegistry.swift */, FEE100000000000000000017 /* FeedBlockingWaiterCompletion.swift */, FEE100000000000000000012 /* FeedBlockingCallBridge.swift */, - FEE100000000000000000013 /* FeedActivitySnapshotGroups.swift */, FEE100000000000000000014 /* FeedPresentationSnapshot.swift */, FEE100000000000000000016 /* FeedPresentationStore.swift */, F17A00000000000000000001 /* FeedButton.swift */, @@ -7286,7 +7283,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E4C001000000000000000001 /* ExtensionSidebarWorkspaceRowView.swift in Sources */, E4C001000000000000000003 /* ExtensionWorktreePrototype.swift in Sources */, C0DEA7710000000000000007 /* FeatureFlags.swift in Sources */, - FEE200000000000000000013 /* FeedActivitySnapshotGroups.swift in Sources */, FEE200000000000000000012 /* FeedBlockingCallBridge.swift in Sources */, FEE200000000000000000017 /* FeedBlockingWaiterCompletion.swift in Sources */, FEE200000000000000000002 /* FeedBlockingWaiterRegistry.swift in Sources */, diff --git a/cmuxUITests/RightSidebarChromeHeightUITests.swift b/cmuxUITests/RightSidebarChromeHeightUITests.swift index 1530340e329a..398234ecb418 100644 --- a/cmuxUITests/RightSidebarChromeHeightUITests.swift +++ b/cmuxUITests/RightSidebarChromeHeightUITests.swift @@ -71,21 +71,7 @@ final class RightSidebarChromeHeightUITests: XCTestCase { let feedButton = app.buttons["RightSidebarModeButton.feed"] XCTAssertTrue(feedButton.waitForExistence(timeout: 5)) feedButton.click() - - let feedControlHeightKeys = [ - "rightSidebarSecondaryControl_feed_actionableHeight", - "rightSidebarSecondaryControl_feed_activityHeight", - ] - guard let feedGeometry = waitForJSONNumbers(feedControlHeightKeys, greaterThan: 1, atPath: dataPath, timeout: 5), - let feedSecondaryBarHeight = Double(feedGeometry["rightSidebarSecondaryBarHeight"] ?? ""), - let actionableControlHeight = Double(feedGeometry["rightSidebarSecondaryControl_feed_actionableHeight"] ?? ""), - let activityControlHeight = Double(feedGeometry["rightSidebarSecondaryControl_feed_activityHeight"] ?? "") else { - XCTFail("Timed out waiting for feed secondary bar geometry. data=\(loadJSON(atPath: dataPath) ?? [:])") - return - } - XCTAssertEqual(feedSecondaryBarHeight, modeBarHeight, accuracy: 0.5, "Expected feed secondary bar to match the mode bar. geometry=\(feedGeometry)") - XCTAssertEqual(actionableControlHeight, modeControlHeight, accuracy: 0.5, "Expected Feed Actionable pill to match mode button height. geometry=\(feedGeometry)") - XCTAssertEqual(activityControlHeight, modeControlHeight, accuracy: 0.5, "Expected Feed All Activity pill to match mode button height. geometry=\(feedGeometry)") + XCTAssertFalse(app.buttons["All Activity"].exists, "Feed no longer exposes activity history") let dockButton = app.buttons["RightSidebarModeButton.dock"] XCTAssertTrue(dockButton.waitForExistence(timeout: 5)) From 58a947f68cb5e67ad53d451402ead395b61b4d91 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:59:34 -0700 Subject: [PATCH 39/49] Cover Feed-only workspace navigation --- cmuxTests/FeedFocusRoutingTests.swift | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/cmuxTests/FeedFocusRoutingTests.swift b/cmuxTests/FeedFocusRoutingTests.swift index f2b495502db7..ffa127b8ee59 100644 --- a/cmuxTests/FeedFocusRoutingTests.swift +++ b/cmuxTests/FeedFocusRoutingTests.swift @@ -183,6 +183,39 @@ import Testing #expect(GhosttySurfaceScrollView.flashCount(for: targetPanel.id) == 1) } + @Test func feedOnlyWorkspaceJumpKeepsLiveTerminalSelectedAfterFocusSettles() async throws { + let appDelegate = AppDelegate() + let manager = TabManager() + let targetWorkspace = try #require(manager.selectedWorkspace) + let targetPanel = try #require(targetWorkspace.focusedTerminalPanel) + let targetSurfaceID = try #require( + targetWorkspace.surfaceIdFromPanelId(targetPanel.id)?.uuid + ) + let feedWorkspace = manager.addWorkspace( + title: "Feed", + initialSurface: .feed, + inheritWorkingDirectory: false, + select: true, + autoWelcomeIfNeeded: false, + allowTextBoxFocusDefault: false + ) + let windowID = appDelegate.registerMainWindowContextForTesting(tabManager: manager) + defer { appDelegate.unregisterMainWindowContextForTesting(windowId: windowID) } + + #expect(feedWorkspace.panels.values.contains { + ($0 as? RightSidebarToolPanel)?.mode == .feed + }) + #expect(appDelegate.routeFeedFocus( + workspaceId: targetWorkspace.id.uuidString, + surfaceId: targetSurfaceID.uuidString + )) + + await Task.yield() + + #expect(manager.selectedTabId == targetWorkspace.id) + #expect(targetWorkspace.focusedPanelId == targetPanel.id) + } + @Test func resolvedQuestionSelectionsRemainAvailableForPresentation() { let status = WorkstreamStatus.resolved( .question(selections: ["Answering a question"]), From 54d808ba97a35feac3e2fc250ab7204dc867aea5 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:00:38 -0700 Subject: [PATCH 40/49] Route Feed jumps through live surface focus --- Sources/AppDelegate.swift | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c8c5c114515c..32b72a1e046d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9345,13 +9345,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent $0.id == targetWorkspaceID }) else { return false } - _ = focusMainWindow(windowId: located.windowId) - TerminalController.shared.setActiveTabManager(located.tabManager) - located.tabManager.focusTab( - targetWorkspaceID, - surfaceId: located.panelId, - suppressFlash: true + // Use the same window-aware navigation owner as `surface.focus`. + // Feed can itself occupy a right-sidebar-tool workspace, so directly + // mutating the located TabManager leaves two focus paths competing as + // AppKit settles. The control surface route owns window activation, + // active-manager selection, workspace selection, and panel focus. + let routing = ControlRoutingSelectors( + hasWindowIDParam: true, + windowID: located.windowId, + groupID: nil, + workspaceID: targetWorkspaceID, + surfaceID: located.panelId, + paneID: nil ) + guard case .focused(let windowID, let workspaceID, let surfaceID) = + TerminalController.shared.controlSurfaceFocus( + routing: routing, + surfaceID: located.panelId + ), + windowID == located.windowId, + workspaceID == targetWorkspaceID, + surfaceID == located.panelId + else { return false } + workspace.triggerFocusFlash(panelId: located.panelId) return located.tabManager.selectedTabId == targetWorkspaceID && workspace.focusedPanelId == located.panelId From a3696c4c81acba6fdeeb258c22caa3e1d6f5a38d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:14:30 -0700 Subject: [PATCH 41/49] Route Feed socket jumps through navigation --- .../Feed/ControlCommandCoordinator+Feed.swift | 5 +-- .../Coordinator/Feed/ControlFeedContext.swift | 10 +++--- .../ControlCommandContextTestStubs.swift | 2 +- .../ControlCommandCoordinatorFeedTests.swift | 35 +++++++++++++++++++ Sources/Feed/FeedCoordinator+Socket.swift | 13 +++++++ ...erminalController+ControlFeedContext.swift | 4 +-- 6 files changed, 56 insertions(+), 13 deletions(-) create mode 100644 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorFeedTests.swift diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlCommandCoordinator+Feed.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlCommandCoordinator+Feed.swift index eb1c3d53d221..40bbc8ecf4d3 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlCommandCoordinator+Feed.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlCommandCoordinator+Feed.swift @@ -36,10 +36,7 @@ extension ControlCommandCoordinator { data: nil ) } - // MVP: resolve to a cmux surface via `SessionIndexStore` lands in - // the UI PR; for now we return whether the id is known so callers - // can show a toast. - let matched = context?.controlFeedResolvePossibleSurface(workstreamID: workstreamID) ?? false + let matched = context?.controlFeedJump(workstreamID: workstreamID) ?? false return .ok(.object([ "workstream_id": .string(workstreamID), "matched": .bool(matched), diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlFeedContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlFeedContext.swift index db13afd373e1..ac7aafff960f 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlFeedContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlFeedContext.swift @@ -14,15 +14,13 @@ /// disappear once the domain moves onto the coordinator. @MainActor public protocol ControlFeedContext: AnyObject { - /// Resolves whether a workstream id maps to a known cmux surface for - /// `feed.jump`, mirroring the legacy `FeedCoordinator.resolvePossibleSurface` - /// probe (the MVP returns only whether the id is known so callers can show a - /// toast). + /// Starts the same terminal-focus action used by a Feed row for + /// `feed.jump`. /// /// - Parameter workstreamID: The caller-supplied `workstream_id`, untrimmed, /// exactly as the legacy body forwarded it. - /// - Returns: Whether the id matched a possible surface. - func controlFeedResolvePossibleSurface(workstreamID: String) -> Bool + /// - Returns: Whether the id matched a possible surface and focus was queued. + func controlFeedJump(workstreamID: String) -> Bool /// Snapshots the workstream feed items for `feed.list`, already shaped as the /// per-item JSON the legacy `FeedSocketEncoding.itemDict` produced and bridged diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift index 62a629eeb59c..4babea438f10 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -37,7 +37,7 @@ extension ControlAppFocusContext { } extension ControlFeedContext { - func controlFeedResolvePossibleSurface(workstreamID: String) -> Bool { false } + func controlFeedJump(workstreamID: String) -> Bool { false } func controlFeedSnapshotItems(pendingOnly: Bool) -> [JSONValue] { [] } } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorFeedTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorFeedTests.swift new file mode 100644 index 000000000000..fda1970227a3 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorFeedTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing +@testable import CmuxControlSocket + +@MainActor +private final class FakeFeedControlCommandContext: ControlCommandContext { + var jumpResult = false + var jumpedWorkstreamID: String? + + func controlFeedJump(workstreamID: String) -> Bool { + jumpedWorkstreamID = workstreamID + return jumpResult + } +} + +@MainActor +@Suite("ControlCommandCoordinator Feed domain") +struct ControlCommandCoordinatorFeedTests { + @Test func feedJumpDispatchesTheSharedFocusAction() { + let context = FakeFeedControlCommandContext() + context.jumpResult = true + let coordinator = ControlCommandCoordinator(context: context) + let request = ControlRequest( + id: .int(1), + method: "feed.jump", + params: ["workstream_id": .string("opencode-session-1")] + ) + + #expect(coordinator.handle(request) == .ok(.object([ + "workstream_id": .string("opencode-session-1"), + "matched": .bool(true), + ]))) + #expect(context.jumpedWorkstreamID == "opencode-session-1") + } +} diff --git a/Sources/Feed/FeedCoordinator+Socket.swift b/Sources/Feed/FeedCoordinator+Socket.swift index bdb2964570f6..fc65a67eddd9 100644 --- a/Sources/Feed/FeedCoordinator+Socket.swift +++ b/Sources/Feed/FeedCoordinator+Socket.swift @@ -26,6 +26,19 @@ extension FeedCoordinator { jumpResolver.resolve(workstreamId) != nil } + /// Queues the same off-main resolution and main-actor navigation used by + /// Feed rows. The synchronous return only reports whether a session target + /// exists; the focus itself completes on the task so socket handling never + /// blocks on AppKit. + func requestFocusIfPossible(workstreamId: String) -> Bool { + guard resolvePossibleSurface(for: workstreamId) else { return false } + Task { [weak self] in + guard let self else { return } + _ = await focusIfPossible(workstreamId: workstreamId) + } + return true + } + /// Fires a best-effort focus for the given `workstreamId`. Returns /// `true` if a target was found and the focus commands were /// dispatched. Runs on the main actor because the focus commands diff --git a/Sources/TerminalController+ControlFeedContext.swift b/Sources/TerminalController+ControlFeedContext.swift index b32d88a6324c..803c692035c5 100644 --- a/Sources/TerminalController+ControlFeedContext.swift +++ b/Sources/TerminalController+ControlFeedContext.swift @@ -12,8 +12,8 @@ import Foundation /// (`feed.push`, `feed.permission.reply`, `feed.question.reply`, /// `feed.exit_plan.reply`) stay on the app-side socket-worker path. extension TerminalController: ControlFeedContext { - func controlFeedResolvePossibleSurface(workstreamID: String) -> Bool { - FeedCoordinator.shared.resolvePossibleSurface(for: workstreamID) + func controlFeedJump(workstreamID: String) -> Bool { + FeedCoordinator.shared.requestFocusIfPossible(workstreamId: workstreamID) } func controlFeedSnapshotItems(pendingOnly: Bool) -> [JSONValue] { From 6137e4a2cbdd6f0d2077d2b9856dee4dbbd798e4 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:31:39 -0700 Subject: [PATCH 42/49] Cover dormant Feed workspace routes --- cmuxTests/FeedFocusRoutingTests.swift | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/cmuxTests/FeedFocusRoutingTests.swift b/cmuxTests/FeedFocusRoutingTests.swift index ffa127b8ee59..a9293c556c8d 100644 --- a/cmuxTests/FeedFocusRoutingTests.swift +++ b/cmuxTests/FeedFocusRoutingTests.swift @@ -183,6 +183,39 @@ import Testing #expect(GhosttySurfaceScrollView.flashCount(for: targetPanel.id) == 1) } + @Test func feedFocusUsesLiveSurfaceOwnerWhenPersistedWorkspaceRemainsDormant() throws { + let appDelegate = AppDelegate() + let liveManager = TabManager() + let targetWorkspace = try #require(liveManager.selectedWorkspace) + let targetPanel = try #require(targetWorkspace.focusedTerminalPanel) + let targetSurfaceID = try #require( + targetWorkspace.surfaceIdFromPanelId(targetPanel.id)?.uuid + ) + let feedWorkspace = liveManager.addWorkspace() + let dormantManager = TabManager() + let persistedWorkspace = try #require(dormantManager.selectedWorkspace) + let liveWindowID = appDelegate.registerMainWindowContextForTesting( + tabManager: liveManager + ) + let dormantWindowID = appDelegate.registerMainWindowContextForTesting( + tabManager: dormantManager + ) + defer { + appDelegate.unregisterMainWindowContextForTesting(windowId: dormantWindowID) + appDelegate.unregisterMainWindowContextForTesting(windowId: liveWindowID) + } + GhosttySurfaceScrollView.resetFlashCounts() + + #expect(liveManager.selectedTabId == feedWorkspace.id) + #expect(appDelegate.routeFeedFocus( + workspaceId: persistedWorkspace.id.uuidString, + surfaceId: targetSurfaceID.uuidString + )) + #expect(liveManager.selectedTabId == targetWorkspace.id) + #expect(targetWorkspace.focusedPanelId == targetPanel.id) + #expect(GhosttySurfaceScrollView.flashCount(for: targetPanel.id) == 1) + } + @Test func feedOnlyWorkspaceJumpKeepsLiveTerminalSelectedAfterFocusSettles() async throws { let appDelegate = AppDelegate() let manager = TabManager() From 905868654ea589b9480b6000f29385aae88d0caf Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:31:56 -0700 Subject: [PATCH 43/49] Prefer live Feed surface owners --- Sources/AppDelegate.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 32b72a1e046d..5b02a00f6326 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9332,12 +9332,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } // Workspace IDs can change when a persisted terminal surface is - // restored. The stable surface ID is authoritative only when the - // claimed workspace no longer exists; if both workspaces are live, - // retain the mismatch rejection so stale routing cannot redirect a - // Feed card into another active workspace. + // restored. A dormant manager may still retain the old workspace, so + // the stable surface's live owner wins across managers. Within one + // manager, keep rejecting a mismatched workspace/surface pair so a + // stale card cannot redirect into a sibling workspace. if located.workspaceId != claimedWorkspaceID, - tabManagerFor(tabId: claimedWorkspaceID) != nil { + let claimedManager = tabManagerFor(tabId: claimedWorkspaceID), + claimedManager === located.tabManager { return false } let targetWorkspaceID = located.workspaceId From 62c014b4897db6b1e601091fd2008d4e21f0c0ba Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:41:14 -0700 Subject: [PATCH 44/49] Cover Feed jump UI dispatch --- .../ControlCommandExecutionPolicyTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index 15133c9be7b1..2d68d89b0996 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -29,7 +29,7 @@ struct ControlCommandExecutionPolicyTests { @Test func fixedWorkerSetRunsOnTheSocketWorker() { for method in [ "system.ping", "system.capabilities", "auth.status", "auth.sign_in_url", - "feed.push", "feed.jump", "browser.download.wait", "system.top", "system.memory", + "feed.push", "browser.download.wait", "system.top", "system.memory", "workspace.remote.pty_bridge", "workspace.env", "sidebar.custom.reload", "sidebar.custom.open", "debug.sidebar.simulate_drag", "mobile.attach_ticket.create", @@ -60,7 +60,7 @@ struct ControlCommandExecutionPolicyTests { "mobile.terminal.create", "vmx.create", "", // Focus-intent verbs stay on the main lane until the mutations // tranche decides them deliberately. - "surface.focus", "workspace.select", "pane.focus", "window.focus", + "surface.focus", "workspace.select", "pane.focus", "window.focus", "feed.jump", ] { let policy = ControlCommandExecutionPolicy(forMethod: method) #expect(policy == .mainActor, "\(method)") From 7e79a5fbb93b1fb3ea605d9a2fa486e1e1a0818e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:13:13 -0700 Subject: [PATCH 45/49] Execute Feed jumps on the UI lane --- .../Coordinator/Feed/ControlFeedContext.swift | 2 +- .../Wire/ControlCommandExecutionPolicy.swift | 1 - Sources/Feed/FeedCoordinator+Socket.swift | 27 +++++++------------ Sources/TerminalController.swift | 16 ----------- 4 files changed, 11 insertions(+), 35 deletions(-) diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlFeedContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlFeedContext.swift index ac7aafff960f..c6ddfd7f5586 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlFeedContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlFeedContext.swift @@ -19,7 +19,7 @@ public protocol ControlFeedContext: AnyObject { /// /// - Parameter workstreamID: The caller-supplied `workstream_id`, untrimmed, /// exactly as the legacy body forwarded it. - /// - Returns: Whether the id matched a possible surface and focus was queued. + /// - Returns: Whether the id matched a surface and navigation completed. func controlFeedJump(workstreamID: String) -> Bool /// Snapshots the workstream feed items for `feed.list`, already shaped as the diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index 1a77a8a646b7..e56f10296c05 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -87,7 +87,6 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "feed.permission.reply", "feed.question.reply", "feed.exit_plan.reply", - "feed.jump", "browser.download.wait", "browser.profiles.list", "browser.profiles.create", diff --git a/Sources/Feed/FeedCoordinator+Socket.swift b/Sources/Feed/FeedCoordinator+Socket.swift index fc65a67eddd9..8b86d87be291 100644 --- a/Sources/Feed/FeedCoordinator+Socket.swift +++ b/Sources/Feed/FeedCoordinator+Socket.swift @@ -19,24 +19,17 @@ extension FeedCoordinator { return (try? await store.removeItem(id: id)) == true } - /// Synchronous socket-only availability probe. Socket handlers execute on - /// their worker queue, so this path never performs file I/O on MainActor. - /// - func resolvePossibleSurface(for workstreamId: String) -> Bool { - jumpResolver.resolve(workstreamId) != nil - } - - /// Queues the same off-main resolution and main-actor navigation used by - /// Feed rows. The synchronous return only reports whether a session target - /// exists; the focus itself completes on the task so socket handling never - /// blocks on AppKit. + /// Runs the same terminal navigation used by Feed rows and returns only + /// after the UI route has accepted or rejected the target. `feed.jump` is + /// a user-triggered main-actor command, so its response stays ordered after + /// the visible focus change instead of acknowledging a queued task. + @MainActor func requestFocusIfPossible(workstreamId: String) -> Bool { - guard resolvePossibleSurface(for: workstreamId) else { return false } - Task { [weak self] in - guard let self else { return } - _ = await focusIfPossible(workstreamId: workstreamId) - } - return true + guard let target = jumpResolver.resolve(workstreamId) else { return false } + return AppDelegate.shared?.routeFeedFocus( + workspaceId: target.workspaceId, + surfaceId: target.surfaceId + ) ?? false } /// Fires a best-effort focus for the given `workstreamId`. Returns diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 274f42f8b7a2..c22f919db28b 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1211,8 +1211,6 @@ class TerminalController { return v2Result(id: request.id, v2FeedQuestionReply(params: request.params)) case "feed.exit_plan.reply": return v2Result(id: request.id, v2FeedExitPlanReply(params: request.params)) - case "feed.jump": - return v2Result(id: request.id, v2FeedJumpOffMain(params: request.params)) case "browser.download.wait": return v2Result(id: request.id, v2BrowserDownloadWaitOnSocketWorker(params: request.params)) case "browser.navigate", "browser.back", "browser.forward", "browser.reload", @@ -5648,20 +5646,6 @@ class TerminalController { return .ok(FeedCoordinator.shared.socketEncoder.payload(for: result)) } - private nonisolated func v2FeedJumpOffMain(params: [String: Any]) -> V2CallResult { - guard let workstreamID = params["workstream_id"] as? String else { - return .err( - code: "invalid_params", - message: "feed.jump requires workstream_id", - data: nil - ) - } - return .ok([ - "workstream_id": workstreamID, - "matched": FeedCoordinator.shared.resolvePossibleSurface(for: workstreamID), - ]) - } - private nonisolated func v2ApplyIMessageModeSideEffects(for event: WorkstreamEvent) { guard event.hookEventName == .userPromptSubmit || event.hookEventName == .stop, let rawWorkspaceId = event.workspaceId?.trimmingCharacters(in: .whitespacesAndNewlines), From 911c8688dba41677231b9245cf6ef8cd37650687 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:28:23 -0700 Subject: [PATCH 46/49] Cover stale Feed workspace identities --- cmuxTests/FeedFocusRoutingTests.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/cmuxTests/FeedFocusRoutingTests.swift b/cmuxTests/FeedFocusRoutingTests.swift index a9293c556c8d..f44d5be40ded 100644 --- a/cmuxTests/FeedFocusRoutingTests.swift +++ b/cmuxTests/FeedFocusRoutingTests.swift @@ -119,7 +119,7 @@ import Testing #expect(!appDelegate.routeFeedText(surfaceId: "not-a-surface-id", text: "retry this")) } - @Test func feedFocusRejectsMismatchedWorkspaceWithoutChangingSelection() throws { + @Test func feedFocusUsesStableSurfaceWhenWorkspaceIdentityIsStale() throws { let appDelegate = AppDelegate() let manager = TabManager() let origin = try #require(manager.selectedWorkspace) @@ -128,13 +128,16 @@ import Testing let other = manager.addWorkspace() let windowID = appDelegate.registerMainWindowContextForTesting(tabManager: manager) defer { appDelegate.unregisterMainWindowContextForTesting(windowId: windowID) } - let selectedBeforeRoute = manager.selectedTabId + GhosttySurfaceScrollView.resetFlashCounts() - #expect(!appDelegate.routeFeedFocus( + #expect(manager.selectedTabId == other.id) + #expect(appDelegate.routeFeedFocus( workspaceId: other.id.uuidString, surfaceId: surfaceID.uuidString )) - #expect(manager.selectedTabId == selectedBeforeRoute) + #expect(manager.selectedTabId == origin.id) + #expect(origin.focusedPanelId == panel.id) + #expect(GhosttySurfaceScrollView.flashCount(for: panel.id) == 1) } @Test func feedFocusSelectsAndFlashesTheOriginatingTerminal() throws { From 5513a895b36b5962c6698f254291da1c43522d91 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:28:40 -0700 Subject: [PATCH 47/49] Follow stable Feed surface identities --- Sources/AppDelegate.swift | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 5b02a00f6326..7be947980667 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9324,23 +9324,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } @discardableResult - func routeFeedFocus(workspaceId: String, surfaceId: String) -> Bool { - guard let claimedWorkspaceID = UUID(uuidString: workspaceId), - let claimedSurfaceID = UUID(uuidString: surfaceId), + func routeFeedFocus(workspaceId _: String, surfaceId: String) -> Bool { + guard let claimedSurfaceID = UUID(uuidString: surfaceId), let located = locateBonsplitSurface(tabId: claimedSurfaceID) else { return false } - // Workspace IDs can change when a persisted terminal surface is - // restored. A dormant manager may still retain the old workspace, so - // the stable surface's live owner wins across managers. Within one - // manager, keep rejecting a mismatched workspace/surface pair so a - // stale card cannot redirect into a sibling workspace. - if located.workspaceId != claimedWorkspaceID, - let claimedManager = tabManagerFor(tabId: claimedWorkspaceID), - claimedManager === located.tabManager { - return false - } + // Workspace IDs are placement metadata and change during restoration + // or moves. Surface IDs are stable and globally identify the terminal, + // so its current live owner is the navigation authority. let targetWorkspaceID = located.workspaceId guard let workspace = located.tabManager.tabs.first(where: { $0.id == targetWorkspaceID From 61afec90e55576437bd191497eecb75ad430852b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:42:57 -0700 Subject: [PATCH 48/49] Cover Feed hook surface identities --- cmuxTests/FeedFocusRoutingTests.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cmuxTests/FeedFocusRoutingTests.swift b/cmuxTests/FeedFocusRoutingTests.swift index f44d5be40ded..5c880faa5d6d 100644 --- a/cmuxTests/FeedFocusRoutingTests.swift +++ b/cmuxTests/FeedFocusRoutingTests.swift @@ -163,6 +163,26 @@ import Testing #expect(GhosttySurfaceScrollView.flashCount(for: targetPanel.id) == 1) } + @Test func feedFocusAcceptsTheHookPanelIdentity() throws { + let appDelegate = AppDelegate() + let manager = TabManager() + let targetWorkspace = try #require(manager.selectedWorkspace) + let targetPanel = try #require(targetWorkspace.focusedTerminalPanel) + let feedWorkspace = manager.addWorkspace() + let windowID = appDelegate.registerMainWindowContextForTesting(tabManager: manager) + defer { appDelegate.unregisterMainWindowContextForTesting(windowId: windowID) } + GhosttySurfaceScrollView.resetFlashCounts() + + #expect(manager.selectedTabId == feedWorkspace.id) + #expect(appDelegate.routeFeedFocus( + workspaceId: UUID().uuidString, + surfaceId: targetPanel.id.uuidString + )) + #expect(manager.selectedTabId == targetWorkspace.id) + #expect(targetWorkspace.focusedPanelId == targetPanel.id) + #expect(GhosttySurfaceScrollView.flashCount(for: targetPanel.id) == 1) + } + @Test func feedFocusUsesLiveWorkspaceWhenPersistedWorkspaceNoLongerExists() throws { let appDelegate = AppDelegate() let manager = TabManager() From 898c25133b2910be83062eecc59f2457e5c5a3cf Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:43:22 -0700 Subject: [PATCH 49/49] Resolve Feed hook surface identities --- Sources/AppDelegate.swift | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7be947980667..993cc7d708ae 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9325,13 +9325,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func routeFeedFocus(workspaceId _: String, surfaceId: String) -> Bool { - guard let claimedSurfaceID = UUID(uuidString: surfaceId), - let located = locateBonsplitSurface(tabId: claimedSurfaceID) - else { - return false - } + guard let claimedSurfaceID = UUID(uuidString: surfaceId) else { return false } + // Hook session stores persist CMUX_SURFACE_ID, which is the panel UUID. + // Older records may contain the Bonsplit tab UUID, so preserve that as + // a compatibility fallback and normalize both forms to a panel route. + let located = locateSurface(surfaceId: claimedSurfaceID).map { + ( + windowId: $0.windowId, + workspaceId: $0.workspaceId, + panelId: claimedSurfaceID, + tabManager: $0.tabManager + ) + } ?? locateBonsplitSurface(tabId: claimedSurfaceID) + guard let located else { return false } // Workspace IDs are placement metadata and change during restoration - // or moves. Surface IDs are stable and globally identify the terminal, + // or moves. Panel IDs are stable and globally identify the terminal, // so its current live owner is the navigation authority. let targetWorkspaceID = located.workspaceId guard let workspace = located.tabManager.tabs.first(where: {