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 3f5a92b..68fd65c 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 {