From 823df7c4e1860d4b4fb9c646e0f701f1536c6d7f Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Tue, 7 Apr 2026 15:23:07 -0500 Subject: [PATCH] Add Attention sidebar section for sessions needing user input (#110) Sessions with a pending permission prompt, question, or completed task now bubble into a top-level "Attention" section at the top of the sidebar with an orange badge count. They are removed from their normal group while in the attention state and return automatically when resolved. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CrowCore/Sources/CrowCore/AppState.swift | 58 +++++++++++++++++-- .../Sources/CrowUI/SessionListView.swift | 44 +++++++++++--- 2 files changed, 89 insertions(+), 13 deletions(-) diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index 76a5b82..6fe7a5d 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -189,12 +189,56 @@ public final class AppState { return sessions.first { $0.id == selectedSessionID } } + // MARK: - Attention Section + + /// Sessions that need user attention (permission prompt, question, or task done). + /// Sorted by longest-waiting first. + public var attentionSessions: [Session] { + sessions + .filter { session in + guard session.id != Self.managerSessionID else { return false } + guard session.status != .completed && session.status != .archived else { return false } + let state = hookState(for: session.id) + return state.pendingNotification != nil || state.claudeState == .done + } + .sorted { a, b in + attentionTimestamp(for: a.id) < attentionTimestamp(for: b.id) + } + } + + /// IDs of sessions currently in the Attention section, for filtering from normal groups. + public var attentionSessionIDs: Set { + Set(attentionSessions.map(\.id)) + } + + /// Timestamp when a session entered its attention-worthy state. + private func attentionTimestamp(for sessionID: UUID) -> Date { + let state = hookState(for: sessionID) + if let notification = state.pendingNotification { + return notification.timestamp + } + if let lastEvent = state.hookEvents.last { + return lastEvent.timestamp + } + return .distantFuture + } + + // MARK: - Session Groups + public var activeSessions: [Session] { - sessions.filter { $0.status == .active && $0.id != Self.managerSessionID && $0.kind == .work } + let attentionIDs = attentionSessionIDs + return sessions.filter { + $0.status == .active && $0.id != Self.managerSessionID && $0.kind == .work + && !attentionIDs.contains($0.id) + } } public var inReviewSessions: [Session] { - sessions.filter { $0.status == .inReview && $0.id != Self.managerSessionID } + let attentionIDs = attentionSessionIDs + return sessions.filter { + $0.status == .inReview && $0.id != Self.managerSessionID + && !attentionIDs.contains($0.id) + } } public var completedSessions: [Session] { @@ -202,7 +246,11 @@ public final class AppState { } public var reviewSessions: [Session] { - sessions.filter { $0.kind == .review && $0.status != .completed && $0.status != .archived } + let attentionIDs = attentionSessionIDs + return sessions.filter { + $0.kind == .review && $0.status != .completed && $0.status != .archived + && !attentionIDs.contains($0.id) + } } public func worktrees(for sessionID: UUID) -> [SessionWorktree] { @@ -275,8 +323,10 @@ public final class AppState { } /// Find the active session linked to a given issue (by matching ticket URL). + /// Checks all sessions (not just the filtered `activeSessions` group) so that + /// sessions temporarily in the Attention section are still found. public func activeSession(for issue: AssignedIssue) -> Session? { - activeSessions.first { $0.ticketURL == issue.url } + sessions.first { $0.status == .active && $0.ticketURL == issue.url } } /// Maps `.unknown` project status to `.backlog` for display purposes. diff --git a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift index 324bbca..5cdac9e 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift @@ -36,6 +36,20 @@ public struct SessionListView: View { .listRowBackground(Color.clear) } + // Attention section (sessions needing user input) + if !appState.attentionSessions.isEmpty { + SectionDivider(title: "Attention", count: appState.attentionSessions.count) + ForEach(filteredSessions(appState.attentionSessions)) { session in + SessionRow(session: session, appState: appState) + .tag(session.id) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .contextMenu { + sessionContextMenu(session) + } + } + } + // Active sessions if !appState.activeSessions.isEmpty { SectionDivider(title: "Active") @@ -170,16 +184,27 @@ struct SidebarBrandmark: View { /// Uppercase section label used to group sessions in the sidebar. struct SectionDivider: View { let title: String + var count: Int? = nil var body: some View { - Text(title) - .font(.system(size: 10, weight: .bold)) - .tracking(1.5) - .textCase(.uppercase) - .foregroundStyle(CorveilTheme.goldDark) - .padding(.top, 10) - .padding(.bottom, 2) - .listRowSeparator(.hidden) + HStack(spacing: 6) { + Text(title) + .font(.system(size: 10, weight: .bold)) + .tracking(1.5) + .textCase(.uppercase) + .foregroundStyle(CorveilTheme.goldDark) + if let count, count > 0 { + Text("\(count)") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(.orange)) + } + } + .padding(.top, 10) + .padding(.bottom, 2) + .listRowSeparator(.hidden) } } @@ -448,7 +473,8 @@ struct SessionRow: View { } private var needsAttention: Bool { - appState.hookState(for: session.id).pendingNotification != nil + let state = appState.hookState(for: session.id) + return state.pendingNotification != nil || state.claudeState == .done } private var rowBackgroundColor: Color {