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..09060eeb6bf4 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView+NeedsYou.swift @@ -0,0 +1,84 @@ +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. + // 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) } + } + 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/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 new file mode 100644 index 000000000000..9e8bd09d0bed --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/Views/FeedNeedsYouView.swift @@ -0,0 +1,433 @@ +public import AppKit +public import CmuxNextDesign +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. +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), 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``. + 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 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 + self.style = style + self.limit = max(1, limit) + super.init(frame: .zero) + wantsLayer = true + layer?.cornerRadius = 12 + layer?.cornerCurve = .continuous + setAccessibilityElement(true) + setAccessibilityRole(.group) + setAccessibilityIdentifier("cmux.home.needsYou") + 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 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 { 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) { + (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 + } + } + + // 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 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() + } + + // MARK: - Clicks + + @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() } + } +} + +/// 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? + + 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") + } + + @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 + } + alphaValue = model.isPending(item.id) ? 0.55 : 1 + needsLayout = true + } + + /// 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 + } + + func preferredHeight(width: CGFloat) -> CGFloat { + guard let card else { return FeedNeedsYouView.rowHeight } + return ceil(card.fittingSize.height) + } + + 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) + } + 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) + } +} + +/// 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() + } +} 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..dff15a402a78 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+PinnedSection.swift @@ -0,0 +1,56 @@ +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 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, nil) + layoutPinnedSection() + } + needsLayout = true + } + + /// 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 } + pinned = (view, nil) + needsLayout = true + } + + /// 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 = 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 + 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..2b9ab25015ed 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). @@ -457,7 +459,8 @@ 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 } layoutNotice() diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeNeedsYouTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeNeedsYouTests.swift new file mode 100644 index 000000000000..2d40fc7e985a --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeNeedsYouTests.swift @@ -0,0 +1,127 @@ +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'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 == pinned) + #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) + } +}