Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
/// A Chief's pinned Needs you section follows the Feed's open requests (HomeHostView+NeedsYou.swift).
var needsYouWatch: Task<Void, Never>?
private var engineWatch: Task<Void, Never>?
/// Each Chief's avatar beside this conversation's group bubbles, as its Home row draws it.
private var avatarsWatch: Task<Void, Never>?
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FeedNeedsYouStyle>.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]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,33 @@ final class FeedCardHost: NSHostingView<AnyView> {
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
}
}
Loading
Loading