native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630import ComposableArchitectureimport Observationimport Sharingimport SwiftUI
@MainActor@Observablefinal class AgentIslandPresentationModel { var notchSize: CGSize? var floatingCompactWidth = AgentIslandRootLayout.floatingCompactWidth var floatingMenuBarHeight: CGFloat? var floatingBarScreenFrame: CGRect?}
enum AgentIslandFloatingDragEvent { case began(pointerX: CGFloat) case changed(pointerX: CGFloat) case ended(pointerX: CGFloat)}
struct AgentIslandRootLayout { static let floatingCompactWidth: CGFloat = 380 static let fallbackFloatingCompactHeight: CGFloat = 40 /// The notched roster matches the bar above it, but keeps the two-column callout width so its /// rows stay readable under a narrow cutout. static let minimumNotchedRosterWidth: CGFloat = 380
static func width( notchCompactWidth: CGFloat?, isRosterExpanded: Bool, attentionEntryCount: Int, floatingCompactWidth: CGFloat = AgentIslandRootLayout.floatingCompactWidth ) -> CGFloat { guard let compactWidth = notchCompactWidth else { return floatingCompactWidth } if isRosterExpanded { return max(compactWidth, minimumNotchedRosterWidth) } guard attentionEntryCount > 0 else { return compactWidth } let attentionWidth = AgentIslandAttentionLayout.layout(entryCount: attentionEntryCount).width return max(compactWidth, attentionWidth) }
static func floatingWidth(screens: [AgentIslandScreenDescriptor]) -> CGFloat { guard let cutout = screens.first(where: { $0.isBuiltIn && $0.hasNotch })?.notchFrame else { return floatingCompactWidth } return max( AgentIslandNotchLayout(cutoutSize: cutout.size).compactWidth, minimumNotchedRosterWidth ) }
static func compactHeight( notchCompactHeight: CGFloat?, floatingMenuBarHeight: CGFloat? ) -> CGFloat { if let notchCompactHeight { return notchCompactHeight } guard let floatingMenuBarHeight, floatingMenuBarHeight > 0 else { return fallbackFloatingCompactHeight } return floatingMenuBarHeight }
static func floatingBarScreenFrame(in panelFrame: CGRect, height: CGFloat) -> CGRect { CGRect( x: panelFrame.minX, y: panelFrame.maxY - height, width: panelFrame.width, height: height ) }
static func showsDisplayControl(connectedDisplayCount: Int) -> Bool { connectedDisplayCount > 1 }
static func usesCompactFloatingSummary(stateCount: Int) -> Bool { stateCount >= AgentIslandStateSummary.order.count }}
// Carry the hover animation through the island's nonanimated layout transaction. Only the// grip opacity and summary offset opt in; roster updates never create their own animation.enum AgentIslandHoverAnimation: TransactionKey { static let defaultValue: Animation? = nil
static func perform(reduceMotion: Bool, _ update: () -> Void) { var transaction = Transaction() transaction[Self.self] = reduceMotion ? nil : .easeOut(duration: 0.18) withTransaction(transaction, update) }
static func apply(to transaction: inout Transaction) { transaction.animation = transaction[Self.self] }}
struct AgentIslandBarHoverState { private(set) var isHovered = false
mutating func update(reportedHover: Bool, pointerLocation: CGPoint, barFrame: CGRect?) -> Bool { // Resizing or reordering the panel can emit exit/entry callbacks without pointer movement. let hovered = barFrame.map { $0.contains(pointerLocation) } ?? reportedHover guard hovered != isHovered else { return false } isHovered = hovered return true }}
struct AgentIslandView: View { @Bindable private var appStore: StoreOf<AppFeature> @Bindable private var agentsStore: StoreOf<ActiveAgentsFeature> @Bindable private var presentation: AgentIslandPresentationModel private let terminalManager: WorktreeTerminalManager @Shared(.repositoryAppearances) private var repositoryAppearances @State private var displayCatalog = AgentIslandDisplayCatalog.shared
let presentationChanged: (Bool, Bool, AgentIslandDisplayPreference, CGSize) -> Void let floatingDragChanged: (AgentIslandFloatingDragEvent) -> Void @State private var contentSize = CGSize(width: 420, height: 40) @State private var isHovering = false @State private var isSilent = false @State private var barHover = AgentIslandBarHoverState() @State private var isFloatingDragging = false @Environment(\.accessibilityReduceMotion) private var reduceMotion
init( store: StoreOf<AppFeature>, terminalManager: WorktreeTerminalManager, presentation: AgentIslandPresentationModel, presentationChanged: @escaping (Bool, Bool, AgentIslandDisplayPreference, CGSize) -> Void, floatingDragChanged: @escaping (AgentIslandFloatingDragEvent) -> Void ) { appStore = store agentsStore = store.scope( state: \.repositories.activeAgents, action: \.repositories.activeAgents ) self.terminalManager = terminalManager self.presentation = presentation self.presentationChanged = presentationChanged self.floatingDragChanged = floatingDragChanged }
var body: some View { Group { if isVisible { islandContent } else { Color.clear .frame(width: 1, height: 1) } } .fixedSize(horizontal: false, vertical: true) .onGeometryChange(for: CGSize.self) { proxy in proxy.size } action: { newSize in guard isVisible else { return } contentSize = newSize publishPresentation(size: newSize) } .onChange(of: isVisible, initial: true) { _, _ in publishPresentation() } .onChange(of: agentsStore.isIslandRosterExpanded, initial: true) { _, _ in publishPresentation() } .onChange(of: appStore.settings.agentIslandDisplayPreference, initial: true) { _, _ in publishPresentation() } // The panel is resized to fit after SwiftUI has laid out the new content. Without an explicit // top alignment the hosting view centers the content vertically, so for that one pass a taller // or shorter island is offset from the top edge and every animated child springs back into // place once the frame catches up. Pinning to the top keeps the compact bar at y = 0 throughout. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .preferredColorScheme(.dark) }
private var islandContent: some View { VStack(spacing: 6) { compactIsland if agentsStore.isIslandRosterExpanded { rosterIsland } else if !agentsStore.islandAttentionEntries.isEmpty { AgentIslandAttentionCollection( entries: agentsStore.islandAttentionEntries, rowDisplays: rowDisplays, workflowBadges: appStore.repositories.workflowRoleBadgesBySurfaceID, showTabTitles: appStore.repositories.showActiveAgentTabTitles, onTap: { agentsStore.send(.island(.entryTapped($0))) } ) } } .frame(width: rootWidth) // Keep the opacity animation outside a transaction-free content subtree. Otherwise, when // `isSilent` clears in the same update that opens the roster, SwiftUI also animates the // roster's layout from the compact panel and its footer briefly crosses the floating bar. .transaction { transaction in transaction.animation = nil } .opacity( AgentIslandOpacityPolicy.opacity( isFloating: isFloating, isSilent: isSilent, isRosterExpanded: agentsStore.isIslandRosterExpanded, hasAttentionEntries: !agentsStore.islandAttentionEntries.isEmpty, silentOpacity: appStore.settings.agentIslandSilentOpacity ) ) .animation(.easeOut(duration: 0.2), value: isSilent) .onHover { isHovering = $0 } .task(id: shouldEnterSilentState) { guard shouldEnterSilentState else { isSilent = false return } do { try await Task.sleep(for: AgentIslandOpacityPolicy.silenceDelay) } catch { return } guard !Task.isCancelled else { return } isSilent = true } }
private var compactIsland: some View { Button { agentsStore.send(.islandToggleRoster) } label: { Group { if let notchLayout { notchedCompactContent(layout: notchLayout) } else { HStack(spacing: 9) { compactContent compactChevron } .padding(.horizontal, 14) .frame(width: presentation.floatingCompactWidth, height: compactHeight) } } .contentShape(.rect) } .buttonStyle(.plain) .background(.black, in: compactShape) .overlay(alignment: .leading) { if isFloating { floatingDragHandle .padding(.leading, 14) .opacity(showsFloatingGrip ? 1 : 0) .transaction(AgentIslandHoverAnimation.apply) .allowsHitTesting(showsFloatingGrip) } } .onHover { hovering in var nextHover = barHover guard nextHover.update( reportedHover: hovering, pointerLocation: NSEvent.mouseLocation, barFrame: presentation.floatingBarScreenFrame ) else { return } AgentIslandHoverAnimation.perform(reduceMotion: reduceMotion) { barHover = nextHover } } .accessibilityLabel( agentsStore.isIslandRosterExpanded ? "Hide Active Agents" : "Show Active Agents" ) .accessibilityIdentifier("agent-island-compact") }
private var floatingDragHandle: some View { ZStack { Image(systemName: "line.3.horizontal") .font(.caption2.weight(.semibold)) .foregroundStyle(.secondary) AgentIslandDragCaptureView { event in switch event { case .began: isFloatingDragging = true case .ended: isFloatingDragging = false case .changed: break } floatingDragChanged(event) } } .frame(width: 24, height: 20) .accessibilityHidden(true) }
private var showsFloatingGrip: Bool { barHover.isHovered || isFloatingDragging }
private func notchedCompactContent(layout: AgentIslandNotchLayout) -> some View { HStack(spacing: 0) { notchedLeadingContent .padding(.leading, AgentIslandNotchLayout.outerInset) .padding(.trailing, AgentIslandNotchLayout.innerInset) .frame(width: layout.wingWidth, alignment: .leading) Color.clear .frame(width: layout.cutoutSize.width) .accessibilityHidden(true) notchedTrailingContent .padding(.leading, AgentIslandNotchLayout.innerInset) .padding(.trailing, AgentIslandNotchLayout.outerInset) .frame(width: layout.wingWidth, alignment: .trailing) } .frame(width: layout.compactWidth, height: layout.compactHeight) }
private var notchedLeadingContent: some View { compactStateSummary(size: .compact) }
private var notchedTrailingContent: some View { HStack(spacing: 0) { AgentIslandIconCluster(entries: islandEntries, pointSize: AgentIslandNotchLayout.iconPointSize) compactChevron .frame(width: AgentIslandNotchLayout.chevronSlotWidth, alignment: .trailing) } }
private var compactChevron: some View { Image(systemName: agentsStore.isIslandRosterExpanded ? "chevron.up" : "chevron.down") .font(.caption2.weight(.bold)) .foregroundStyle(.secondary) .accessibilityHidden(true) }
/// The floating pill shows the same per-state counts as the notched wing, one size up. private var compactContent: some View { HStack(spacing: 0) { compactStateSummary(size: floatingSummarySize) .offset(x: showsFloatingGrip ? 28 : 0) .transaction(AgentIslandHoverAnimation.apply) .frame(maxWidth: .infinity, alignment: .leading) AgentIslandIconCluster(entries: islandEntries) } }
@ViewBuilder private func compactStateSummary(size: AgentIslandStateSummaryView.Size) -> some View { if islandEntries.isEmpty { Image("AgentIslandAppIcon") .interpolation(.high) .frame(width: 22, height: 22) .clipShape(.rect(cornerRadius: 5)) .accessibilityLabel("Prowl") } else { AgentIslandStateSummaryView(summary: stateSummary, size: size) } }
private var floatingSummarySize: AgentIslandStateSummaryView.Size { AgentIslandRootLayout.usesCompactFloatingSummary(stateCount: stateSummary.items.count) ? .compact : .regular }
private var stateSummary: AgentIslandStateSummary { AgentIslandStateSummary(entries: islandEntries) }
/// Same source as the sidebar overlay: the terminal manager's active surface for the selected /// worktree. The reducer's `focusedSurfaceID` is a keyboard-navigation anchor fed by /// per-worktree deduplicated `focusChanged` events, so re-selecting a worktree leaves it /// pointing at the previously selected worktree's pane. private var selectedSurfaceID: UUID? { terminalManager.selectedWorktreeID.flatMap { worktreeID in terminalManager.stateIfExists(for: worktreeID)?.activeSurfaceID } }
private var rosterIsland: some View { VStack(spacing: 0) { HStack { Text("Active Agents") .font(.headline) Spacer() Button { agentsStore.send(.islandOpenProwlTapped) } label: { Label("Open Prowl", systemImage: "arrow.up.forward.app") } .buttonStyle(.borderless) .accessibilityIdentifier("agent-island-open-prowl") } .padding(.horizontal, 14) .frame(height: 44) .overlay { if AgentIslandRootLayout.showsDisplayControl( connectedDisplayCount: displayCatalog.screens.count ) { displayMenu } }
Divider()
AgentIslandRosterContent( store: agentsStore, rowDisplays: rowDisplays, workflowBadges: appStore.repositories.workflowRoleBadgesBySurfaceID, selectedSurfaceID: selectedSurfaceID ) } .frame(width: rootWidth) .background(.black, in: RoundedRectangle(cornerRadius: 12)) .overlay { RoundedRectangle(cornerRadius: 12) .stroke(.separator.opacity(0.55), lineWidth: 1) } .accessibilityIdentifier("agent-island-roster") }
private var rowDisplays: [ActiveAgentEntry.ID: ActiveAgentRowDisplay] { let repositories = appStore.repositories.repositories let metadata = SidebarListView.activeAgentWorktreeMetadata( repositories: repositories, customTitles: appStore.repositories.repositoryCustomTitles, repositoryAppearances: repositoryAppearances ) return SidebarListView.activeAgentRowDisplays( entries: agentsStore.entries, repositories: repositories, metadata: metadata ) }
private var islandEntries: [ActiveAgentEntry] { Array(agentsStore.entries) }
private var compactShape: AnyShape { AnyShape( UnevenRoundedRectangle( topLeadingRadius: 0, bottomLeadingRadius: 12, bottomTrailingRadius: 12, topTrailingRadius: 0 ) ) }
private var displayMenu: some View { Menu { Button { setDisplayPreference(.automatic) } label: { displayMenuLabel( String(localized: "Automatic"), isSelected: appStore.settings.agentIslandDisplayPreference == .automatic) } Divider() ForEach(displayCatalog.screens) { screen in Button { setDisplayPreference(.display(id: screen.id, name: screen.name)) } label: { displayMenuLabel(screen.name, isSelected: isSelectedDisplay(screen.id)) } } } label: { Image(systemName: "display.2") .frame(width: 24, height: 24) } .menuStyle(.borderlessButton) .fixedSize() .accessibilityLabel("Agent Island display") }
@ViewBuilder private func displayMenuLabel(_ title: String, isSelected: Bool) -> some View { if isSelected { Label(title, systemImage: "checkmark") } else { Text(title) } }
private func isSelectedDisplay(_ id: String) -> Bool { guard case .display(let selectedID, _) = appStore.settings.agentIslandDisplayPreference else { return false } return selectedID == id }
private func setDisplayPreference(_ preference: AgentIslandDisplayPreference) { appStore.send(.settings(.setAgentIslandDisplayPreference(preference))) }
private var notchLayout: AgentIslandNotchLayout? { presentation.notchSize.map { AgentIslandNotchLayout(cutoutSize: $0) } }
private var compactHeight: CGFloat { AgentIslandRootLayout.compactHeight( notchCompactHeight: notchLayout?.compactHeight, floatingMenuBarHeight: presentation.floatingMenuBarHeight ) }
private var isFloating: Bool { notchLayout == nil }
private var shouldEnterSilentState: Bool { AgentIslandOpacityPolicy.shouldEnterSilentState( isFloating: isFloating, isRosterExpanded: agentsStore.isIslandRosterExpanded, hasAttentionEntries: !agentsStore.islandAttentionEntries.isEmpty, isHovering: isHovering, isControlPresented: isFloatingDragging ) }
private var rootWidth: CGFloat { AgentIslandRootLayout.width( notchCompactWidth: notchLayout?.compactWidth, isRosterExpanded: agentsStore.isIslandRosterExpanded, attentionEntryCount: agentsStore.islandAttentionEntries.count, floatingCompactWidth: presentation.floatingCompactWidth ) }
private var isVisible: Bool { AgentIslandVisibilityPolicy.isVisible( isEnabled: appStore.settings.agentIslandEnabled, onlyShowWithAgents: appStore.settings.agentIslandOnlyShowWithAgents, hasEntries: !agentsStore.entries.isEmpty ) }
private func publishPresentation(size: CGSize? = nil) { presentationChanged( isVisible, agentsStore.isIslandRosterExpanded, appStore.settings.agentIslandDisplayPreference, size ?? contentSize ) }}
private struct AgentIslandDragCaptureView: NSViewRepresentable { let dragChanged: (AgentIslandFloatingDragEvent) -> Void
func makeNSView(context: Context) -> AgentIslandDragCaptureNSView { AgentIslandDragCaptureNSView(dragChanged: dragChanged) }
func updateNSView(_ nsView: AgentIslandDragCaptureNSView, context: Context) { nsView.dragChanged = dragChanged }}
private final class AgentIslandDragCaptureNSView: NSView { var dragChanged: (AgentIslandFloatingDragEvent) -> Void private var hoverTrackingArea: NSTrackingArea? private var isDragging = false
init(dragChanged: @escaping (AgentIslandFloatingDragEvent) -> Void) { self.dragChanged = dragChanged super.init(frame: .zero) }
@available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func updateTrackingAreas() { if let hoverTrackingArea { removeTrackingArea(hoverTrackingArea) } super.updateTrackingAreas()
let hoverTrackingArea = NSTrackingArea( rect: .zero, options: [.mouseEnteredAndExited, .cursorUpdate, .activeAlways, .inVisibleRect], owner: self, userInfo: nil ) addTrackingArea(hoverTrackingArea) self.hoverTrackingArea = hoverTrackingArea }
override func mouseEntered(with event: NSEvent) { currentDragCursor.set() }
override func mouseExited(with event: NSEvent) { if !isDragging { NSCursor.arrow.set() } }
override func cursorUpdate(with event: NSEvent) { currentDragCursor.set() }
override func mouseDown(with event: NSEvent) { isDragging = true window?.invalidateCursorRects(for: self) currentDragCursor.set() dragChanged(.began(pointerX: NSEvent.mouseLocation.x)) }
override func mouseDragged(with event: NSEvent) { currentDragCursor.set() dragChanged(.changed(pointerX: NSEvent.mouseLocation.x)) }
override func mouseUp(with event: NSEvent) { isDragging = false window?.invalidateCursorRects(for: self) let pointer = convert(event.locationInWindow, from: nil) (bounds.contains(pointer) ? NSCursor.openHand : .arrow).set() dragChanged(.ended(pointerX: NSEvent.mouseLocation.x)) }
override func resetCursorRects() { addCursorRect(bounds, cursor: currentDragCursor) }
private var currentDragCursor: NSCursor { isDragging ? .closedHand : .openHand }}