diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index 4fdd49b..f7b665e 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -402,6 +402,30 @@ public final class AppState { activeSessions.first { $0.ticketURL == issue.url } } + /// Find the assigned issue linked to a given session (by matching ticket URL). + public func assignedIssue(for session: Session) -> AssignedIssue? { + guard let url = session.ticketURL else { return nil } + return assignedIssues.first { $0.url == url } + } + + /// Find the review request linked to a given session (by matching PR link URL). + public func reviewRequest(for session: Session) -> ReviewRequest? { + guard session.kind == .review else { return nil } + guard let prLink = links(for: session.id).first(where: { $0.linkType == .pr }) else { return nil } + return reviewRequests.first { $0.url == prLink.url } + } + + /// Labels for a session, sourced from its linked AssignedIssue or ReviewRequest. + public func labels(forSession session: Session) -> [String] { + if let issue = assignedIssue(for: session) { + return issue.labels + } + if let review = reviewRequest(for: session) { + return review.labels + } + return [] + } + /// Maps `.unknown` project status to `.backlog` for display purposes. private func effectiveStatus(_ issue: AssignedIssue) -> TicketStatus { issue.projectStatus == .unknown ? .backlog : issue.projectStatus diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AppStateLookupTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppStateLookupTests.swift new file mode 100644 index 0000000..ed0bf6c --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppStateLookupTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +@testable import CrowCore + +// MARK: - AppState Lookup Tests + +@MainActor @Test func assignedIssueForSessionMatchesByTicketURL() { + let appState = AppState() + let issue = AssignedIssue( + id: "github:org/repo#42", number: 42, title: "Bug fix", + state: "open", url: "https://github.com/org/repo/issues/42", + repo: "org/repo", labels: ["bug", "priority"], provider: .github + ) + appState.assignedIssues = [issue] + let session = Session(name: "fix-bug", ticketURL: "https://github.com/org/repo/issues/42") + let result = appState.assignedIssue(for: session) + #expect(result?.id == "github:org/repo#42") + #expect(result?.labels == ["bug", "priority"]) +} + +@MainActor @Test func assignedIssueForSessionReturnsNilWithoutTicketURL() { + let appState = AppState() + appState.assignedIssues = [ + AssignedIssue( + id: "github:org/repo#1", number: 1, title: "T", + state: "open", url: "https://github.com/org/repo/issues/1", + repo: "org/repo", provider: .github + ) + ] + let session = Session(name: "no-ticket") + #expect(appState.assignedIssue(for: session) == nil) +} + +@MainActor @Test func assignedIssueForSessionReturnsNilWhenNoMatch() { + let appState = AppState() + appState.assignedIssues = [ + AssignedIssue( + id: "github:org/repo#1", number: 1, title: "T", + state: "open", url: "https://github.com/org/repo/issues/1", + repo: "org/repo", provider: .github + ) + ] + let session = Session(name: "other", ticketURL: "https://github.com/org/repo/issues/99") + #expect(appState.assignedIssue(for: session) == nil) +} + +@MainActor @Test func reviewRequestForSessionMatchesByPRLink() { + let appState = AppState() + let session = Session(name: "review-repo-123", kind: .review) + let prLink = SessionLink( + sessionID: session.id, label: "PR #123", + url: "https://github.com/org/repo/pull/123", linkType: .pr + ) + appState.links[session.id] = [prLink] + let request = ReviewRequest( + id: "github:org/repo#123", prNumber: 123, title: "Fix", + url: "https://github.com/org/repo/pull/123", + repo: "org/repo", author: "alice", headBranch: "fix-branch", + baseBranch: "main", labels: ["bug", "urgent"] + ) + appState.reviewRequests = [request] + let result = appState.reviewRequest(for: session) + #expect(result?.labels == ["bug", "urgent"]) +} + +@MainActor @Test func reviewRequestForSessionReturnsNilForWorkSessions() { + let appState = AppState() + let session = Session(name: "work-session", kind: .work) + #expect(appState.reviewRequest(for: session) == nil) +} + +@MainActor @Test func labelsForSessionReturnsIssueLabels() { + let appState = AppState() + let issue = AssignedIssue( + id: "github:org/repo#1", number: 1, title: "T", + state: "open", url: "https://github.com/org/repo/issues/1", + repo: "org/repo", labels: ["enhancement", "ui"], provider: .github + ) + appState.assignedIssues = [issue] + let session = Session(name: "s", ticketURL: "https://github.com/org/repo/issues/1") + #expect(appState.labels(forSession: session) == ["enhancement", "ui"]) +} + +@MainActor @Test func labelsForSessionReturnsReviewLabels() { + let appState = AppState() + let session = Session(name: "review-s", kind: .review) + let prLink = SessionLink( + sessionID: session.id, label: "PR #5", + url: "https://github.com/org/repo/pull/5", linkType: .pr + ) + appState.links[session.id] = [prLink] + let request = ReviewRequest( + id: "github:org/repo#5", prNumber: 5, title: "PR", + url: "https://github.com/org/repo/pull/5", + repo: "org/repo", author: "bob", headBranch: "feat", + baseBranch: "main", labels: ["needs-review"] + ) + appState.reviewRequests = [request] + #expect(appState.labels(forSession: session) == ["needs-review"]) +} + +@MainActor @Test func labelsForSessionReturnsEmptyWhenNoMatch() { + let appState = AppState() + let session = Session(name: "s") + #expect(appState.labels(forSession: session).isEmpty) +} diff --git a/Packages/CrowUI/Sources/CrowUI/CorveilTheme.swift b/Packages/CrowUI/Sources/CrowUI/CorveilTheme.swift index d2f0e73..5b77ad5 100644 --- a/Packages/CrowUI/Sources/CrowUI/CorveilTheme.swift +++ b/Packages/CrowUI/Sources/CrowUI/CorveilTheme.swift @@ -172,3 +172,38 @@ public struct CapsuleBadge: View { .clipShape(Capsule()) } } + +// MARK: - Label Pills + +/// Reusable horizontal row of label capsules with overflow count. +public struct LabelPillsView: View { + let labels: [String] + var maxVisible: Int + var muted: Bool + + public init(labels: [String], maxVisible: Int = 3, muted: Bool = false) { + self.labels = labels + self.maxVisible = maxVisible + self.muted = muted + } + + public var body: some View { + HStack(spacing: 4) { + ForEach(Array(labels.prefix(maxVisible)), id: \.self) { label in + Text(label) + .font(.system(size: 10)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(CorveilTheme.gold.opacity(0.08)) + .foregroundStyle(muted ? CorveilTheme.textMuted : CorveilTheme.textSecondary) + .overlay(Capsule().strokeBorder(CorveilTheme.borderSubtle, lineWidth: 0.5)) + .clipShape(Capsule()) + } + if labels.count > maxVisible { + Text("+\(labels.count - maxVisible)") + .font(.system(size: 10)) + .foregroundStyle(CorveilTheme.textMuted) + } + } + } +} diff --git a/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift b/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift index 505b775..af095cf 100644 --- a/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift +++ b/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift @@ -239,6 +239,10 @@ struct ReviewRow: View { .foregroundStyle(CorveilTheme.textMuted) } } + + if !request.labels.isEmpty { + LabelPillsView(labels: request.labels, maxVisible: 3) + } } Spacer() diff --git a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift index efb170f..d759790 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift @@ -21,6 +21,10 @@ public struct SessionDetailView: View { appState.links(for: session.id) } + private var sessionLabels: [String] { + appState.labels(forSession: session) + } + public var body: some View { VStack(spacing: 0) { sessionHeader @@ -59,6 +63,11 @@ public struct SessionDetailView: View { .foregroundStyle(CorveilTheme.textSecondary) .lineLimit(2) } + + if !sessionLabels.isEmpty { + LabelPillsView(labels: sessionLabels, maxVisible: 5) + .padding(.top, 2) + } } Spacer() diff --git a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift index 324cb27..d7f8781 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift @@ -452,6 +452,10 @@ struct SessionRow: View { appState.hookState(for: session.id).claudeState } + private var sessionLabels: [String] { + appState.labels(forSession: session) + } + /// Readiness of the primary terminal for this session. private var terminalReadiness: TerminalReadiness? { let terminals = appState.terminals(for: session.id) @@ -568,6 +572,11 @@ struct SessionRow: View { .lineLimit(1) } + // Row 3.5: Labels + if !appState.hideSessionDetails, !sessionLabels.isEmpty { + LabelPillsView(labels: sessionLabels, maxVisible: 2) + } + // Row 4: Issue badge + PR badge + Claude state let hasIssueBadge = session.ticketNumber != nil let hasBadges = hasIssueBadge || prLink != nil || claudeState != .idle diff --git a/Packages/CrowUI/Sources/CrowUI/TicketBoardView.swift b/Packages/CrowUI/Sources/CrowUI/TicketBoardView.swift index 8d1ca96..acdd7b8 100644 --- a/Packages/CrowUI/Sources/CrowUI/TicketBoardView.swift +++ b/Packages/CrowUI/Sources/CrowUI/TicketBoardView.swift @@ -499,23 +499,7 @@ struct TicketCard: View { } private var labelRow: some View { - HStack(spacing: 4) { - ForEach(issue.labels.prefix(3), id: \.self) { label in - Text(label) - .font(.system(size: 10)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(CorveilTheme.gold.opacity(0.08)) - .foregroundStyle(isDone ? CorveilTheme.textMuted : CorveilTheme.textSecondary) - .overlay(Capsule().strokeBorder(CorveilTheme.borderSubtle, lineWidth: 0.5)) - .clipShape(Capsule()) - } - if issue.labels.count > 3 { - Text("+\(issue.labels.count - 3)") - .font(.system(size: 10)) - .foregroundStyle(CorveilTheme.textMuted) - } - } + LabelPillsView(labels: issue.labels, maxVisible: 3, muted: isDone) } private var statusBadge: some View {