krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

5f6d545eb3455a9e4da5a3cb4460811e3a48d939

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-12T05:34:54Z

improve home attention flows and cross-linking
 Hutch.xcodeproj/project.pbxproj             |  16 +-
 Hutch/App/AppState.swift                    |  36 ++-
 Hutch/Models/Inbox.swift                    |   8 +-
 Hutch/Views/Builds/BuildDetailView.swift    |  38 +++
 Hutch/Views/Builds/BuildListView.swift      |  11 +
 Hutch/Views/Builds/BuildListViewModel.swift |  35 ++-
 Hutch/Views/Home/HomeView.swift             | 378 ++++++++++++++++++++++++----
 Hutch/Views/Home/HomeViewModel.swift        | 322 ++++++++++++++++++++++--
 Hutch/Views/Inbox/InboxView.swift           |  11 +
 Hutch/Views/Inbox/InboxViewModel.swift      |  25 +-
 Hutch/Views/Inbox/ThreadDetailView.swift    |  51 ++++
 Hutch/Views/Tickets/TicketDetailView.swift  |  35 +++
 HutchTests/AppStateTests.swift              |  42 ++++
 HutchTests/BuildListViewModelTests.swift    |  20 +-
 HutchTests/HomeViewModelTests.swift         |  13 +
 HutchTests/InboxViewModelTests.swift        |  49 ++++
 16 files changed, 996 insertions(+), 94 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index ed3e455..ca3476e 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 30;
+				CURRENT_PROJECT_VERSION = 31;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.13.1;
+				MARKETING_VERSION = 2.14.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 30;
+				CURRENT_PROJECT_VERSION = 31;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.13.1;
+				MARKETING_VERSION = 2.14.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 30;
+				CURRENT_PROJECT_VERSION = 31;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.13.1;
+				MARKETING_VERSION = 2.14.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 30;
+				CURRENT_PROJECT_VERSION = 31;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.13.1;
+				MARKETING_VERSION = 2.14.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index 95d9fa9..c2e2744 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -261,22 +261,48 @@ final class AppState {
 
     func openProjectSource(_ source: Project.SourceRepo) async throws {
         let repository = try await resolveProjectSource(source)
-        pendingTabNavigation = .repository(repository)
-        selectedTab = .repositories
+        navigateToRepository(repository)
     }
 
     func openProjectTracker(_ tracker: Project.Tracker) async throws {
         let resolvedTracker = try await resolveProjectTracker(tracker)
-        pendingTabNavigation = .tracker(resolvedTracker)
-        selectedTab = .tickets
+        navigateToTracker(resolvedTracker)
     }
 
     func openMailingList(_ mailingList: InboxMailingListReference) {
+        navigateToMailingList(mailingList)
+    }
+
+    func openSystemStatus() {
+        navigateToSystemStatus()
+    }
+
+    func navigateToRepository(_ repository: RepositorySummary) {
+        pendingTabNavigation = .repository(repository)
+        selectedTab = .repositories
+    }
+
+    func navigateToTracker(_ tracker: TrackerSummary) {
+        pendingTabNavigation = .tracker(tracker)
+        selectedTab = .tickets
+    }
+
+    func navigateToBuild(jobId: Int) {
+        pendingDeepLink = .build(jobId: jobId)
+        selectedTab = .builds
+    }
+
+    func navigateToTicket(ownerUsername: String, trackerName: String, ticketId: Int) {
+        pendingDeepLink = .ticket(owner: ownerUsername, tracker: trackerName, ticketId: ticketId)
+        selectedTab = .tickets
+    }
+
+    func navigateToMailingList(_ mailingList: InboxMailingListReference) {
         pendingTabNavigation = .mailingList(mailingList)
         selectedTab = .more
     }
 
-    func openSystemStatus() {
+    func navigateToSystemStatus() {
         pendingTabNavigation = .systemStatus
         selectedTab = .more
     }
diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift
index c89cb12..8ab2480 100644
--- a/Hutch/Models/Inbox.swift
+++ b/Hutch/Models/Inbox.swift
@@ -52,10 +52,14 @@ struct InboxThreadSummary: Identifiable, Hashable, Sendable {
     }
 
     var threadGroupingKey: String {
-        "\(listRID)#\(displaySubject.lowercased())"
+        "\(listRID)#\(Self.normalizationKey(for: subject))"
     }
 
-    private static func normalizedSubject(from subject: String) -> String {
+    nonisolated static func normalizationKey(for subject: String) -> String {
+        normalizedSubject(from: subject).lowercased()
+    }
+
+    private nonisolated static func normalizedSubject(from subject: String) -> String {
         let collapsedWhitespace = subject
             .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
             .trimmingCharacters(in: .whitespacesAndNewlines)
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.")
                 )
             }
         }
@@ -83,6 +87,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 {
@@ -100,6 +139,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 {
@@ -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
diff --git a/HutchTests/AppStateTests.swift b/HutchTests/AppStateTests.swift
index 9cf4e13..fe357cb 100644
--- a/HutchTests/AppStateTests.swift
+++ b/HutchTests/AppStateTests.swift
@@ -33,4 +33,46 @@ struct AppStateTests {
         #expect(appState.selectedTab == .more)
         #expect(appState.pendingTabNavigation == .systemStatus)
     }
+
+    @Test
+    @MainActor
+    func navigationHelpersQueueExpectedTargets() {
+        let appState = AppState()
+        let repository = RepositorySummary(
+            id: 1,
+            rid: "repo",
+            service: .git,
+            name: "hutch",
+            description: nil,
+            visibility: .public,
+            updated: .distantPast,
+            owner: Entity(canonicalName: "~owner"),
+            head: nil
+        )
+        let tracker = TrackerSummary(
+            id: 2,
+            rid: "tracker",
+            name: "todo",
+            description: nil,
+            visibility: .public,
+            updated: .distantPast,
+            owner: Entity(canonicalName: "~owner")
+        )
+
+        appState.navigateToRepository(repository)
+        #expect(appState.selectedTab == .repositories)
+        #expect(appState.pendingTabNavigation == .repository(repository))
+
+        appState.navigateToTracker(tracker)
+        #expect(appState.selectedTab == .tickets)
+        #expect(appState.pendingTabNavigation == .tracker(tracker))
+
+        appState.navigateToBuild(jobId: 42)
+        #expect(appState.selectedTab == .builds)
+        #expect(appState.pendingDeepLink == .build(jobId: 42))
+
+        appState.navigateToTicket(ownerUsername: "owner", trackerName: "todo", ticketId: 9)
+        #expect(appState.selectedTab == .tickets)
+        #expect(appState.pendingDeepLink == .ticket(owner: "owner", tracker: "todo", ticketId: 9))
+    }
 }
diff --git a/HutchTests/BuildListViewModelTests.swift b/HutchTests/BuildListViewModelTests.swift
index a5c2483..a8cdc30 100644
--- a/HutchTests/BuildListViewModelTests.swift
+++ b/HutchTests/BuildListViewModelTests.swift
@@ -28,6 +28,22 @@ struct BuildListViewModelTests {
         #expect(filtered.map(\.id) == [42])
     }
 
+    @Test
+    func buildFilterPrioritizesActionableStates() {
+        let jobs = [
+            makeJob(id: 1, status: .success, tags: []),
+            makeJob(id: 2, status: .failed, tags: []),
+            makeJob(id: 3, status: .running, tags: []),
+            makeJob(id: 4, status: .cancelled, tags: [])
+        ]
+
+        let attention = BuildListViewModel.filterJobs(jobs, filter: .attention)
+        let active = BuildListViewModel.filterJobs(jobs, filter: .active)
+
+        #expect(attention.map(\.id) == [2, 3])
+        #expect(active.map(\.id) == [3])
+    }
+
     private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] {
         let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
         guard !q.isEmpty else { return jobs }
@@ -39,12 +55,12 @@ struct BuildListViewModelTests {
         }
     }
 
-    private func makeJob(id: Int, tags: [String]) -> JobSummary {
+    private func makeJob(id: Int, status: JobStatus = .success, tags: [String]) -> JobSummary {
         JobSummary(
             id: id,
             created: Date(),
             updated: Date(),
-            status: .success,
+            status: status,
             note: nil,
             tags: tags,
             visibility: nil,
diff --git a/HutchTests/HomeViewModelTests.swift b/HutchTests/HomeViewModelTests.swift
index fef93e0..72f2e71 100644
--- a/HutchTests/HomeViewModelTests.swift
+++ b/HutchTests/HomeViewModelTests.swift
@@ -59,6 +59,19 @@ struct HomeViewModelTests {
         #expect(repository?.name == "hutch")
     }
 
+    @Test
+    func buildItemsAreSortedForTriage() {
+        let jobs = [
+            makeJob(id: 1, status: .success, created: Date(timeIntervalSince1970: 10)),
+            makeJob(id: 2, status: .running, created: Date(timeIntervalSince1970: 20)),
+            makeJob(id: 3, status: .failed, created: Date(timeIntervalSince1970: 30))
+        ]
+
+        let sorted = HomeViewModel.buildItems(from: jobs)
+
+        #expect(sorted.map(\.job.id) == [3, 2, 1])
+    }
+
     private func makeJob(id: Int, status: JobStatus, created: Date) -> HomeJobPayload {
         HomeJobPayload(
             id: id,
diff --git a/HutchTests/InboxViewModelTests.swift b/HutchTests/InboxViewModelTests.swift
index 597bbc2..a5a0428 100644
--- a/HutchTests/InboxViewModelTests.swift
+++ b/HutchTests/InboxViewModelTests.swift
@@ -165,6 +165,55 @@ struct InboxViewModelTests {
         #expect(senderMatches.map(\.rootEmailID) == [11])
     }
 
+    @Test
+    func inboxFilterSeparatesPatchThreadsFromDiscussionThreads() {
+        let baseList = InboxMailingListReference(
+            id: 1,
+            rid: "list",
+            name: "hutch-devel",
+            owner: Entity(canonicalName: "~owner")
+        )
+        let patchThread = InboxThreadSummary(
+            rootEmailID: 10,
+            rootMessageID: "message-1",
+            threadRootEmailIDs: [10],
+            threadRootMessageIDs: ["message-1"],
+            listID: baseList.id,
+            listRID: baseList.rid,
+            listName: baseList.name,
+            listOwner: baseList.owner,
+            subject: "[PATCH] add search",
+            latestSender: Entity(canonicalName: "~alice"),
+            lastActivityAt: Date(timeIntervalSince1970: 100),
+            messageCount: 1,
+            repo: "hutch",
+            containsPatch: true,
+            isUnread: true
+        )
+        let discussionThread = InboxThreadSummary(
+            rootEmailID: 11,
+            rootMessageID: "message-2",
+            threadRootEmailIDs: [11],
+            threadRootMessageIDs: ["message-2"],
+            listID: baseList.id,
+            listRID: baseList.rid,
+            listName: baseList.name,
+            listOwner: baseList.owner,
+            subject: "release planning",
+            latestSender: Entity(canonicalName: "~bob"),
+            lastActivityAt: Date(timeIntervalSince1970: 200),
+            messageCount: 2,
+            repo: "hutch",
+            containsPatch: false,
+            isUnread: true
+        )
+
+        let threads = [patchThread, discussionThread]
+
+        #expect(InboxViewModel.filterThreads(threads, filter: .patches).map(\.rootEmailID) == [10])
+        #expect(InboxViewModel.filterThreads(threads, filter: .discussions).map(\.rootEmailID) == [11])
+    }
+
     @Test
     func segmentsPatchBodyAndTreatsSignatureAsPlainText() {
         let body = """