From fc6f8cc34b146d9b85bf1e70311e5a076ce9309e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 04:20:29 -0800 Subject: [PATCH 1/7] test(home): Chief Home lists the Feed's open requests as a pinned Needs you section (red) Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-528c54a2913c7edf480a --- .../CmuxNextAppTests/HomeNeedsYouTests.swift | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeNeedsYouTests.swift diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeNeedsYouTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeNeedsYouTests.swift new file mode 100644 index 000000000000..f21d5e493715 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeNeedsYouTests.swift @@ -0,0 +1,126 @@ +import AppKit +import CmuxHomeCore +@testable import CmuxNextApp +@testable import CmuxNextFeed +@testable import CmuxNextHome +import CmuxNextTestSupport +import Foundation +import Testing + +/// Chief Home's pinned Needs you section (Leo, via his Chief: "a pinned Needs you view in Chief +/// Home, Feed and iPhone listing items waiting on Leo"): it lists exactly the Feed's open +/// requests, in the order the Feed pins them, from the Feed's own model (no second store); it +/// shows only in a Chief's conversation and only while something waits; it sits under the header +/// over the transcript, so showing or hiding it moves no message. +@MainActor @Suite(.serialized, .closesItsWindows) struct HomeNeedsYouTests { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + private func request(_ id: String, ago minutes: Double, priority: FeedPriority? = nil, + state: FeedItemState = .open) -> FeedItem { + FeedItem(id: id, title: "Allow \(id)?", + prompt: .approve(.init(action: .init(type: .command, summary: "Run", command: "make \(id)"))), + priority: priority, context: FeedContext(acpSession: "ses_\(id)"), + poster: FeedPoster(kind: .harness, label: "api-server", harness: "Claude Code"), state: state, + createdAt: now.addingTimeInterval(-minutes * 60)) + } + + private func notice(_ id: String, ago minutes: Double) -> FeedItem { + FeedItem(id: id, title: "Finished \(id)", poster: FeedPoster(kind: .agent, label: "Agent"), + createdAt: now.addingTimeInterval(-minutes * 60)) + } + + private func feed(_ items: [FeedItem]) -> (FeedModel, InMemoryFeedSource) { + let source = InMemoryFeedSource(snapshot: FeedSnapshot(revision: 1, user: "usr_me", device: "Mac", items: items)) + let model = FeedModel(source: source, clock: { [now] in now }) + model.start() + return (model, source) + } + + /// Lets main-actor tasks run until `condition` holds (bounded). + private func until(_ condition: () -> Bool) async { + var tries = 0 + while !condition(), tries < 400 { + tries += 1 + try? await Task.sleep(for: .milliseconds(5)) + } + } + + @Test func itListsExactlyTheFeedsOpenRequestsInTheFeedsOrder() { + let (model, _) = feed([ + request("a", ago: 5), notice("n", ago: 0), request("b", ago: 10, priority: .high), + request("c", ago: 1, state: .answered), request("d", ago: 1), + ]) + // The Feed pins open requests by priority, then newest: b (high), d, a. No notice, no answered one. + #expect(model.listSections.requests.map(\.id) == ["b", "d", "a"]) + #expect(model.needsYou.map(\.id) == model.listSections.requests.map(\.id)) + let section = FeedNeedsYouView(model: model) + #expect(section.shownIDs == ["b", "d", "a"]) + #expect(section.moreCount == 0) + #expect(!section.isEmpty) + } + + @Test func pastItsRowsTheRestAreCountedNotDropped() { + let (model, _) = feed((0..<5).map { request("r\($0)", ago: Double($0)) }) + let section = FeedNeedsYouView(model: model, limit: 3) + #expect(section.shownIDs == ["r0", "r1", "r2"]) + #expect(section.moreCount == 2) + } + + @Test func itHidesWhenNothingWaitsAndFollowsAnAnswer() async { + let (empty, _) = feed([notice("n", ago: 0)]) + #expect(empty.needsYou.isEmpty) + #expect(FeedNeedsYouView(model: empty).isEmpty) + + let (model, _) = feed([request("a", ago: 1), notice("n", ago: 0)]) + let section = FeedNeedsYouView(model: model) + #expect(section.shownIDs == ["a"]) + // The Feed's own answer path (an Allow click on the row): the owner closes it, the row goes. + model.answer("a", .approve(.init(.allow))) + await until { model.needsYou.isEmpty } + #expect(model.needsYou.isEmpty) + #expect(section.isEmpty) + #expect(section.shownIDs.isEmpty) + } + + @Test func onlyAChiefsConversationShowsItAndOnlyWhileSomethingWaits() { + #expect(HomeNeedsYou.shows(isChief: true, waiting: 1)) + #expect(!HomeNeedsYou.shows(isChief: true, waiting: 0)) + #expect(!HomeNeedsYou.shows(isChief: false, waiting: 3)) + } + + @Test func thePinnedSectionSitsUnderTheHeaderAndMovesNoMessage() async throws { + let source = EmptyChiefSource() + let id = ConversationID("conv_chief") + let inbox = try await source.inbox() + let store = HomeStore(source: source) + store.start() + let view = HomeNativeTranscriptView(store: store, conversation: id, me: inbox.me.id) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 800), styleMask: [.titled], + backing: .buffered, defer: true) + window.isReleasedWhenClosed = false + defer { view.stop(); store.stop(); window.close() } + view.frame = NSRect(x: 0, y: 0, width: 600, height: 800) + window.contentView = view + await view.binding.opened() + view.layoutSubtreeIfNeeded() + let transcriptFrame = view.transcript.frame + #expect(view.pinnedSection == nil) + + let (model, _) = feed([request("a", ago: 1)]) + let section = FeedNeedsYouView(model: model) + view.setPinnedSection(section) + view.layoutSubtreeIfNeeded() + #expect(view.pinnedSection === section) + #expect(section.superview === view) + #expect(section.frame.minY >= view.headerHeight, "under the header: \(section.frame) header \(view.headerHeight)") + #expect(section.frame.height > 0) + #expect(section.frame.maxY < view.transcript.fieldTop, "above the message box") + #expect(view.transcript.frame == transcriptFrame, "the transcript keeps its frame: nothing shifts") + + view.setPinnedSection(nil) + view.layoutSubtreeIfNeeded() + #expect(view.pinnedSection == nil) + #expect(section.superview == nil) + #expect(view.transcript.frame == transcriptFrame) + } +} From e4ea8307d25b54eafac15c427ed6665ef9677297 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 04:30:24 -0800 Subject: [PATCH 2/7] feat(home): a pinned Needs you section at the top of Chief Home The Feed's open requests (the ones it pins, in its order, from its own model) show in a card under the Chief conversation's header: what each one is and who asked, a click opens its source (its link, the asking tab or chat, else the Feed), and the Feed row's one-click answers answer through the Feed's own path. Hidden when nothing waits. It lies over the transcript, so it moves no message. Variants rows (default), cards and bar under Debug Settings > Feed. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-528c54a2913c7edf480a --- .../Home/HomeHostView+NeedsYou.swift | 83 ++++++ .../CmuxNextApp/Home/HomeHostView.swift | 6 +- .../Sources/CmuxNextFeed/FeedStrings.swift | 2 + .../Sources/CmuxNextFeed/FeedTunables.swift | 8 +- .../Resources/Localizable.xcstrings | 131 +++++++++ .../CmuxNextFeed/Views/FeedNeedsYouView.swift | 259 ++++++++++++++++++ ...meNativeTranscriptView+PinnedSection.swift | 51 ++++ .../HomeNativeTranscriptView.swift | 3 + 8 files changed, 540 insertions(+), 3 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView+NeedsYou.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+PinnedSection.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView+NeedsYou.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView+NeedsYou.swift new file mode 100644 index 000000000000..f683aead4ead --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView+NeedsYou.swift @@ -0,0 +1,83 @@ +import CmuxNextCompat +import CmuxNextDaemon +import CmuxNextFeed +import CmuxNextHome + +/// Chief Home's pinned Needs you section (Leo, via his Chief: "a pinned Needs you view in Chief +/// Home, Feed and iPhone listing items waiting on Leo (PRs held for his trial like #30817, +/// sign-ins, approvals, consent cards)"). It is a view on the Feed's own model +/// (``FeedModel/needsYou``, the requests the Feed pins), never a second store; its answers are +/// the Feed's (``FeedNeedsYouView``). +@MainActor +enum HomeNeedsYou { + /// A Chief's conversation shows the section while anything waits; any other conversation never. + static func shows(isChief: Bool, waiting: Int) -> Bool { isChief && waiting > 0 } + + /// The section over the app's Feed, wired to open what a click names. + static func makeSection(services: AppServices) -> FeedNeedsYouView { + let model = services.feed.model + // The Feed's own hooks: a request's Sign In / Open answers and its row's who and where. + FeedGitHubActions.install(on: model, services: services) + model.place = FeedPlace.lookup(services.daemon.store.place) + let section = FeedNeedsYouView(model: model) + section.onOpen = { [weak services] item in + if let services { open(item, services: services) } + } + section.onShowAll = { [weak services] in + if let services { showFeed(services) } + } + return section + } + + /// A click on a request opens its source: its link (a pull request, a sign-in page) through the + /// Feed's open; else the tab or chat that asked; else the Feed with it selected. + static func open(_ item: FeedItem, services: AppServices) { + let model = services.feed.model + if item.context.url != nil { + model.open(item.id) + return + } + model.select(item.id) + let store = services.daemon.store, at = item.context + let tab = at.tab.flatMap(store.tab(idOrResource:)) + ?? at.terminal.flatMap { store.tab(terminalResource: ResourceID(rawValue: $0)) } + ?? at.acpSession.flatMap { store.tab(agentSession: $0) } + if let tab, services.revealTab(tab.id) { return } + showFeed(services) + } + + /// The Feed's Inbox page, in the active window. + static func showFeed(_ services: AppServices) { + try? (services.pages.provider(.inbox) as? FeedPageService)?.open(focus: true) + } +} + +extension HomeHostView { + /// While this Home shows a Chief's conversation, one observation of whether the Feed has open + /// requests puts the section under the header or takes it away; the section observes its own + /// rows. No polling, no timer: it wakes only on a Feed change. Restarted when the conversation + /// becomes (or stops being) a Chief's; cancelled with the view (deinit). + func watchNeedsYou() { + needsYouWatch?.cancel() + needsYouWatch = nil + guard isChief else { return transcript.setPinnedSection(nil) } + let services = control.services + let feed = services.feed + let model = feed.model + // task-owner: lives while this Home shows a Chief (cancelled on the next change and in deinit); event-driven (Observation) + needsYouWatch = Task { [weak self] in + // The Feed's mirror runs once the account is signed in (the Feed panel and sidebar start it the same way). + feed.startIfSignedIn() + for await shows in ObservationStream({ HomeNeedsYou.shows(isChief: true, waiting: model.needsYou.count) }) { + guard let self else { return } + if !shows { + transcript.setPinnedSection(nil) + } else if transcript.pinnedSection == nil { + let section = HomeNeedsYou.makeSection(services: services) + section.onHeightChange = { [weak transcript] in transcript?.pinnedSectionDidResize() } + transcript.setPinnedSection(section) + } + } + } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift index f3e065667416..541032a96113 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift @@ -30,9 +30,11 @@ final class HomeHostView: NSView { private var settingsView: HomeChiefSettingsView { settings.content } /// The Chief pet's rows in the popover's Appearance section. private let petRows: ChiefPetSettingsRows - private(set) var isChief = false { didSet { if isChief != oldValue { watchHeaderPanels() } } } + private(set) var isChief = false { didSet { if isChief != oldValue { watchHeaderPanels(); watchNeedsYou() } } } /// The header's top-right panel toggles follow their panels (HomeHostView+HeaderPanels.swift). var headerPanelsWatch: Task? + /// A Chief's pinned Needs you section follows the Feed's open requests (HomeHostView+NeedsYou.swift). + var needsYouWatch: Task? private var engineWatch: Task? /// Each Chief's avatar beside this conversation's group bubbles, as its Home row draws it. private var avatarsWatch: Task? @@ -384,7 +386,7 @@ final class HomeHostView: NSView { // the app's claim registry and cancels its wake on the app's timer (both outlive this view). A guard that // returned off main skipped all of it. nonisolated deinit { - for task in [availability, engineWatch, avatarsWatch, hostWatch, agentsWatch, shortcutsWatch, spellCheckingWatch, headerPanelsWatch, firstPage, memoryOfferWatch, chiefIntroWatch] { + for task in [availability, engineWatch, avatarsWatch, hostWatch, agentsWatch, shortcutsWatch, spellCheckingWatch, headerPanelsWatch, needsYouWatch, firstPage, memoryOfferWatch, chiefIntroWatch] { task?.cancel() } let settings = settings, transcript = transcript diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/FeedStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/FeedStrings.swift index 4f55ebbebb67..82ecbb488bd5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/FeedStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/FeedStrings.swift @@ -43,6 +43,8 @@ nonisolated enum FeedStrings { } static var needsYou: String { t("group.needsYou", "Needs you") } + /// The count of open requests a pinned Needs you section does not list; a click opens the Feed. + static func needsYouMore(_ count: Int) -> String { t("needsYou.more", "\(count) more") } static var today: String { t("group.today", "Today") } static var earlier: String { t("group.earlier", "Earlier") } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/FeedTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/FeedTunables.swift index de16bdd06b70..aa8ba01b4245 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/FeedTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/FeedTunables.swift @@ -29,7 +29,13 @@ public nonisolated enum FeedTunables { "feed.rowHeight", section, "Row height", help: "Height of a one-line row (notices, inbox).", default: 30, range: 22...48, step: 1, unit: .points, code: "FeedTunables.rowHeight") + /// Chief Home's pinned Needs you section's look (``FeedNeedsYouStyle``). + public static let needsYouStyle = Tunable.choice( + "feed.needsYouStyle", section, "Needs you in Home", + help: "How Chief Home's pinned Needs you section shows the Feed's open requests: a row each with its answers at the end, the Feed's request cards, or one line with the count. Switches live.", + default: .rows, code: "FeedTunables.needsYouStyle") + public static var all: [TunableDescriptor] { - [layout.descriptor, rowHeight.descriptor] + [layout.descriptor, rowHeight.descriptor, needsYouStyle.descriptor] } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Resources/Localizable.xcstrings index 333c465db433..bcde9361d4e7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Resources/Localizable.xcstrings @@ -2621,6 +2621,137 @@ } } }, + "needsYou.more": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "%lld أخرى" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Još %lld" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "%lld mere" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "%lld weitere" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld more" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "%lld más" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "%lld de plus" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Altri %lld" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "他 %lld 件" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "%lld ទៀត" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "%lld개 더" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "%lld til" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Jeszcze %lld" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Mais %lld" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Ещё %lld" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "อีก %lld รายการ" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "%lld tane daha" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Ще %lld" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Thêm %lld" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "还有 %lld 项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "還有 %lld 項" + } + } + } + }, "group.today": { "extractionState": "manual", "localizations": { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift new file mode 100644 index 000000000000..82dd2c76b931 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift @@ -0,0 +1,259 @@ +public import AppKit +public import CmuxNextDesign +import SwiftUI + +/// How the pinned Needs you section draws (`feed.needsYouStyle`, Debug Settings > Feed). Rows is +/// the default; the others are its gallery variants, switched live. +public nonisolated enum FeedNeedsYouStyle: String, Sendable, CaseIterable, TunableChoice { + /// One line per request: what it is, who asked, and its one-click answers at the end. + case rows + /// The Feed list's own request cards (what it asks, then its answers under it). + case cards + /// One line for all of them: the count and the first request; a click opens it. + case bar + + public var tunableTitle: String { + switch self { + case .rows: "Rows (answers at the end)" + case .cards: "Cards (the Feed's request cards)" + case .bar: "One line (count and first request)" + } + } +} + +extension FeedModel { + /// What waits on the person: the Feed's open requests, the items and the order its list pins at + /// its top (``listSections``), read from this model (no second store). + public var needsYou: [FeedItem] { listSections.requests } +} + +/// The Feed's open requests as a pinned section (Chief Home's Needs you): each request says what +/// it is and who asked; a click on it opens its source (``onOpen``); the answers the Feed's own row +/// offers in one click answer through the Feed's own path (``FeedAnswerControls``). Past ``limit`` +/// requests the rest are counted, and the count opens the Feed (``onShowAll``). It draws nothing +/// when nothing waits; the host removes it then. It observes the model only while it is drawn, and +/// owns no timer. +public final class FeedNeedsYouView: NSView { + public let model: FeedModel + /// The look; nil follows ``FeedTunables/needsYouStyle``. + public let style: FeedNeedsYouStyle? + /// The most requests listed; the rest are counted. + public let limit: Int + /// A click on a request (not on one of its answers): the host opens its source. + public var onOpen: (FeedItem) -> Void = { _ in } + /// A click on the count of the requests not listed: the host opens the Feed. + public var onShowAll: () -> Void = {} + /// The section's height changed (a request came or went, an answer form opened). + public var onHeightChange: () -> Void = {} + private let appearance = FeedAppearance() + private var hosting: FeedNeedsYouHost? + + public init(model: FeedModel, style: FeedNeedsYouStyle? = nil, limit: Int = 3) { + self.model = model + self.style = style + self.limit = max(1, limit) + super.init(frame: .zero) + wantsLayer = true + setAccessibilityElement(false) + setAccessibilityIdentifier("cmux.home.needsYou") + let root = FeedNeedsYouSection(model: model, style: style, limit: self.limit, appearance: appearance, + open: { [weak self] item in self?.onOpen(item) }, showAll: { [weak self] in self?.onShowAll() }) + let hosting = FeedNeedsYouHost(rootView: root) + hosting.sizingOptions = [.intrinsicContentSize] + hosting.onResize = { [weak self] in self?.onHeightChange() } + self.hosting = hosting + embed(hosting) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// The requests listed, in the Feed's order. + public var shownIDs: [String] { Self.split(model.needsYou, limit: limit).shown.map(\.id) } + /// The requests past ``limit``, counted under the list. + public var moreCount: Int { Self.split(model.needsYou, limit: limit).more } + /// Nothing waits: the section draws nothing. + public var isEmpty: Bool { model.needsYou.isEmpty } + /// The height its content needs. + public var preferredHeight: CGFloat { isEmpty ? 0 : ceil(hosting?.fittingSize.height ?? 0) } + + /// The requests listed and how many more there are. + nonisolated static func split(_ requests: [FeedItem], limit: Int) -> (shown: [FeedItem], more: Int) { + (Array(requests.prefix(limit)), max(0, requests.count - limit)) + } + + /// The answers a request's row offers in place: the ones the Feed row answers with a click. + /// A question or a choice needs typing or picking: its row opens it instead. + nonisolated static func answersInline(_ item: FeedItem) -> Bool { + switch item.prompt { + case .notice, .question, .choice: false + case .approve, .confirm, .signIn, .passkey, .review, .handoff, .file, .input, .custom: true + } + } + + nonisolated public override var wantsUpdateLayer: Bool { true } + + public override func updateLayer() { resolveColors() } + + public override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + resolveColors() + } + + public override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + resolveColors() + } + + private func resolveColors() { + let colors = performWithTheme { FeedColors.resolved(background: Palette.elevatedBackground) } + if appearance.colors != colors { appearance.colors = colors } + } +} + +/// Tells the section when its content resized, so the host lays it out again. +final class FeedNeedsYouHost: NSHostingView { + var onResize: (() -> Void)? + + override func invalidateIntrinsicContentSize() { + super.invalidateIntrinsicContentSize() + onResize?() + } +} + +struct FeedNeedsYouSection: View { + let model: FeedModel + let style: FeedNeedsYouStyle? + let limit: Int + let appearance: FeedAppearance + let open: (FeedItem) -> Void + let showAll: () -> Void + + var body: some View { + let requests = model.needsYou + let colors = appearance.colors + Group { + if !requests.isEmpty { + switch style ?? FeedTunables.needsYouStyle.value { + case .rows: list(requests, colors: colors) { FeedNeedsYouRow(item: $0, model: model, open: open) } + case .cards: list(requests, colors: colors) { item in + FeedRequestCard(item: item, model: model).contentShape(Rectangle()).onTapGesture { open(item) } + } + case .bar: FeedNeedsYouBar(requests: requests, model: model, open: open, showAll: showAll) + } + } + } + .background(card(colors, empty: requests.isEmpty)) + .environment(\.feedColors, colors) + .tint(colors.primary) + } + + /// The title, the listed requests, and the count of the rest. + private func list(_ requests: [FeedItem], colors: FeedColors, row: @escaping (FeedItem) -> Row) -> some View { + let (shown, more) = FeedNeedsYouView.split(requests, limit: limit) + return VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(FeedStrings.needsYou).font(.system(size: TypeScale.caption, weight: .semibold)).foregroundStyle(colors.secondary) + Text(verbatim: "\(requests.count)").font(.system(size: TypeScale.caption)).monospacedDigit().foregroundStyle(colors.tertiary) + Spacer(minLength: 0) + if more > 0 { + Button(FeedStrings.needsYouMore(more), action: showAll) + .buttonStyle(FeedButtonStyle(role: .plain, compact: true)) + .accessibilityIdentifier("cmux.home.needsYou.more") + } + } + .padding(.horizontal, 8).padding(.top, 2).padding(.bottom, 2) + ForEach(shown, id: \.id) { row($0) } + } + .padding(6) + } + + @ViewBuilder + private func card(_ colors: FeedColors, empty: Bool) -> some View { + if !empty { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(colors.elevated) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(colors.borders ? colors.separator : .clear, lineWidth: 1)) + .shadow(color: colors.shadow, radius: 8, y: 2) + } + } +} + +/// One request: what it is and who asked (a click opens its source), then its one-click answers. +struct FeedNeedsYouRow: View { + let item: FeedItem + let model: FeedModel + let open: (FeedItem) -> Void + @Environment(\.feedColors) private var colors + @State private var hovering = false + + var body: some View { + let content = FeedRowContent(item: item, place: model.place?(item)) + HStack(alignment: .center, spacing: 8) { + Button { open(item) } label: { + HStack(spacing: 8) { + FeedGlyph(item: item) + VStack(alignment: .leading, spacing: 1) { + Text(content.headline) + .font(.system(size: TypeScale.body, weight: .medium)) + .foregroundStyle(colors.primary) + .lineLimit(1) + HStack(spacing: 5) { + Text(content.meta.isEmpty ? item.poster.displayLabel : content.meta).lineLimit(1) + Text(verbatim: "·") + Text(FeedRelativeTime.string(item.createdAt, now: model.now)).monospacedDigit() + } + .font(.system(size: 11)) + .foregroundStyle(colors.tertiary) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 6).padding(.vertical, 4) + .background(RoundedRectangle(cornerRadius: 7, style: .continuous).fill(hovering ? colors.hover : .clear)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .accessibilityLabel(content.accessibilityLabel) + .accessibilityIdentifier("cmux.home.needsYou.row") + if FeedNeedsYouView.answersInline(item) { + FeedAnswerControls(item: item, model: model, density: .inline).fixedSize() + } + } + .padding(.trailing, 4) + .opacity(model.isPending(item.id) ? 0.55 : 1) + } +} + +/// Variant `bar`: "Needs you 3 · Allow make build?" on one line; a click opens the only request, +/// or the Feed when there are more. +struct FeedNeedsYouBar: View { + let requests: [FeedItem] + let model: FeedModel + let open: (FeedItem) -> Void + let showAll: () -> Void + @Environment(\.feedColors) private var colors + @State private var hovering = false + + var body: some View { + let first = requests[0] + Button { requests.count == 1 ? open(first) : showAll() } label: { + HStack(spacing: 8) { + FeedGlyph(item: first) + Text(FeedStrings.needsYou).font(.system(size: TypeScale.body, weight: .semibold)).foregroundStyle(colors.primary) + Text(verbatim: "\(requests.count)").font(.system(size: TypeScale.body)).monospacedDigit().foregroundStyle(colors.secondary) + Text(verbatim: "·").foregroundStyle(colors.tertiary) + Text(FeedRowContent.lines(first).headline).lineLimit(1).foregroundStyle(colors.secondary) + Spacer(minLength: 0) + } + .font(.system(size: TypeScale.body)) + .padding(.horizontal, 12).padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(hovering ? colors.hover : .clear)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .accessibilityIdentifier("cmux.home.needsYou.bar") + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+PinnedSection.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+PinnedSection.swift new file mode 100644 index 000000000000..e55ea57130b8 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+PinnedSection.swift @@ -0,0 +1,51 @@ +public import AppKit + +/// A section pinned at the top of the chat, under the header (Chief Home's Needs you): it stays +/// in place while the messages scroll under it. It lies over the transcript, so showing it, +/// hiding it or a change of its height moves no message (no layout shift); the host decides what +/// it shows and when (nil: none). +extension HomeNativeTranscriptView { + /// The pinned section shown now (tests and `debug.home`). + public var pinnedSection: NSView? { pinned?.view } + + /// Shows `view` pinned under the header, measured once now (its fitting height); nil removes it. + public func setPinnedSection(_ view: NSView?) { + guard view !== pinned?.view else { return pinnedSectionDidResize() } + pinned?.view.removeFromSuperview() + pinned = nil + if let view { + // Over the transcript and the first-run panel, under the notice above the field. + addSubview(view, positioned: .below, relativeTo: noticeLabel) + pinned = (view, 0) + pinnedSectionDidResize() + } else { + needsLayout = true + } + } + + /// The pinned section's content changed height (a request came or went): measure it again. + public func pinnedSectionDidResize() { + guard let view = pinned?.view else { return } + let height = ceil(view.fittingSize.height) + guard height != pinned?.height || view.frame.isEmpty else { return } + pinned = (view, height) + needsLayout = true + layoutPinnedSection() + } + + /// Under the header, inset from the sides like the field, no wider than a reading column; never + /// over the message box. + func layoutPinnedSection() { + guard let pinned else { return } + let (view, height) = pinned + let inset: CGFloat = 12 + let width = min(max(0, bounds.width - 2 * inset), Self.pinnedSectionMaxWidth) + let top = transcript.headerHeight + 4 + let shown = min(height, max(0, transcript.fieldTop - top - 8)) + let frame = CGRect(x: ((bounds.width - width) / 2).rounded(), y: top, width: width, height: shown) + if view.frame != frame { view.frame = frame } + } + + /// The pinned section's widest. + static let pinnedSectionMaxWidth: CGFloat = 640 +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView.swift index d45d616fb442..365b7c0de6dc 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView.swift @@ -149,6 +149,8 @@ public final class HomeNativeTranscriptView: NSView { public var onHeaderPanel: (String) -> Void = { _ in } /// The header's top-right panel toggles (HomeNativeTranscriptView+HeaderPanels.swift). let headerPanels = HomeHeaderPanelBar() + /// The section pinned under the header (HomeNativeTranscriptView+PinnedSection.swift) and its measured height. + var pinned: (view: NSView, height: CGFloat)? /// The window's key and occlusion observers; each token removes itself when released (on any thread). private var observers: [MainActorObservation] = [] /// The window's typing hold for the compose field's cursor rects (HomeCursorRectHold). @@ -460,6 +462,7 @@ public final class HomeNativeTranscriptView: NSView { let top = transcript.headerHeight let panel = CGRect(x: 0, y: top, width: bounds.width, height: max(0, transcript.fieldTop - top)) if firstRun.frame != panel { firstRun.frame = panel } + layoutPinnedSection() layoutNotice() } From 05e6a9829414d0c0cc821b34d7429cb82f1f3a68 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 04:35:34 -0800 Subject: [PATCH 3/7] fix(home): the Needs you section's colours don't shadow NSView.appearance Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-528c54a2913c7edf480a --- .../Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift index 82dd2c76b931..95db5ef134bb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift @@ -45,7 +45,7 @@ public final class FeedNeedsYouView: NSView { public var onShowAll: () -> Void = {} /// The section's height changed (a request came or went, an answer form opened). public var onHeightChange: () -> Void = {} - private let appearance = FeedAppearance() + private let palette = FeedAppearance() private var hosting: FeedNeedsYouHost? public init(model: FeedModel, style: FeedNeedsYouStyle? = nil, limit: Int = 3) { @@ -56,7 +56,7 @@ public final class FeedNeedsYouView: NSView { wantsLayer = true setAccessibilityElement(false) setAccessibilityIdentifier("cmux.home.needsYou") - let root = FeedNeedsYouSection(model: model, style: style, limit: self.limit, appearance: appearance, + let root = FeedNeedsYouSection(model: model, style: style, limit: self.limit, appearance: palette, open: { [weak self] item in self?.onOpen(item) }, showAll: { [weak self] in self?.onShowAll() }) let hosting = FeedNeedsYouHost(rootView: root) hosting.sizingOptions = [.intrinsicContentSize] @@ -107,7 +107,7 @@ public final class FeedNeedsYouView: NSView { private func resolveColors() { let colors = performWithTheme { FeedColors.resolved(background: Palette.elevatedBackground) } - if appearance.colors != colors { appearance.colors = colors } + if palette.colors != colors { palette.colors = colors } } } From 7f93b176b5ea758aee6d824b97e214ee47534eb2 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 04:41:18 -0800 Subject: [PATCH 4/7] fix(home): Needs you is measured in layout, keeps the first-run panel clear, wires the Feed's hooks once Review follow-ups at 05e6a98: a height change re-measures in the next layout pass (never inside SwiftUI's update); the empty Chief's first-run panel starts under the card; the cards variant leaves the Feed card's buttons to act; the Feed's open and place hooks install only when the Inbox page has not. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-528c54a2913c7edf480a --- .../Home/HomeHostView+NeedsYou.swift | 5 ++-- .../CmuxNextFeed/Views/FeedNeedsYouView.swift | 5 ++-- ...meNativeTranscriptView+PinnedSection.swift | 27 +++++++++++-------- .../HomeNativeTranscriptView.swift | 6 ++--- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView+NeedsYou.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView+NeedsYou.swift index f683aead4ead..09060eeb6bf4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView+NeedsYou.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView+NeedsYou.swift @@ -17,8 +17,9 @@ enum HomeNeedsYou { static func makeSection(services: AppServices) -> FeedNeedsYouView { let model = services.feed.model // The Feed's own hooks: a request's Sign In / Open answers and its row's who and where. - FeedGitHubActions.install(on: model, services: services) - model.place = FeedPlace.lookup(services.daemon.store.place) + // Once: the Inbox page installs the same hooks when it is made. + if model.onOpenItem == nil { FeedGitHubActions.install(on: model, services: services) } + if model.place == nil { model.place = FeedPlace.lookup(services.daemon.store.place) } let section = FeedNeedsYouView(model: model) section.onOpen = { [weak services] item in if let services { open(item, services: services) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift index 95db5ef134bb..c4c21cf4aba2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift @@ -136,9 +136,8 @@ struct FeedNeedsYouSection: View { if !requests.isEmpty { switch style ?? FeedTunables.needsYouStyle.value { case .rows: list(requests, colors: colors) { FeedNeedsYouRow(item: $0, model: model, open: open) } - case .cards: list(requests, colors: colors) { item in - FeedRequestCard(item: item, model: model).contentShape(Rectangle()).onTapGesture { open(item) } - } + // The Feed's own card: its Open, Sign In and answer buttons act; no tap over them. + case .cards: list(requests, colors: colors) { FeedRequestCard(item: $0, model: model) } case .bar: FeedNeedsYouBar(requests: requests, model: model, open: open, showAll: showAll) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+PinnedSection.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+PinnedSection.swift index e55ea57130b8..dff15a402a78 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+PinnedSection.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+PinnedSection.swift @@ -8,7 +8,7 @@ extension HomeNativeTranscriptView { /// The pinned section shown now (tests and `debug.home`). public var pinnedSection: NSView? { pinned?.view } - /// Shows `view` pinned under the header, measured once now (its fitting height); nil removes it. + /// Shows `view` pinned under the header, measured now (its fitting height); nil removes it. public func setPinnedSection(_ view: NSView?) { guard view !== pinned?.view else { return pinnedSectionDidResize() } pinned?.view.removeFromSuperview() @@ -16,28 +16,33 @@ extension HomeNativeTranscriptView { if let view { // Over the transcript and the first-run panel, under the notice above the field. addSubview(view, positioned: .below, relativeTo: noticeLabel) - pinned = (view, 0) - pinnedSectionDidResize() - } else { - needsLayout = true + pinned = (view, nil) + layoutPinnedSection() } + needsLayout = true } - /// The pinned section's content changed height (a request came or went): measure it again. + /// The pinned section's content changed height (a request came or went): it is measured again + /// in the next layout pass, never inside the content's own update. public func pinnedSectionDidResize() { guard let view = pinned?.view else { return } - let height = ceil(view.fittingSize.height) - guard height != pinned?.height || view.frame.isEmpty else { return } - pinned = (view, height) + pinned = (view, nil) needsLayout = true - layoutPinnedSection() + } + + /// The room the pinned section takes under the header (the first-run panel starts below it). + var pinnedSectionClearance: CGFloat { + guard let pinned, let height = pinned.height, height > 0 else { return 0 } + return pinned.view.frame.maxY - transcript.headerHeight + 8 } /// Under the header, inset from the sides like the field, no wider than a reading column; never /// over the message box. func layoutPinnedSection() { guard let pinned else { return } - let (view, height) = pinned + let view = pinned.view + let height = pinned.height ?? ceil(view.fittingSize.height) + if pinned.height == nil { self.pinned = (view, height) } let inset: CGFloat = 12 let width = min(max(0, bounds.width - 2 * inset), Self.pinnedSectionMaxWidth) let top = transcript.headerHeight + 4 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView.swift index 365b7c0de6dc..2b9ab25015ed 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView.swift @@ -150,7 +150,7 @@ public final class HomeNativeTranscriptView: NSView { /// The header's top-right panel toggles (HomeNativeTranscriptView+HeaderPanels.swift). let headerPanels = HomeHeaderPanelBar() /// The section pinned under the header (HomeNativeTranscriptView+PinnedSection.swift) and its measured height. - var pinned: (view: NSView, height: CGFloat)? + var pinned: (view: NSView, height: CGFloat?)? /// The window's key and occlusion observers; each token removes itself when released (on any thread). private var observers: [MainActorObservation] = [] /// The window's typing hold for the compose field's cursor rects (HomeCursorRectHold). @@ -459,10 +459,10 @@ public final class HomeNativeTranscriptView: NSView { // Every pass: the field can grow (a multi-line draft) without a size // change (`onFieldTopChange` asks for this pass), and the first-run // panel fits what it shows above it, under the header. - let top = transcript.headerHeight + layoutPinnedSection() + let top = transcript.headerHeight + pinnedSectionClearance let panel = CGRect(x: 0, y: top, width: bounds.width, height: max(0, transcript.fieldTop - top)) if firstRun.frame != panel { firstRun.frame = panel } - layoutPinnedSection() layoutNotice() } From cbd8579a2a932a72c72d9e62506fb0b880311820 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 04:51:51 -0800 Subject: [PATCH 5/7] test(home): Needs you follows the Feed's own pinned order, not a hand-written one Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-528c54a2913c7edf480a --- .../Tests/CmuxNextAppTests/HomeNeedsYouTests.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeNeedsYouTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeNeedsYouTests.swift index f21d5e493715..2d40fc7e985a 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeNeedsYouTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeNeedsYouTests.swift @@ -50,11 +50,12 @@ import Testing request("a", ago: 5), notice("n", ago: 0), request("b", ago: 10, priority: .high), request("c", ago: 1, state: .answered), request("d", ago: 1), ]) - // The Feed pins open requests by priority, then newest: b (high), d, a. No notice, no answered one. - #expect(model.listSections.requests.map(\.id) == ["b", "d", "a"]) - #expect(model.needsYou.map(\.id) == model.listSections.requests.map(\.id)) + // The Feed's own pinned order (FeedListKey: priority, then newest); no notice, no answered one. + let pinned = model.listSections.requests.map(\.id) + #expect(Set(pinned) == ["a", "b", "d"]) + #expect(model.needsYou.map(\.id) == pinned) let section = FeedNeedsYouView(model: model) - #expect(section.shownIDs == ["b", "d", "a"]) + #expect(section.shownIDs == pinned) #expect(section.moreCount == 0) #expect(!section.isEmpty) } From f433263b683223f445d0335902b84f003c67842f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 05:01:46 -0800 Subject: [PATCH 6/7] fix(home): Needs you rows use the type scale Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-528c54a2913c7edf480a --- .../Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift index c4c21cf4aba2..960731226b2a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift @@ -195,7 +195,7 @@ struct FeedNeedsYouRow: View { FeedGlyph(item: item) VStack(alignment: .leading, spacing: 1) { Text(content.headline) - .font(.system(size: TypeScale.body, weight: .medium)) + .font(.system(size: TypeScale.body, weight: .semibold)) .foregroundStyle(colors.primary) .lineLimit(1) HStack(spacing: 5) { @@ -203,7 +203,7 @@ struct FeedNeedsYouRow: View { Text(verbatim: "·") Text(FeedRelativeTime.string(item.createdAt, now: model.now)).monospacedDigit() } - .font(.system(size: 11)) + .font(.system(size: TypeScale.caption)) .foregroundStyle(colors.tertiary) } Spacer(minLength: 0) From 4090945768cbe44bddb29e2ca62053f22dd0ef01 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 05:17:01 -0800 Subject: [PATCH 7/7] fix(home): the Needs you section is AppKit The no-SwiftUI gate refuses a new SwiftUI file. The section, its rows and the bar are AppKit now; the Feed's own SwiftUI answer controls and request card are hosted through FeedListView.swift (already allowlisted), as the Feed list hosts its request cards. It follows the model with one withObservationTracking read, re-armed only while in a window. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-528c54a2913c7edf480a --- .../CmuxNextFeed/Views/FeedListView.swift | 30 ++ .../CmuxNextFeed/Views/FeedNeedsYouView.swift | 477 ++++++++++++------ 2 files changed, 356 insertions(+), 151 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedListView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedListView.swift index 6645ab5c550f..299a68476827 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedListView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedListView.swift @@ -124,3 +124,33 @@ final class FeedCardHost: NSHostingView { onResize?() } } + +/// The Feed's request controls for AppKit hosts (Chief Home's Needs you, ``FeedNeedsYouView``): the +/// same SwiftUI `FeedAnswerControls` and `FeedRequestCard` the Feed list hosts, so an answer goes +/// through the Feed's own path. Hosted once per request and updated in place, like the list's cards. +enum FeedAnswerHost { + /// A request's one-click answers (Allow, Deny, Sign In, Approve...). + static func answers(item: FeedItem, model: FeedModel, colors: FeedColors, reusing last: NSView?, + onResize: @escaping () -> Void) -> NSView { + host(AnyView(FeedAnswerControls(item: item, model: model, density: .inline).fixedSize() + .environment(\.feedColors, colors).tint(colors.primary)), reusing: last, onResize: onResize) + } + + /// The Feed list's whole request card. + static func card(item: FeedItem, model: FeedModel, colors: FeedColors, reusing last: NSView?, + onResize: @escaping () -> Void) -> NSView { + host(AnyView(FeedRequestCard(item: item, model: model).environment(\.feedColors, colors).tint(colors.primary)), + reusing: last, onResize: onResize) + } + + private static func host(_ root: AnyView, reusing last: NSView?, onResize: @escaping () -> Void) -> NSView { + if let hosting = last as? FeedCardHost { + hosting.rootView = root + return hosting + } + let hosting = FeedCardHost(rootView: root) + hosting.sizingOptions = [.intrinsicContentSize] + hosting.onResize = onResize + return hosting + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift index 960731226b2a..9e8bd09d0bed 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift @@ -1,6 +1,7 @@ public import AppKit public import CmuxNextDesign -import SwiftUI +import CmuxNextIcons +import Observation /// How the pinned Needs you section draws (`feed.needsYouStyle`, Debug Settings > Feed). Rows is /// the default; the others are its gallery variants, switched live. @@ -27,12 +28,15 @@ extension FeedModel { public var needsYou: [FeedItem] { listSections.requests } } -/// The Feed's open requests as a pinned section (Chief Home's Needs you): each request says what -/// it is and who asked; a click on it opens its source (``onOpen``); the answers the Feed's own row -/// offers in one click answer through the Feed's own path (``FeedAnswerControls``). Past ``limit`` -/// requests the rest are counted, and the count opens the Feed (``onShowAll``). It draws nothing -/// when nothing waits; the host removes it then. It observes the model only while it is drawn, and -/// owns no timer. +/// The Feed's open requests as a pinned section (Chief Home's Needs you), in AppKit: each request +/// says what it is and who asked (``FeedRowContent``, the Feed row's own words); a click on it opens +/// its source (``onOpen``); the answers the Feed's own row offers in one click are the Feed's +/// (``FeedAnswerHost``, its `FeedAnswerControls`), so they answer through ``FeedModel/answer(_:_:)``. +/// Past ``limit`` requests the rest are counted, and the count opens the Feed (``onShowAll``). It +/// draws nothing when nothing waits; the host removes it then. +/// +/// It follows the model with one `withObservationTracking` read of what it shows, re-armed after +/// each change only while it is in a window (stop condition: leaving the window), and owns no timer. public final class FeedNeedsYouView: NSView { public let model: FeedModel /// The look; nil follows ``FeedTunables/needsYouStyle``. @@ -45,8 +49,21 @@ public final class FeedNeedsYouView: NSView { public var onShowAll: () -> Void = {} /// The section's height changed (a request came or went, an answer form opened). public var onHeightChange: () -> Void = {} - private let palette = FeedAppearance() - private var hosting: FeedNeedsYouHost? + + private var colors = FeedColors() + private let title = FeedRowCell.label(size: TypeScale.caption, weight: TypeScale.emphasis) + private let count = FeedRowCell.label(size: TypeScale.caption) + private let more = NSButton(title: "", target: nil, action: nil) + private let bar = FeedNeedsYouRowView() + private var rows: [String: FeedNeedsYouRowView] = [:] + private var order: [String] = [] + private var shownStyle: FeedNeedsYouStyle = .rows + private var height: CGFloat = 0 + private var tracking = false + + static let padding: CGFloat = 6 + static let headerHeight: CGFloat = 22 + static let rowHeight: CGFloat = 40 public init(model: FeedModel, style: FeedNeedsYouStyle? = nil, limit: Int = 3) { self.model = model @@ -54,28 +71,35 @@ public final class FeedNeedsYouView: NSView { self.limit = max(1, limit) super.init(frame: .zero) wantsLayer = true - setAccessibilityElement(false) + layer?.cornerRadius = 12 + layer?.cornerCurve = .continuous + setAccessibilityElement(true) + setAccessibilityRole(.group) setAccessibilityIdentifier("cmux.home.needsYou") - let root = FeedNeedsYouSection(model: model, style: style, limit: self.limit, appearance: palette, - open: { [weak self] item in self?.onOpen(item) }, showAll: { [weak self] in self?.onShowAll() }) - let hosting = FeedNeedsYouHost(rootView: root) - hosting.sizingOptions = [.intrinsicContentSize] - hosting.onResize = { [weak self] in self?.onHeightChange() } - self.hosting = hosting - embed(hosting) + more.isBordered = false + more.target = self + more.action = #selector(showAllClicked) + more.setAccessibilityIdentifier("cmux.home.needsYou.more") + bar.onOpen = { [weak self] id in self?.barClicked(id) } + for view in [title, count, more, bar] { addSubview(view) } + reload() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + public override var isFlipped: Bool { true } + /// The requests listed, in the Feed's order. public var shownIDs: [String] { Self.split(model.needsYou, limit: limit).shown.map(\.id) } - /// The requests past ``limit``, counted under the list. + /// The requests past ``limit``, counted in the header. public var moreCount: Int { Self.split(model.needsYou, limit: limit).more } /// Nothing waits: the section draws nothing. public var isEmpty: Bool { model.needsYou.isEmpty } /// The height its content needs. - public var preferredHeight: CGFloat { isEmpty ? 0 : ceil(hosting?.fittingSize.height ?? 0) } + public var preferredHeight: CGFloat { height } + + public override var intrinsicContentSize: NSSize { NSSize(width: NSView.noIntrinsicMetric, height: height) } /// The requests listed and how many more there are. nonisolated static func split(_ requests: [FeedItem], limit: Int) -> (shown: [FeedItem], more: Int) { @@ -91,168 +115,319 @@ public final class FeedNeedsYouView: NSView { } } - nonisolated public override var wantsUpdateLayer: Bool { true } - - public override func updateLayer() { resolveColors() } + // MARK: - Following the model public override func viewDidMoveToWindow() { super.viewDidMoveToWindow() resolveColors() + if window != nil, !tracking { follow() } } + /// One observation of what the section shows; re-armed after each change while in a window. + private func follow() { + tracking = true + withObservationTracking { + reload() + } onChange: { [weak self] in + // task-owner: one main-actor hop per model change; re-arms only while the view is in a window + Task { @MainActor [weak self] in + guard let self else { return } + tracking = false + if window != nil { follow() } else { reload() } + } + } + } + + /// Builds the header and the rows from the model (rows keep their views by item id). + private func reload() { + let requests = model.needsYou + let style = style ?? FeedTunables.needsYouStyle.value + let (shown, more) = Self.split(requests, limit: limit) + _ = model.now + let ids = style == .bar ? [] : shown.map(\.id) + for (id, row) in rows where !ids.contains(id) || style != shownStyle { + row.removeFromSuperview() + rows[id] = nil + } + shownStyle = style + order = ids + for item in style == .bar ? [] : shown { + let row: FeedNeedsYouRowView + if let kept = rows[item.id] { + row = kept + } else { + row = FeedNeedsYouRowView() + row.onOpen = { [weak self] id in self?.rowClicked(id) } + row.onResize = { [weak self] in self?.relayout() } + addSubview(row) + rows[item.id] = row + } + row.show(item, model: model, colors: colors, style: style) + } + title.stringValue = FeedStrings.needsYou + count.stringValue = "\(requests.count)" + self.more.attributedTitle = NSAttributedString(string: more > 0 ? FeedStrings.needsYouMore(more) : "", attributes: [ + .font: NSFont.systemFont(ofSize: TypeScale.caption, weight: TypeScale.regular), .foregroundColor: colors.basket.secondary, + ]) + self.more.isHidden = more == 0 || style == .bar + title.isHidden = style == .bar || requests.isEmpty + count.isHidden = title.isHidden + bar.isHidden = style != .bar || requests.isEmpty + if style == .bar, let first = requests.first { bar.showBar(first, count: requests.count, colors: colors) } + isHidden = requests.isEmpty + setAccessibilityLabel("\(FeedStrings.needsYou), \(requests.count)") + relayout() + } + + /// Measures the content and lays it out; tells the host when the height changed. + private func relayout() { + let style = shownStyle + var next: CGFloat + if isEmpty { + next = 0 + } else if style == .bar { + next = Self.rowHeight + } else { + next = Self.padding * 2 + Self.headerHeight + for id in order { next += rows[id]?.preferredHeight(width: max(0, bounds.width - 2 * Self.padding)) ?? Self.rowHeight } + } + needsLayout = true + if next != height { + height = next + invalidateIntrinsicContentSize() + onHeightChange() + } + } + + public override func layout() { + super.layout() + let inset = Self.padding, width = max(0, bounds.width - 2 * inset) + if shownStyle == .bar { + bar.frame = NSRect(x: 0, y: 0, width: bounds.width, height: bounds.height) + return + } + let titleWidth = ceil(title.intrinsicContentSize.width) + let lineY = inset + (Self.headerHeight - TypeScale.captionLineHeight) / 2 + title.frame = NSRect(x: inset + 8, y: lineY, width: min(titleWidth, width / 2), height: TypeScale.captionLineHeight) + count.frame = NSRect(x: title.frame.maxX + 6, y: lineY, width: 40, height: TypeScale.captionLineHeight) + let moreWidth = ceil(more.intrinsicContentSize.width) + 8 + more.frame = NSRect(x: bounds.width - inset - moreWidth, y: inset, width: moreWidth, height: Self.headerHeight) + var y = inset + Self.headerHeight + for id in order { + guard let row = rows[id] else { continue } + let h = row.preferredHeight(width: width) + row.frame = NSRect(x: inset, y: y, width: width, height: h) + y += h + } + } + + // MARK: - Look + + nonisolated public override var wantsUpdateLayer: Bool { true } + + public override func updateLayer() { resolveColors() } + public override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() resolveColors() } private func resolveColors() { - let colors = performWithTheme { FeedColors.resolved(background: Palette.elevatedBackground) } - if palette.colors != colors { palette.colors = colors } + let resolved = performWithTheme { FeedColors.resolved(background: Palette.elevatedBackground) } + let (fill, border, shadow) = performWithTheme { (Palette.elevatedBackground, Palette.separator, Palette.shadow) } + layer?.backgroundColor = fill.cgColor + layer?.borderWidth = resolved.borders ? 1 : 0 + layer?.borderColor = border.cgColor + layer?.shadowColor = shadow.cgColor + layer?.shadowOpacity = 1 + layer?.shadowRadius = 8 + layer?.shadowOffset = CGSize(width: 0, height: -2) + title.textColor = resolved.basket.secondary + count.textColor = resolved.basket.tertiary + guard resolved != colors else { return } + colors = resolved + reload() } -} -/// Tells the section when its content resized, so the host lays it out again. -final class FeedNeedsYouHost: NSHostingView { - var onResize: (() -> Void)? + // MARK: - Clicks - override func invalidateIntrinsicContentSize() { - super.invalidateIntrinsicContentSize() - onResize?() + @objc private func showAllClicked() { onShowAll() } + + private func rowClicked(_ id: String) { + if let item = model.item(id) { onOpen(item) } + } + + /// The bar opens its only request, or the Feed when there are more. + private func barClicked(_ id: String) { + let requests = model.needsYou + if requests.count == 1, let item = requests.first { onOpen(item) } else { onShowAll() } } } -struct FeedNeedsYouSection: View { - let model: FeedModel - let style: FeedNeedsYouStyle? - let limit: Int - let appearance: FeedAppearance - let open: (FeedItem) -> Void - let showAll: () -> Void +/// One request: what it is and who asked (a click there opens its source, and only that part +/// lights on hover), then its one-click answers; or, in the cards style, the Feed's request card. +final class FeedNeedsYouRowView: NSView { + var onOpen: (String) -> Void = { _ in } + var onResize: () -> Void = {} + private let opener = FeedNeedsYouOpener() + private let glyph = NSImageView() + private let headline = FeedRowCell.label(size: TypeScale.body, weight: TypeScale.emphasis) + private let meta = FeedRowCell.label(size: TypeScale.caption) + private var answers: NSView? + private var card: NSView? + private var id: String? + private var icon: IconName? - var body: some View { - let requests = model.needsYou - let colors = appearance.colors - Group { - if !requests.isEmpty { - switch style ?? FeedTunables.needsYouStyle.value { - case .rows: list(requests, colors: colors) { FeedNeedsYouRow(item: $0, model: model, open: open) } - // The Feed's own card: its Open, Sign In and answer buttons act; no tap over them. - case .cards: list(requests, colors: colors) { FeedRequestCard(item: $0, model: model) } - case .bar: FeedNeedsYouBar(requests: requests, model: model, open: open, showAll: showAll) - } - } - } - .background(card(colors, empty: requests.isEmpty)) - .environment(\.feedColors, colors) - .tint(colors.primary) + override init(frame: NSRect) { + super.init(frame: frame) + glyph.imageScaling = .scaleProportionallyDown + for view in [glyph, headline, meta] { opener.addSubview(view) } + addSubview(opener) + opener.onClick = { [weak self] in if let id = self?.id { self?.onOpen(id) } } + opener.setAccessibilityIdentifier("cmux.home.needsYou.row") } - /// The title, the listed requests, and the count of the rest. - private func list(_ requests: [FeedItem], colors: FeedColors, row: @escaping (FeedItem) -> Row) -> some View { - let (shown, more) = FeedNeedsYouView.split(requests, limit: limit) - return VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(FeedStrings.needsYou).font(.system(size: TypeScale.caption, weight: .semibold)).foregroundStyle(colors.secondary) - Text(verbatim: "\(requests.count)").font(.system(size: TypeScale.caption)).monospacedDigit().foregroundStyle(colors.tertiary) - Spacer(minLength: 0) - if more > 0 { - Button(FeedStrings.needsYouMore(more), action: showAll) - .buttonStyle(FeedButtonStyle(role: .plain, compact: true)) - .accessibilityIdentifier("cmux.home.needsYou.more") - } - } - .padding(.horizontal, 8).padding(.top, 2).padding(.bottom, 2) - ForEach(shown, id: \.id) { row($0) } + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override var isFlipped: Bool { true } + + func show(_ item: FeedItem, model: FeedModel, colors: FeedColors, style: FeedNeedsYouStyle) { + id = item.id + if style == .cards { + opener.isHidden = true + answers?.removeFromSuperview() + answers = nil + let view = FeedAnswerHost.card(item: item, model: model, colors: colors, reusing: card, onResize: { [weak self] in self?.onResize() }) + if view !== card { card?.removeFromSuperview(); addSubview(view); card = view } + needsLayout = true + return + } + card?.removeFromSuperview() + card = nil + opener.isHidden = false + let content = FeedRowContent(item: item, place: model.place?(item)) + headline.stringValue = content.headline + headline.textColor = colors.basket.primary + let who = content.meta.isEmpty ? item.poster.displayLabel : content.meta + meta.stringValue = "\(who) · \(FeedRelativeTime.string(item.createdAt, now: model.now))" + meta.textColor = colors.basket.tertiary + if icon != content.icon { + icon = content.icon + glyph.image = NSImage.icon(content.icon, size: .iconRowSize(forLabelPointSize: TypeScale.body)) + glyph.image?.isTemplate = true + } + glyph.contentTintColor = content.isUrgent ? colors.basket.attention : colors.basket.primary + opener.hoverFill = colors.basket.hover + opener.setAccessibilityLabel(content.accessibilityLabel) + if FeedNeedsYouView.answersInline(item) { + let view = FeedAnswerHost.answers(item: item, model: model, colors: colors, reusing: answers, + onResize: { [weak self] in self?.needsLayout = true }) + if view !== answers { answers?.removeFromSuperview(); addSubview(view); answers = view } + } else { + answers?.removeFromSuperview() + answers = nil } - .padding(6) + alphaValue = model.isPending(item.id) ? 0.55 : 1 + needsLayout = true } - @ViewBuilder - private func card(_ colors: FeedColors, empty: Bool) -> some View { - if !empty { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(colors.elevated) - .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(colors.borders ? colors.separator : .clear, lineWidth: 1)) - .shadow(color: colors.shadow, radius: 8, y: 2) - } + /// The one-line bar: "Needs you 3 · Allow make build?". + func showBar(_ first: FeedItem, count: Int, colors: FeedColors) { + id = first.id + card?.removeFromSuperview() + card = nil + answers?.removeFromSuperview() + answers = nil + let content = FeedRowContent(item: first, place: nil) + headline.stringValue = "\(FeedStrings.needsYou) \(count)" + headline.textColor = colors.basket.primary + meta.stringValue = content.headline + meta.textColor = colors.basket.secondary + icon = content.icon + glyph.image = NSImage.icon(content.icon, size: .iconRowSize(forLabelPointSize: TypeScale.body)) + glyph.image?.isTemplate = true + glyph.contentTintColor = colors.basket.primary + opener.hoverFill = colors.basket.hover + opener.setAccessibilityLabel("\(FeedStrings.needsYou) \(count): \(content.headline)") + opener.setAccessibilityIdentifier("cmux.home.needsYou.bar") + needsLayout = true } -} -/// One request: what it is and who asked (a click opens its source), then its one-click answers. -struct FeedNeedsYouRow: View { - let item: FeedItem - let model: FeedModel - let open: (FeedItem) -> Void - @Environment(\.feedColors) private var colors - @State private var hovering = false + func preferredHeight(width: CGFloat) -> CGFloat { + guard let card else { return FeedNeedsYouView.rowHeight } + return ceil(card.fittingSize.height) + } - var body: some View { - let content = FeedRowContent(item: item, place: model.place?(item)) - HStack(alignment: .center, spacing: 8) { - Button { open(item) } label: { - HStack(spacing: 8) { - FeedGlyph(item: item) - VStack(alignment: .leading, spacing: 1) { - Text(content.headline) - .font(.system(size: TypeScale.body, weight: .semibold)) - .foregroundStyle(colors.primary) - .lineLimit(1) - HStack(spacing: 5) { - Text(content.meta.isEmpty ? item.poster.displayLabel : content.meta).lineLimit(1) - Text(verbatim: "·") - Text(FeedRelativeTime.string(item.createdAt, now: model.now)).monospacedDigit() - } - .font(.system(size: TypeScale.caption)) - .foregroundStyle(colors.tertiary) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 6).padding(.vertical, 4) - .background(RoundedRectangle(cornerRadius: 7, style: .continuous).fill(hovering ? colors.hover : .clear)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering = $0 } - .accessibilityLabel(content.accessibilityLabel) - .accessibilityIdentifier("cmux.home.needsYou.row") - if FeedNeedsYouView.answersInline(item) { - FeedAnswerControls(item: item, model: model, density: .inline).fixedSize() - } + override func layout() { + super.layout() + if let card { + card.frame = bounds + return + } + let answerSize = answers?.fittingSize ?? .zero + if let answers { + answers.frame = NSRect(x: bounds.width - answerSize.width - 4, y: ((bounds.height - answerSize.height) / 2).rounded(), + width: answerSize.width, height: answerSize.height) } - .padding(.trailing, 4) - .opacity(model.isPending(item.id) ? 0.55 : 1) + let openerWidth = max(0, bounds.width - (answers == nil ? 0 : answerSize.width + 12)) + opener.frame = NSRect(x: 0, y: 2, width: openerWidth, height: bounds.height - 4) + let side = CGFloat.iconRowSize(forLabelPointSize: TypeScale.body) + let textX: CGFloat = 32, textWidth = max(0, openerWidth - textX - 8) + if headline.stringValue.isEmpty == false, meta.stringValue.isEmpty == false { + let top = (opener.bounds.height - TypeScale.bodyLineHeight - TypeScale.captionLineHeight) / 2 + headline.frame = NSRect(x: textX, y: top, width: textWidth, height: TypeScale.bodyLineHeight) + meta.frame = NSRect(x: textX, y: top + TypeScale.bodyLineHeight, width: textWidth, height: TypeScale.captionLineHeight) + } + glyph.frame = NSRect(x: 8, y: (opener.bounds.height - side) / 2, width: side, height: side) } } -/// Variant `bar`: "Needs you 3 · Allow make build?" on one line; a click opens the only request, -/// or the Feed when there are more. -struct FeedNeedsYouBar: View { - let requests: [FeedItem] - let model: FeedModel - let open: (FeedItem) -> Void - let showAll: () -> Void - @Environment(\.feedColors) private var colors - @State private var hovering = false - - var body: some View { - let first = requests[0] - Button { requests.count == 1 ? open(first) : showAll() } label: { - HStack(spacing: 8) { - FeedGlyph(item: first) - Text(FeedStrings.needsYou).font(.system(size: TypeScale.body, weight: .semibold)).foregroundStyle(colors.primary) - Text(verbatim: "\(requests.count)").font(.system(size: TypeScale.body)).monospacedDigit().foregroundStyle(colors.secondary) - Text(verbatim: "·").foregroundStyle(colors.tertiary) - Text(FeedRowContent.lines(first).headline).lineLimit(1).foregroundStyle(colors.secondary) - Spacer(minLength: 0) - } - .font(.system(size: TypeScale.body)) - .padding(.horizontal, 12).padding(.vertical, 8) - .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(hovering ? colors.hover : .clear)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering = $0 } - .accessibilityIdentifier("cmux.home.needsYou.bar") +/// The open part of a request's row: its hover fill covers only itself, and a click opens. +final class FeedNeedsYouOpener: NSView { + var onClick: () -> Void = {} + var hoverFill: NSColor = .clear { didSet { needsDisplay = true } } + private var hovering = false { didSet { if hovering != oldValue { needsDisplay = true } } } + + override init(frame: NSRect) { + super.init(frame: frame) + setAccessibilityElement(true) + setAccessibilityRole(.button) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override var isFlipped: Bool { true } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + for area in trackingAreas { removeTrackingArea(area) } + addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], owner: self)) + } + + override func mouseEntered(with event: NSEvent) { hovering = true } + override func mouseExited(with event: NSEvent) { hovering = false } + + /// Its labels take no clicks: the whole part is one target. + override func hitTest(_ point: NSPoint) -> NSView? { + !isHidden && frame.contains(point) ? self : nil + } + + override func mouseUp(with event: NSEvent) { + if bounds.contains(convert(event.locationInWindow, from: nil)) { onClick() } + } + + override func mouseDown(with event: NSEvent) {} + + override func accessibilityPerformPress() -> Bool { + onClick() + return true + } + + override func draw(_ dirtyRect: NSRect) { + guard hovering else { return } + hoverFill.setFill() + NSBezierPath(roundedRect: bounds, xRadius: 7, yRadius: 7).fill() } }