Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions Packages/CrowCore/Tests/CrowCoreTests/AppStateLookupTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
35 changes: 35 additions & 0 deletions Packages/CrowUI/Sources/CrowUI/CorveilTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
4 changes: 4 additions & 0 deletions Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ struct ReviewRow: View {
.foregroundStyle(CorveilTheme.textMuted)
}
}

if !request.labels.isEmpty {
LabelPillsView(labels: request.labels, maxVisible: 3)
}
}

Spacer()
Expand Down
9 changes: 9 additions & 0 deletions Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions Packages/CrowUI/Sources/CrowUI/SessionListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
18 changes: 1 addition & 17 deletions Packages/CrowUI/Sources/CrowUI/TicketBoardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading