summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-12 00:34:54 -0500
committerChristian Cleberg <[email protected]>2026-04-12 00:34:54 -0500
commit5f6d545eb3455a9e4da5a3cb4460811e3a48d939 (patch)
tree7b7591146da4e02bd617ddb9f26e47b1c4474ef6 /Hutch/Views
parentcb1ea5ea4f163d87053285d3fb7999b12a3558b9 (diff)
downloadhutch-5f6d545eb3455a9e4da5a3cb4460811e3a48d939.tar.gz
hutch-5f6d545eb3455a9e4da5a3cb4460811e3a48d939.tar.bz2
hutch-5f6d545eb3455a9e4da5a3cb4460811e3a48d939.zip
improve home attention flows and cross-linkingv2.14.0
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Builds/BuildDetailView.swift38
-rw-r--r--Hutch/Views/Builds/BuildListView.swift11
-rw-r--r--Hutch/Views/Builds/BuildListViewModel.swift35
-rw-r--r--Hutch/Views/Home/HomeView.swift378
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift322
-rw-r--r--Hutch/Views/Inbox/InboxView.swift11
-rw-r--r--Hutch/Views/Inbox/InboxViewModel.swift25
-rw-r--r--Hutch/Views/Inbox/ThreadDetailView.swift51
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift35
9 files changed, 829 insertions, 77 deletions
diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift
index 1c2e2bb..33590e2 100644
--- a/Hutch/Views/Builds/BuildDetailView.swift
+++ b/Hutch/Views/Builds/BuildDetailView.swift
@@ -9,6 +9,7 @@ struct BuildDetailView: View {
@State private var selectedTaskName: String?
@State private var showEditResubmitSheet = false
@State private var showCancelConfirmation = false
+ @State private var isOpeningRepository = false
private var isPresentingLogSheet: Bool {
selectedTaskName != nil
@@ -158,6 +159,28 @@ struct BuildDetailView: View {
LabeledContent("Updated", value: job.updated.relativeDescription)
}
+ if let repositoryReference = HomeViewModel.primaryRepositoryReference(in: job.manifest) {
+ Section("Source") {
+ Button {
+ openRepository(ownerCanonicalName: repositoryReference.ownerCanonicalName, repositoryName: repositoryReference.name)
+ } label: {
+ HStack {
+ Label("\(repositoryReference.ownerCanonicalName)/\(repositoryReference.name)", systemImage: "book.closed")
+ Spacer()
+ if isOpeningRepository {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Image(systemName: "arrow.up.right")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ }
+ .disabled(isOpeningRepository)
+ }
+ }
+
// Per-task logs
if !job.tasks.isEmpty {
ForEach(job.tasks) { task in
@@ -241,6 +264,21 @@ struct BuildDetailView: View {
))
}
}
+
+ private func openRepository(ownerCanonicalName: String, repositoryName: String) {
+ guard !isOpeningRepository else { return }
+ isOpeningRepository = true
+ Task {
+ defer { isOpeningRepository = false }
+ do {
+ let ownerUsername = ownerCanonicalName.hasPrefix("~") ? String(ownerCanonicalName.dropFirst()) : ownerCanonicalName
+ let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName)
+ appState.navigateToRepository(repository)
+ } catch {
+ appState.presentRepositoryDeepLinkError()
+ }
+ }
+ }
}
private struct EditResubmitBuildSheet: View {
diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift
index 6bfbd6b..705f97c 100644
--- a/Hutch/Views/Builds/BuildListView.swift
+++ b/Hutch/Views/Builds/BuildListView.swift
@@ -65,6 +65,17 @@ struct BuildListView: View {
@Bindable var vm = viewModel
List {
+ Section {
+ Picker("Filter", selection: $vm.filter) {
+ ForEach(BuildListFilter.allCases, id: \.self) { filter in
+ Text(filter.rawValue).tag(filter)
+ }
+ }
+ .pickerStyle(.segmented)
+ .listRowBackground(Color.clear)
+ .listRowInsets(EdgeInsets())
+ }
+
ForEach(viewModel.filteredJobs) { job in
NavigationLink(value: job) {
BuildRowView(job: job)
diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift
index 0757ad8..8468cff 100644
--- a/Hutch/Views/Builds/BuildListViewModel.swift
+++ b/Hutch/Views/Builds/BuildListViewModel.swift
@@ -19,6 +19,12 @@ private struct SubmittedJob: Decodable, Sendable {
let id: Int
}
+enum BuildListFilter: String, CaseIterable, Sendable {
+ case attention = "Attention"
+ case active = "Active"
+ case all = "All"
+}
+
// MARK: - View Model
@Observable
@@ -31,6 +37,7 @@ final class BuildListViewModel {
private(set) var isRefreshing = false
private(set) var isSubmitting = false
var error: String?
+ var filter: BuildListFilter = .attention
var searchText = ""
private var cursor: String?
@@ -44,9 +51,10 @@ final class BuildListViewModel {
}
var filteredJobs: [JobSummary] {
+ let statusFiltered = Self.filterJobs(jobs, filter: filter)
let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- guard !q.isEmpty else { return jobs }
- return jobs.filter {
+ guard !q.isEmpty else { return statusFiltered }
+ return statusFiltered.filter {
String($0.id).contains(q) ||
$0.tags.contains { $0.lowercased().contains(q) } ||
($0.note?.lowercased().contains(q) == true) ||
@@ -272,4 +280,27 @@ final class BuildListViewModel {
let cancel: CancelResult
}
+
+ nonisolated static func filterJobs(_ jobs: [JobSummary], filter: BuildListFilter) -> [JobSummary] {
+ jobs.filter { job in
+ switch filter {
+ case .attention:
+ switch job.status {
+ case .failed, .timeout, .running, .queued, .pending:
+ return true
+ case .success, .cancelled:
+ return false
+ }
+ case .active:
+ switch job.status {
+ case .running, .queued, .pending:
+ return true
+ case .success, .failed, .cancelled, .timeout:
+ return false
+ }
+ case .all:
+ return true
+ }
+ }
+ }
}
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 632e804..3efef7a 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -55,7 +55,9 @@ struct HomeView: View {
@ViewBuilder
private func content(_ viewModel: HomeViewModel) -> some View {
List {
+ attentionSection(viewModel)
systemStatusBannerSection(viewModel)
+ inboxSection(viewModel)
projectsSection(viewModel)
assignedTicketsSection(viewModel)
recentBuildsSection(viewModel)
@@ -63,15 +65,17 @@ struct HomeView: View {
.listStyle(.insetGrouped)
.overlay {
if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
- viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
+ viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
+ viewModel.unreadInboxThreads.isEmpty {
SRHTLoadingStateView(message: "Loading Home…")
} else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
+ viewModel.unreadInboxThreads.isEmpty &&
viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
ContentUnavailableView(
"All Clear",
systemImage: "checkmark.circle",
- description: Text("There are no assigned tickets or recent builds right now.")
+ description: Text("There are no unread threads, assigned tickets, or urgent builds right now.")
)
}
}
@@ -84,6 +88,41 @@ struct HomeView: View {
}
@ViewBuilder
+ private func attentionSection(_ viewModel: HomeViewModel) -> some View {
+ Section("Needs Attention") {
+ HomeAttentionSummaryRow(
+ title: viewModel.needsAttentionCount == 0 ? "All clear" : "\(viewModel.needsAttentionCount) things need attention",
+ summary: viewModel.attentionSummaryText
+ )
+
+ HomeAttentionLinkRow(
+ title: "Inbox",
+ summary: viewModel.inboxSummaryText,
+ countText: viewModel.unreadInboxThreadCount.map(String.init) ?? "?"
+ ) {
+ InboxView()
+ }
+
+ HomeAttentionLinkRow(
+ title: "Assigned Tickets",
+ summary: viewModel.ticketsSummaryText,
+ countText: String(viewModel.assignedTickets.count)
+ ) {
+ HomeAssignedTicketsListView(viewModel: viewModel)
+ }
+
+ HomeAttentionLinkRow(
+ title: "Builds",
+ summary: viewModel.buildsSummaryText,
+ countText: String(viewModel.failedBuildCount + viewModel.activeBuildCount),
+ action: {
+ appState.selectedTab = .builds
+ }
+ )
+ }
+ }
+
+ @ViewBuilder
private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View {
Section {
NavigationLink {
@@ -101,6 +140,40 @@ struct HomeView: View {
}
@ViewBuilder
+ private func inboxSection(_ viewModel: HomeViewModel) -> some View {
+ Section {
+ if let unreadCount = viewModel.unreadInboxThreadCount, unreadCount == 0 {
+ HomeSectionMessageRow(
+ text: "No unread inbox threads.",
+ systemImage: "tray"
+ )
+ } else if viewModel.unreadInboxThreads.isEmpty {
+ HomeSectionMessageRow(
+ text: viewModel.inboxSummaryText,
+ systemImage: "tray"
+ )
+ } else {
+ ForEach(viewModel.unreadInboxThreads.prefix(previewLimit)) { thread in
+ NavigationLink {
+ ThreadDetailView(
+ thread: thread,
+ onViewed: { viewModel.markInboxThreadRead(thread) },
+ onMarkRead: { viewModel.markInboxThreadRead(thread) },
+ onMarkUnread: { viewModel.markInboxThreadUnread(thread) }
+ )
+ } label: {
+ HomeInboxThreadRow(thread: thread)
+ }
+ }
+ }
+ } header: {
+ HomeSectionHeader("Inbox") {
+ InboxView()
+ }
+ }
+ }
+
+ @ViewBuilder
private func projectsSection(_ viewModel: HomeViewModel) -> some View {
if !viewModel.projects.isEmpty {
Section {
@@ -312,41 +385,55 @@ private struct HomeProjectsListView: View {
}
private struct HomeBuildRow: View {
+ @Environment(AppState.self) private var appState
let build: HomeBuildItem
var body: some View {
- HStack(spacing: 12) {
- JobStatusIcon(status: build.job.status)
- .frame(width: 20)
-
- VStack(alignment: .leading, spacing: 4) {
- Text(primaryTitle)
- .font(.subheadline.weight(.medium))
- .lineLimit(1)
-
- HStack(spacing: 8) {
- Text("Job #\(build.job.id)")
- .font(.caption)
- .foregroundStyle(.secondary)
-
- Text("•")
- .font(.caption)
- .foregroundStyle(.tertiary)
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 12) {
+ JobStatusIcon(status: build.job.status)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(primaryTitle)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(1)
+
+ HStack(spacing: 8) {
+ Text("Job #\(build.job.id)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Text("•")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+
+ Text(build.job.status.rawValue.capitalized)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Text("•")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+
+ Text(build.job.created.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+
+ Spacer()
+ }
+ }
+ }
- Text(build.job.status.rawValue.capitalized)
+ if let repositoryDisplayName = build.repositoryDisplayName {
+ Button {
+ openRepository()
+ } label: {
+ Label(repositoryDisplayName, systemImage: "book.closed")
.font(.caption)
.foregroundStyle(.secondary)
-
- Text("•")
- .font(.caption)
- .foregroundStyle(.tertiary)
-
- Text(build.job.created.relativeDescription)
- .font(.caption)
- .foregroundStyle(.tertiary)
-
- Spacer()
}
+ .buttonStyle(.plain)
}
}
.padding(.vertical, 2)
@@ -358,38 +445,148 @@ private struct HomeBuildRow: View {
}
return build.job.displayLabel
}
+
+ private func openRepository() {
+ guard let repositoryName = build.repositoryName,
+ let repositoryOwner = build.repositoryOwner else { return }
+ Task {
+ do {
+ let repository = try await appState.resolveRepository(
+ owner: repositoryOwner.hasPrefix("~") ? String(repositoryOwner.dropFirst()) : repositoryOwner,
+ name: repositoryName
+ )
+ appState.navigateToRepository(repository)
+ } catch {
+ appState.presentRepositoryDeepLinkError()
+ }
+ }
+ }
}
private struct HomeAssignedTicketRow: View {
+ @Environment(AppState.self) private var appState
let ticket: HomeAssignedTicket
var body: some View {
- HStack(alignment: .top, spacing: 12) {
- TicketStatusIcon(status: ticket.ticket.status)
- .frame(width: 20)
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .top, spacing: 12) {
+ TicketStatusIcon(status: ticket.ticket.status)
+ .frame(width: 20)
- VStack(alignment: .leading, spacing: 4) {
- Text(ticket.ticket.title)
- .font(.subheadline.weight(.medium))
- .lineLimit(2)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(ticket.ticket.title)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(2)
- Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)")
- .font(.caption)
+ Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+
+ Spacer(minLength: 8)
+
+ Text(ticket.ticket.status.displayName)
+ .font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
- .truncationMode(.tail)
+ .fixedSize()
}
- Spacer(minLength: 8)
+ Button {
+ openTracker()
+ } label: {
+ Label("\(ticket.ownerCanonicalName)/\(ticket.trackerName)", systemImage: "checklist")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.vertical, 2)
+ }
- Text(ticket.ticket.status.displayName)
- .font(.caption2.weight(.medium))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .fixedSize()
+ private func openTracker() {
+ Task {
+ do {
+ let tracker = try await appState.resolveTracker(owner: ticket.ownerUsername, name: ticket.trackerName)
+ appState.navigateToTracker(tracker)
+ } catch {
+ appState.presentTicketDeepLinkError()
+ }
+ }
+ }
+}
+
+private struct HomeInboxThreadRow: View {
+ @Environment(AppState.self) private var appState
+ let thread: InboxThreadSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .top, spacing: 10) {
+ Circle()
+ .fill(.blue)
+ .frame(width: 8, height: 8)
+ .padding(.top, 6)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(thread.displaySubject)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(2)
+
+ Text(thread.metadataLine)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+
+ HStack(spacing: 10) {
+ Button {
+ appState.navigateToMailingList(
+ InboxMailingListReference(
+ id: thread.listID,
+ rid: thread.listRID,
+ name: thread.listName,
+ owner: thread.listOwner
+ )
+ )
+ } label: {
+ Label(thread.listName, systemImage: "list.bullet")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+
+ if let repo = thread.repo {
+ Button {
+ openRepository(named: repo)
+ } label: {
+ Label(repo, systemImage: "book.closed")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+ }
}
.padding(.vertical, 2)
}
+
+ private func openRepository(named repositoryName: String) {
+ Task {
+ do {
+ let ownerUsername = thread.listOwner.canonicalName.hasPrefix("~")
+ ? String(thread.listOwner.canonicalName.dropFirst())
+ : thread.listOwner.canonicalName
+ let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName)
+ appState.navigateToRepository(repository)
+ } catch {
+ appState.presentRepositoryDeepLinkError()
+ }
+ }
+ }
}
private struct HomeSectionLoadingRow: View {
@@ -431,6 +628,95 @@ private struct HomeSectionHeader<Destination: View>: View {
}
}
+private struct HomeAttentionSummaryRow: View {
+ let title: String
+ let summary: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.subheadline.weight(.semibold))
+ Text(summary)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+private struct HomeAttentionLinkRow<Destination: View>: View {
+ let title: String
+ let summary: String
+ let countText: String
+ let destination: Destination?
+ let action: (() -> Void)?
+
+ init(
+ title: String,
+ summary: String,
+ countText: String,
+ @ViewBuilder destination: () -> Destination
+ ) {
+ self.title = title
+ self.summary = summary
+ self.countText = countText
+ self.destination = destination()
+ self.action = nil
+ }
+
+ init(
+ title: String,
+ summary: String,
+ countText: String,
+ action: @escaping () -> Void
+ ) where Destination == EmptyView {
+ self.title = title
+ self.summary = summary
+ self.countText = countText
+ self.destination = nil
+ self.action = action
+ }
+
+ var body: some View {
+ Group {
+ if let destination {
+ NavigationLink {
+ destination
+ } label: {
+ content
+ }
+ } else if let action {
+ Button(action: action) {
+ content
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ private var content: some View {
+ HStack(spacing: 12) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.subheadline.weight(.medium))
+ Text(summary)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ Spacer()
+ Text(countText)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(Color(.secondarySystemFill), in: Capsule())
+ }
+ .padding(.vertical, 2)
+ }
+}
+
private struct HomeSectionActionHeader: View {
let title: String
let action: () -> Void
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index ba00a36..8762393 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -63,8 +63,31 @@ private struct HomeInboxThreadPage: Decodable, Sendable {
}
private struct HomeInboxThreadPayload: Decodable, Sendable {
+ let created: Date
let updated: Date
let subject: String
+ let replies: Int
+ let sender: Entity
+ let root: HomeInboxEmailPreview
+}
+
+private struct HomeInboxEmailPreview: Decodable, Sendable {
+ let id: Int
+ let subject: String
+ let date: Date?
+ let received: Date
+ let messageID: String
+ let body: String
+ let patch: HomeInboxPatchPreview?
+}
+
+private struct HomeInboxPatchPreview: Decodable, Sendable {
+ let subject: String?
+}
+
+private struct HomeInboxUnreadSnapshot: Sendable {
+ let unreadCount: Int
+ let threads: [InboxThreadSummary]
}
private struct HomeTicketPayload: Decodable, Sendable {
@@ -135,6 +158,15 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable {
}
return repositoryName
}
+
+ var requiresAttention: Bool {
+ switch job.status {
+ case .failed, .timeout, .running, .queued, .pending:
+ true
+ case .success, .cancelled:
+ false
+ }
+ }
}
@Observable
@@ -143,6 +175,7 @@ final class HomeViewModel {
private(set) var projects: [Project] = []
var assignedTickets: [HomeAssignedTicket] = []
var recentBuilds: [HomeBuildItem] = []
+ var unreadInboxThreads: [InboxThreadSummary] = []
private(set) var systemStatusSnapshot: SystemStatusSnapshot?
private(set) var isLoadingSystemStatus = false
private(set) var isShowingStaleSystemStatus = false
@@ -242,8 +275,20 @@ final class HomeViewModel {
list(rid: $rid) {
threads(cursor: $cursor) {
results {
+ created
updated
subject
+ replies
+ sender { canonicalName }
+ root {
+ id
+ subject
+ date
+ received
+ messageID
+ body
+ patch { subject }
+ }
}
cursor
}
@@ -291,7 +336,7 @@ final class HomeViewModel {
async let projectsTask = loadProjects()
async let jobsTask = loadRecentJobs()
async let assignedTicketsTask = loadAssignedTickets()
- async let inboxUnreadTask = loadInboxUnreadCount()
+ async let inboxUnreadTask = loadInboxUnreadSnapshot()
async let systemStatusTask = loadSystemStatusSnapshot()
let projectsResult = await projectsTask
@@ -328,7 +373,9 @@ final class HomeViewModel {
}
isLoadingAssignedTickets = false
- unreadInboxThreadCount = await inboxUnreadTask
+ let inboxUnreadSnapshot = await inboxUnreadTask
+ unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount
+ unreadInboxThreads = Array(inboxUnreadSnapshot?.threads.prefix(4) ?? [])
hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
let systemStatusResult = await systemStatusTask
switch systemStatusResult {
@@ -344,7 +391,116 @@ final class HomeViewModel {
}
var hasDashboardContent: Bool {
- !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || systemStatusSnapshot != nil
+ !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil
+ }
+
+ var failedBuildCount: Int {
+ recentBuilds.filter {
+ switch $0.job.status {
+ case .failed, .timeout:
+ return true
+ default:
+ return false
+ }
+ }.count
+ }
+
+ var activeBuildCount: Int {
+ recentBuilds.filter {
+ switch $0.job.status {
+ case .pending, .queued, .running:
+ return true
+ default:
+ return false
+ }
+ }.count
+ }
+
+ var activeIncidentCount: Int {
+ systemStatusSnapshot?.activeIncidents.count ?? 0
+ }
+
+ var disruptedServiceCount: Int {
+ systemStatusSnapshot?.disruptedServices.count ?? 0
+ }
+
+ var needsAttentionCount: Int {
+ var count = 0
+ if let unreadInboxThreadCount {
+ count += unreadInboxThreadCount
+ }
+ count += assignedTickets.count
+ count += failedBuildCount
+ count += activeBuildCount
+ if let snapshot = systemStatusSnapshot, snapshot.hasDisruption {
+ count += max(snapshot.disruptedServices.count, snapshot.activeIncidents.count)
+ }
+ return count
+ }
+
+ var attentionSummaryText: String {
+ if needsAttentionCount == 0 {
+ return "All clear"
+ }
+ var parts: [String] = []
+ if let unreadInboxThreadCount, unreadInboxThreadCount > 0 {
+ parts.append(Self.countLabel(unreadInboxThreadCount, singular: "unread thread"))
+ }
+ if !assignedTickets.isEmpty {
+ parts.append(Self.countLabel(assignedTickets.count, singular: "assigned ticket"))
+ }
+ if failedBuildCount > 0 {
+ parts.append(Self.countLabel(failedBuildCount, singular: "failed build"))
+ }
+ if activeBuildCount > 0 {
+ parts.append(Self.countLabel(activeBuildCount, singular: "active build"))
+ }
+ if disruptedServiceCount > 0 {
+ parts.append(Self.countLabel(disruptedServiceCount, singular: "service issue"))
+ }
+ return parts.joined(separator: " • ")
+ }
+
+ var inboxSummaryText: String {
+ guard let unreadInboxThreadCount else { return "Inbox status unavailable" }
+ if unreadInboxThreadCount == 0 {
+ return "Inbox zero"
+ }
+ return "\(Self.countLabel(unreadInboxThreadCount, singular: "unread thread")) across your lists"
+ }
+
+ var ticketsSummaryText: String {
+ if assignedTickets.isEmpty {
+ return "No open tickets assigned to you"
+ }
+ return Self.countLabel(assignedTickets.count, singular: "open assigned ticket")
+ }
+
+ var buildsSummaryText: String {
+ if recentBuilds.isEmpty {
+ return "No recent builds"
+ }
+ var parts: [String] = []
+ if failedBuildCount > 0 {
+ parts.append(Self.countLabel(failedBuildCount, singular: "failed build"))
+ }
+ if activeBuildCount > 0 {
+ parts.append(Self.countLabel(activeBuildCount, singular: "active build"))
+ }
+ if parts.isEmpty {
+ return "Recent builds are clear"
+ }
+ return parts.joined(separator: " • ")
+ }
+
+ var systemSummaryText: String {
+ guard let systemStatusSnapshot else {
+ return systemStatusErrorMessage ?? "System status unavailable"
+ }
+ if systemStatusSnapshot.hasDisruption {
+ return systemStatusSnapshot.bannerSummary
+ }
+ return systemStatusSnapshot.overallStatusText
}
func resolveTicket(_ ticket: HomeAssignedTicket) async {
@@ -415,6 +571,43 @@ final class HomeViewModel {
}
}
+ func markInboxThreadRead(_ thread: InboxThreadSummary) {
+ InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
+ unreadInboxThreads.removeAll { $0.id == thread.id }
+ unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0)
+ hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
+ persistNeedsAttentionSnapshot()
+ }
+
+ func markInboxThreadUnread(_ thread: InboxThreadSummary) {
+ InboxReadStateStore.markUnread(for: thread.id)
+ if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false {
+ unreadInboxThreads.append(
+ InboxThreadSummary(
+ rootEmailID: thread.rootEmailID,
+ rootMessageID: thread.rootMessageID,
+ threadRootEmailIDs: thread.threadRootEmailIDs,
+ threadRootMessageIDs: thread.threadRootMessageIDs,
+ listID: thread.listID,
+ listRID: thread.listRID,
+ listName: thread.listName,
+ listOwner: thread.listOwner,
+ subject: thread.subject,
+ latestSender: thread.latestSender,
+ lastActivityAt: thread.lastActivityAt,
+ messageCount: thread.messageCount,
+ repo: thread.repo,
+ containsPatch: thread.containsPatch,
+ isUnread: true
+ )
+ )
+ unreadInboxThreads.sort(by: Self.sortInboxThreadsForTriage)
+ }
+ unreadInboxThreadCount = (unreadInboxThreadCount ?? 0) + 1
+ hasUnreadInboxThreads = true
+ persistNeedsAttentionSnapshot()
+ }
+
private func loadProjects() async -> Result<[Project], Error> {
do {
return .success(try await projectService.fetchProjects())
@@ -436,9 +629,9 @@ final class HomeViewModel {
}
}
- private func loadInboxUnreadCount() async -> Int? {
+ private func loadInboxUnreadSnapshot() async -> HomeInboxUnreadSnapshot? {
do {
- return try await fetchUnreadInboxThreadCount()
+ return try await fetchUnreadInboxSnapshot()
} catch {
return nil
}
@@ -452,13 +645,14 @@ final class HomeViewModel {
}
}
- private func fetchUnreadInboxThreadCount() async throws -> Int {
+ private func fetchUnreadInboxSnapshot() async throws -> HomeInboxUnreadSnapshot {
let mailingLists = try await fetchInboxMailingLists()
- guard !mailingLists.isEmpty else { return 0 }
+ guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) }
var startIndex = mailingLists.startIndex
var unreadCount = 0
var successfulFetchCount = 0
+ var unreadThreads: [InboxThreadSummary] = []
while startIndex < mailingLists.endIndex {
let endIndex = mailingLists.index(
startIndex,
@@ -467,31 +661,32 @@ final class HomeViewModel {
) ?? mailingLists.endIndex
let batch = Array(mailingLists[startIndex..<endIndex])
- let batchResult = await withTaskGroup(of: Result<Int, Error>.self) { group in
+ let batchResult = await withTaskGroup(of: Result<HomeInboxUnreadSnapshot, Error>.self) { group in
for mailingList in batch {
group.addTask {
do {
- return .success(try await self.fetchUnreadThreadCount(for: mailingList))
+ return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList))
} catch {
return .failure(error)
}
}
}
- var counts: [Int] = []
+ var snapshots: [HomeInboxUnreadSnapshot] = []
var errors: [Error] = []
for await result in group {
switch result {
- case .success(let count):
- counts.append(count)
+ case .success(let snapshot):
+ snapshots.append(snapshot)
case .failure(let error):
errors.append(error)
}
}
- return (counts, errors)
+ return (snapshots, errors)
}
- unreadCount += batchResult.0.reduce(0, +)
+ unreadCount += batchResult.0.reduce(0) { $0 + $1.unreadCount }
+ unreadThreads.append(contentsOf: batchResult.0.flatMap(\.threads))
successfulFetchCount += batchResult.0.count
startIndex = endIndex
@@ -501,7 +696,10 @@ final class HomeViewModel {
throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
}
- return unreadCount
+ return HomeInboxUnreadSnapshot(
+ unreadCount: unreadCount,
+ threads: unreadThreads.sorted(by: Self.sortInboxThreadsForTriage)
+ )
}
private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
@@ -532,9 +730,10 @@ final class HomeViewModel {
return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
}
- private func fetchUnreadThreadCount(for mailingList: InboxMailingListReference) async throws -> Int {
+ private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference) async throws -> HomeInboxUnreadSnapshot {
var unreadCount = 0
var cursor: String?
+ var unreadThreads: [InboxThreadSummary] = []
while true {
var variables: [String: any Sendable] = ["rid": mailingList.rid]
@@ -549,15 +748,31 @@ final class HomeViewModel {
responseType: HomeInboxListThreadsResponse.self
)
- unreadCount += response.list.threads.results.filter { thread in
- let normalizedSubject = thread.subject
- .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
- .lowercased()
- let threadID = "\(mailingList.rid)#\(normalizedSubject)"
- return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
- }.count
+ let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in
+ let summary = InboxThreadSummary(
+ rootEmailID: thread.root.id,
+ rootMessageID: thread.root.messageID,
+ threadRootEmailIDs: [thread.root.id],
+ threadRootMessageIDs: [thread.root.messageID],
+ listID: mailingList.id,
+ listRID: mailingList.rid,
+ listName: mailingList.name,
+ listOwner: mailingList.owner,
+ subject: thread.subject,
+ latestSender: thread.sender,
+ lastActivityAt: thread.updated,
+ messageCount: thread.replies + 1,
+ repo: InboxViewModel.deriveRepositoryName(from: mailingList.name),
+ containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
+ isUnread: InboxReadStateStore.isUnread(
+ threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))",
+ lastActivityAt: thread.updated
+ )
+ )
+ return summary.isUnread ? summary : nil
+ }
+ unreadCount += unreadThreadSummaries.count
+ unreadThreads.append(contentsOf: unreadThreadSummaries)
guard let nextCursor = response.list.threads.cursor else {
break
@@ -565,14 +780,17 @@ final class HomeViewModel {
cursor = nextCursor
}
- return unreadCount
+ return HomeInboxUnreadSnapshot(
+ unreadCount: unreadCount,
+ threads: unreadThreads
+ )
}
private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
do {
let trackers = try await fetchAllTrackers()
let tickets = try await fetchAssignedTickets(for: trackers)
- .sorted { $0.ticket.created > $1.ticket.created }
+ .sorted(by: Self.sortAssignedTicketsForTriage)
return .success(tickets)
} catch {
return .failure(error)
@@ -719,6 +937,7 @@ final class HomeViewModel {
repositoryOwner: repository?.ownerCanonicalName
)
}
+ .sorted(by: sortBuildItemsForTriage)
}
nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
@@ -732,6 +951,36 @@ final class HomeViewModel {
}
}
+ nonisolated static func sortBuildItemsForTriage(_ lhs: HomeBuildItem, _ rhs: HomeBuildItem) -> Bool {
+ let lhsPriority = buildPriority(for: lhs.job.status)
+ let rhsPriority = buildPriority(for: rhs.job.status)
+ if lhsPriority != rhsPriority {
+ return lhsPriority < rhsPriority
+ }
+ if lhs.job.updated != rhs.job.updated {
+ return lhs.job.updated > rhs.job.updated
+ }
+ return lhs.job.id > rhs.job.id
+ }
+
+ nonisolated static func sortAssignedTicketsForTriage(_ lhs: HomeAssignedTicket, _ rhs: HomeAssignedTicket) -> Bool {
+ if lhs.ticket.created != rhs.ticket.created {
+ return lhs.ticket.created < rhs.ticket.created
+ }
+ return lhs.ticket.id < rhs.ticket.id
+ }
+
+ nonisolated static func sortInboxThreadsForTriage(_ lhs: InboxThreadSummary, _ rhs: InboxThreadSummary) -> Bool {
+ if lhs.containsPatch != rhs.containsPatch {
+ return lhs.containsPatch && !rhs.containsPatch
+ }
+ if lhs.lastActivityAt != rhs.lastActivityAt {
+ return lhs.lastActivityAt > rhs.lastActivityAt
+ }
+ return InboxThreadSummary.normalizationKey(for: lhs.subject)
+ .localizedCaseInsensitiveCompare(InboxThreadSummary.normalizationKey(for: rhs.subject)) == .orderedAscending
+ }
+
nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
@@ -784,6 +1033,25 @@ final class HomeViewModel {
return trimmed
}
+ private nonisolated static func buildPriority(for status: JobStatus) -> Int {
+ switch status {
+ case .failed, .timeout:
+ return 0
+ case .running:
+ return 1
+ case .queued, .pending:
+ return 2
+ case .cancelled:
+ return 3
+ case .success:
+ return 4
+ }
+ }
+
+ private nonisolated static func countLabel(_ count: Int, singular: String) -> String {
+ count == 1 ? "1 \(singular)" : "\(count) \(singular)s"
+ }
+
private struct StatusEventResponse: Decodable, Sendable {
struct EventRef: Decodable, Sendable {
let eventType: String
diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
index aa3765c..2447a6e 100644
--- a/Hutch/Views/Inbox/InboxView.swift
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -42,6 +42,17 @@ struct InboxView: View {
@Bindable var vm = viewModel
List {
+ Section {
+ Picker("Filter", selection: $vm.filter) {
+ ForEach(InboxThreadFilter.allCases, id: \.self) { filter in
+ Text(filter.rawValue).tag(filter)
+ }
+ }
+ .pickerStyle(.segmented)
+ .listRowBackground(Color.clear)
+ .listRowInsets(EdgeInsets())
+ }
+
ForEach(viewModel.filteredThreads) { thread in
Button {
selectThread(thread)
diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift
index edb7593..a01f836 100644
--- a/Hutch/Views/Inbox/InboxViewModel.swift
+++ b/Hutch/Views/Inbox/InboxViewModel.swift
@@ -56,12 +56,19 @@ private struct InboxEmailPreview: Decodable, Sendable {
let patch: InboxPatchPreview?
}
+enum InboxThreadFilter: String, CaseIterable, Sendable {
+ case all = "All"
+ case patches = "Patches"
+ case discussions = "Talk"
+}
+
@Observable
@MainActor
final class InboxViewModel {
private(set) var threads: [InboxThreadSummary] = []
private(set) var isLoading = false
var error: String?
+ var filter: InboxThreadFilter = .all
var searchText = ""
private let client: SRHTClient
@@ -181,9 +188,10 @@ final class InboxViewModel {
}
var filteredThreads: [InboxThreadSummary] {
+ let filteredByKind = Self.filterThreads(threads, filter: filter)
let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- guard !q.isEmpty else { return threads }
- return threads.filter {
+ guard !q.isEmpty else { return filteredByKind }
+ return filteredByKind.filter {
$0.displaySubject.lowercased().contains(q) ||
$0.listName.lowercased().contains(q) ||
$0.latestSender.canonicalName.lowercased().contains(q)
@@ -389,4 +397,17 @@ final class InboxViewModel {
}
return nil
}
+
+ nonisolated static func filterThreads(_ threads: [InboxThreadSummary], filter: InboxThreadFilter) -> [InboxThreadSummary] {
+ threads.filter { thread in
+ switch filter {
+ case .all:
+ return true
+ case .patches:
+ return thread.containsPatch
+ case .discussions:
+ return !thread.containsPatch
+ }
+ }
+ }
}
diff --git a/Hutch/Views/Inbox/ThreadDetailView.swift b/Hutch/Views/Inbox/ThreadDetailView.swift
index c595497..03aa779 100644
--- a/Hutch/Views/Inbox/ThreadDetailView.swift
+++ b/Hutch/Views/Inbox/ThreadDetailView.swift
@@ -18,6 +18,7 @@ struct ThreadDetailView: View {
@State private var hasMarkedCurrentThreadViewed = false
@State private var suppressAutoMarkViewed = false
@State private var isUnread: Bool
+ @State private var isOpeningRepository = false
init(
thread: InboxThreadSummary,
@@ -119,6 +120,41 @@ struct ThreadDetailView: View {
.padding(.vertical, 4)
}
+ Section("Related") {
+ Button {
+ appState.navigateToMailingList(
+ InboxMailingListReference(
+ id: thread.listID,
+ rid: thread.listRID,
+ name: thread.listName,
+ owner: thread.listOwner
+ )
+ )
+ } label: {
+ Label(thread.listDisplayName, systemImage: "list.bullet")
+ }
+
+ if let repo = self.thread.repo {
+ Button {
+ openRepository(named: repo, ownerCanonicalName: thread.listOwner.canonicalName)
+ } label: {
+ HStack {
+ Label("\(thread.listOwner.canonicalName)/\(repo)", systemImage: "book.closed")
+ Spacer()
+ if isOpeningRepository {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Image(systemName: "arrow.up.right")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ }
+ .disabled(isOpeningRepository)
+ }
+ }
+
if let partialWarning = viewModel.partialWarning {
Section {
Text(partialWarning)
@@ -180,6 +216,21 @@ struct ThreadDetailView: View {
parts.append(thread.lastActivityAt.relativeDescription)
return parts.joined(separator: " • ")
}
+
+ private func openRepository(named repositoryName: String, ownerCanonicalName: String) {
+ guard !isOpeningRepository else { return }
+ isOpeningRepository = true
+ Task {
+ defer { isOpeningRepository = false }
+ do {
+ let ownerUsername = ownerCanonicalName.hasPrefix("~") ? String(ownerCanonicalName.dropFirst()) : ownerCanonicalName
+ let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName)
+ appState.navigateToRepository(repository)
+ } catch {
+ appState.presentRepositoryDeepLinkError()
+ }
+ }
+ }
}
private struct InboxMessageRow: View {
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift
index 22ebaa9..4bea3ae 100644
--- a/Hutch/Views/Tickets/TicketDetailView.swift
+++ b/Hutch/Views/Tickets/TicketDetailView.swift
@@ -16,6 +16,7 @@ struct TicketDetailView: View {
@State private var showResolveSheet = false
@State private var showAssignSheet = false
@State private var showLabelsSheet = false
+ @State private var isOpeningTracker = false
// Comment composer mode
@State private var commentMode: CommentMode = .write
@@ -227,6 +228,26 @@ struct TicketDetailView: View {
}
.font(.caption)
+ Button {
+ openTracker()
+ } label: {
+ HStack(spacing: 6) {
+ Label("\(ownerUsername)/\(trackerName)", systemImage: "checklist")
+ .font(.caption.weight(.medium))
+ if isOpeningTracker {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Image(systemName: "arrow.up.right")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .disabled(isOpeningTracker)
+
if !ticket.assignees.isEmpty {
HStack(spacing: 4) {
Image(systemName: "person.fill")
@@ -346,6 +367,20 @@ struct TicketDetailView: View {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
}
+
+ private func openTracker() {
+ guard !isOpeningTracker else { return }
+ isOpeningTracker = true
+ Task {
+ defer { isOpeningTracker = false }
+ do {
+ let tracker = try await appState.resolveTracker(owner: ownerUsername, name: trackerName)
+ appState.navigateToTracker(tracker)
+ } catch {
+ appState.presentTicketDeepLinkError()
+ }
+ }
+ }
}
// MARK: - Self-Sizing Markdown Web View