krz/hutch

an ios client for sourcehut

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

94d0b581bbd2be7b63c07fe7f86d736daa96e1f9

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-24T23:28:21Z

v2.4.0: widgets
 Hutch.xcodeproj/project.pbxproj                    | 176 +++++++++++++++-
 .../project.xcworkspace/contents.xcworkspacedata   |   8 +
 Hutch/App/AppState.swift                           |  15 ++
 Hutch/App/DeepLink.swift                           |   4 +
 Hutch/App/RootView.swift                           |   7 +
 Hutch/Hutch.entitlements                           |  10 +
 Hutch/Views/Home/HomeViewModel.swift               | 127 ++++++++----
 Hutch/Views/Inbox/InboxViewModel.swift             |   3 +
 Hutch/Views/Projects/ProjectMailingListView.swift  |   2 +
 .../HutchWidgetExtension.entitlements              |  10 +
 HutchWidgetExtension/Info.plist                    |  29 +++
 HutchWidgetExtension/NeedsAttentionWidget.swift    | 226 +++++++++++++++++++++
 Shared/NeedsAttentionSnapshot.swift                | 109 ++++++++++
 13 files changed, 687 insertions(+), 39 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index be22f3f..ee8e2d6 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -10,6 +10,7 @@
 		8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; };
 		8B2F89672F69DEB900FC0253 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.md */; };
 		8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; };
+		8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -20,6 +21,13 @@
 			remoteGlobalIDString = 8B4B28D02F6704280045FA19;
 			remoteInfo = Hutch;
 		};
+		8BE082022F80000100000001 /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 8B4B28C92F6704280045FA19 /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 8BE081F72F80000100000001;
+			remoteInfo = HutchWidgetExtension;
+		};
 /* End PBXContainerItemProxy section */
 
 /* Begin PBXFileReference section */
@@ -30,6 +38,7 @@
 		8B8182B72F6B73F3000AE049 /* HutchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HutchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 		8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; };
 		8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; };
+		8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -40,6 +49,13 @@
 			);
 			target = 8B4B28D02F6704280045FA19 /* Hutch */;
 		};
+		8BE082032F80000100000001 /* Exceptions for "HutchWidgetExtension" folder in "HutchWidgetExtension" target */ = {
+			isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+			membershipExceptions = (
+				Info.plist,
+			);
+			target = 8BE081F72F80000100000001 /* HutchWidgetExtension */;
+		};
 /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
 
 /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -56,8 +72,35 @@
 			path = HutchTests;
 			sourceTree = "<group>";
 		};
+		8BE081F42F80000100000001 /* Shared */ = {
+			isa = PBXFileSystemSynchronizedRootGroup;
+			path = Shared;
+			sourceTree = "<group>";
+		};
+		8BE081F52F80000100000001 /* HutchWidgetExtension */ = {
+			isa = PBXFileSystemSynchronizedRootGroup;
+			exceptions = (
+				8BE082032F80000100000001 /* Exceptions for "HutchWidgetExtension" folder in "HutchWidgetExtension" target */,
+			);
+			path = HutchWidgetExtension;
+			sourceTree = "<group>";
+		};
 /* End PBXFileSystemSynchronizedRootGroup section */
 
+/* Begin PBXCopyFilesBuildPhase section */
+		8BE081FA2F80000100000001 /* Embed App Extensions */ = {
+			isa = PBXCopyFilesBuildPhase;
+			buildActionMask = 2147483647;
+			dstPath = "";
+			dstSubfolderSpec = 13;
+			files = (
+				8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed App Extensions */,
+			);
+			name = "Embed App Extensions";
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXCopyFilesBuildPhase section */
+
 /* Begin PBXFrameworksBuildPhase section */
 		8B4B28CE2F6704280045FA19 /* Frameworks */ = {
 			isa = PBXFrameworksBuildPhase;
@@ -73,6 +116,13 @@
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
+		8BE081F92F80000100000001 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 /* End PBXFrameworksBuildPhase section */
 
 /* Begin PBXGroup section */
@@ -85,6 +135,8 @@
 				8B2F89642F69DEB900FC0253 /* README.md */,
 				8B2F89652F69DEB900FC0253 /* SECURITY.md */,
 				8B4B28D32F6704280045FA19 /* Hutch */,
+				8BE081F42F80000100000001 /* Shared */,
+				8BE081F52F80000100000001 /* HutchWidgetExtension */,
 				8B8182B82F6B73F3000AE049 /* HutchTests */,
 				8B4B28D22F6704280045FA19 /* Products */,
 			);
@@ -95,6 +147,7 @@
 			children = (
 				8B4B28D12F6704280045FA19 /* Hutch.app */,
 				8B8182B72F6B73F3000AE049 /* HutchTests.xctest */,
+				8BE081F62F80000100000001 /* HutchWidgetExtension.appex */,
 			);
 			name = Products;
 			sourceTree = "<group>";
@@ -109,13 +162,16 @@
 				8B4B28CD2F6704280045FA19 /* Sources */,
 				8B4B28CE2F6704280045FA19 /* Frameworks */,
 				8B4B28CF2F6704280045FA19 /* Resources */,
+				8BE081FA2F80000100000001 /* Embed App Extensions */,
 			);
 			buildRules = (
 			);
 			dependencies = (
+				8BE082042F80000100000001 /* PBXTargetDependency */,
 			);
 			fileSystemSynchronizedGroups = (
 				8B4B28D32F6704280045FA19 /* Hutch */,
+				8BE081F42F80000100000001 /* Shared */,
 			);
 			name = Hutch;
 			packageProductDependencies = (
@@ -147,6 +203,29 @@
 			productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */;
 			productType = "com.apple.product-type.bundle.unit-test";
 		};
+		8BE081F72F80000100000001 /* HutchWidgetExtension */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 8BE081FE2F80000100000001 /* Build configuration list for PBXNativeTarget "HutchWidgetExtension" */;
+			buildPhases = (
+				8BE081F82F80000100000001 /* Sources */,
+				8BE081F92F80000100000001 /* Frameworks */,
+				8BE081FB2F80000100000001 /* Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			fileSystemSynchronizedGroups = (
+				8BE081F42F80000100000001 /* Shared */,
+				8BE081F52F80000100000001 /* HutchWidgetExtension */,
+			);
+			name = HutchWidgetExtension;
+			packageProductDependencies = (
+			);
+			productName = HutchWidgetExtension;
+			productReference = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */;
+			productType = "com.apple.product-type.app-extension";
+		};
 /* End PBXNativeTarget section */
 
 /* Begin PBXProject section */
@@ -182,6 +261,7 @@
 			targets = (
 				8B4B28D02F6704280045FA19 /* Hutch */,
 				8B8182B62F6B73F3000AE049 /* HutchTests */,
+				8BE081F72F80000100000001 /* HutchWidgetExtension */,
 			);
 		};
 /* End PBXProject section */
@@ -204,6 +284,13 @@
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
+		8BE081FB2F80000100000001 /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 /* End PBXResourcesBuildPhase section */
 
 /* Begin PBXSourcesBuildPhase section */
@@ -221,6 +308,13 @@
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
+		8BE081F82F80000100000001 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 /* End PBXSourcesBuildPhase section */
 
 /* Begin PBXTargetDependency section */
@@ -229,6 +323,11 @@
 			target = 8B4B28D02F6704280045FA19 /* Hutch */;
 			targetProxy = 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */;
 		};
+		8BE082042F80000100000001 /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 8BE081F72F80000100000001 /* HutchWidgetExtension */;
+			targetProxy = 8BE082022F80000100000001 /* PBXContainerItemProxy */;
+		};
 /* End PBXTargetDependency section */
 
 /* Begin XCBuildConfiguration section */
@@ -358,8 +457,9 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 9;
+				CURRENT_PROJECT_VERSION = 10;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -376,7 +476,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.3.1;
+				MARKETING_VERSION = 2.4.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -394,8 +494,9 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 9;
+				CURRENT_PROJECT_VERSION = 10;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -412,7 +513,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.3.1;
+				MARKETING_VERSION = 2.4.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -469,6 +570,64 @@
 			};
 			name = Release;
 		};
+		8BE081FC2F80000100000001 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				APPLICATION_EXTENSION_API_ONLY = YES;
+				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 10;
+				DEVELOPMENT_TEAM = ZCNAX3VL9D;
+				GENERATE_INFOPLIST_FILE = NO;
+				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.6;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@executable_path/../../Frameworks",
+				);
+				MARKETING_VERSION = 2.4.0;
+				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SKIP_INSTALL = YES;
+				STRING_CATALOG_GENERATE_SYMBOLS = YES;
+				SWIFT_APPROACHABLE_CONCURRENCY = YES;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Debug;
+		};
+		8BE081FD2F80000100000001 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				APPLICATION_EXTENSION_API_ONLY = YES;
+				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 10;
+				DEVELOPMENT_TEAM = ZCNAX3VL9D;
+				GENERATE_INFOPLIST_FILE = NO;
+				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.6;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@executable_path/../../Frameworks",
+				);
+				MARKETING_VERSION = 2.4.0;
+				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SKIP_INSTALL = YES;
+				STRING_CATALOG_GENERATE_SYMBOLS = YES;
+				SWIFT_APPROACHABLE_CONCURRENCY = YES;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Release;
+		};
 /* End XCBuildConfiguration section */
 
 /* Begin XCConfigurationList section */
@@ -499,6 +658,15 @@
 			defaultConfigurationIsVisible = 0;
 			defaultConfigurationName = Release;
 		};
+		8BE081FE2F80000100000001 /* Build configuration list for PBXNativeTarget "HutchWidgetExtension" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				8BE081FC2F80000100000001 /* Debug */,
+				8BE081FD2F80000100000001 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
 /* End XCConfigurationList section */
 	};
 	rootObject = 8B4B28C92F6704280045FA19 /* Project object */;
diff --git a/Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata
index 919434a..24c0c84 100644
--- a/Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata
+++ b/Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -4,4 +4,12 @@
    <FileRef
       location = "self:">
    </FileRef>
+   <Group
+      location = "group:Shared"
+      name = "Shared">
+   </Group>
+   <Group
+      location = "group:HutchWidgetExtension"
+      name = "HutchWidgetExtension">
+   </Group>
 </Workspace>
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index e74a58b..9788c85 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -77,11 +77,13 @@ final class AppState {
             let user = try await fetchMe()
             currentUser = user
             authPhase = .authenticated
+            await refreshNeedsAttentionSnapshot()
         } catch {
             try? KeychainHelper.deleteToken()
             client.setToken(nil)
             currentUser = nil
             authPhase = .unauthenticated
+            NeedsAttentionSnapshotStore.clear()
         }
     }
 
@@ -98,6 +100,7 @@ final class AppState {
             try KeychainHelper.saveToken(token)
             currentUser = user
             authPhase = .authenticated
+            await refreshNeedsAttentionSnapshot()
         } catch {
             // Roll back — don't leave an invalid token in the client.
             client.setToken(nil)
@@ -111,6 +114,7 @@ final class AppState {
         HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
         await clearWebData()
         clearWebContentRenderCaches()
+        NeedsAttentionSnapshotStore.clear()
         authPhase = .unauthenticated
         selectedTab = .home
     }
@@ -125,6 +129,7 @@ final class AppState {
         HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
         await clearWebData()
         clearWebContentRenderCaches()
+        NeedsAttentionSnapshotStore.clear()
 
         authPhase = .unauthenticated
         selectedTab = .home
@@ -270,6 +275,16 @@ final class AppState {
         selectedTab = .home
     }
 
+    private func refreshNeedsAttentionSnapshot() async {
+        guard let currentUser else {
+            NeedsAttentionSnapshotStore.clear()
+            return
+        }
+
+        let viewModel = HomeViewModel(currentUser: currentUser, client: client)
+        await viewModel.loadDashboard()
+    }
+
     private func clearWebData() async {
         await withCheckedContinuation { continuation in
             let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
diff --git a/Hutch/App/DeepLink.swift b/Hutch/App/DeepLink.swift
index a4b8269..e42bd6b 100644
--- a/Hutch/App/DeepLink.swift
+++ b/Hutch/App/DeepLink.swift
@@ -2,6 +2,7 @@ import Foundation
 
 /// Represents a parsed `hutch://` deep link.
 enum DeepLink: Equatable {
+    case home
     /// hutch://git/<owner>/<repo>
     case repository(owner: String, repo: String)
     /// hutch://todo/<owner>/<tracker>/<ticketId>
@@ -19,6 +20,9 @@ enum DeepLink: Equatable {
         let components = url.pathComponents(fromScheme: "hutch")
 
         switch components.first {
+        case "home", nil:
+            self = .home
+
         case "git" where components.count >= 3:
             let owner = components[1]
             let repo = components[2]
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 9c4e71a..8d4eef1 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -161,6 +161,10 @@ struct RootView: View {
         guard appState.isAuthenticated else { return }
 
         switch link {
+        case .home:
+            homePath = NavigationPath()
+            appState.selectedTab = .home
+
         case .repository(let owner, let repo):
             resolveRepositoryLink(owner: owner, repo: repo)
 
@@ -284,12 +288,15 @@ private struct MoreNavigationRoot: View {
                         thread: thread,
                         onViewed: {
                             InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
+                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
                         },
                         onMarkRead: {
                             InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
+                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
                         },
                         onMarkUnread: {
                             InboxReadStateStore.markUnread(for: thread.id)
+                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
                         }
                     )
                 }
diff --git a/Hutch/Hutch.entitlements b/Hutch/Hutch.entitlements
new file mode 100644
index 0000000..9ed683a
--- /dev/null
+++ b/Hutch/Hutch.entitlements
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>com.apple.security.application-groups</key>
+    <array>
+        <string>group.net.cleberg.Hutch</string>
+    </array>
+</dict>
+</plist>
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index 50b1854..8b4e7aa 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -59,6 +59,7 @@ private struct HomeInboxMailingListThreads: Decodable, Sendable {
 
 private struct HomeInboxThreadPage: Decodable, Sendable {
     let results: [HomeInboxThreadPayload]
+    let cursor: String?
 }
 
 private struct HomeInboxThreadPayload: Decodable, Sendable {
@@ -143,6 +144,7 @@ final class HomeViewModel {
     var assignedTickets: [HomeAssignedTicket] = []
     var recentBuilds: [HomeBuildItem] = []
     private(set) var hasUnreadInboxThreads = false
+    private(set) var unreadInboxThreadCount: Int?
     private(set) var isLoadingProjects = false
     private(set) var isLoadingAssignedTickets = false
     private(set) var isLoadingRecentBuilds = false
@@ -154,7 +156,6 @@ final class HomeViewModel {
     private let projectService: ProjectService
     private let ticketFetchConcurrencyLimit = 6
     private let inboxUnreadConcurrencyLimit = 4
-    private let inboxUnreadThreadPreviewLimit = 10
 
     private static let jobsQuery = """
     query jobs {
@@ -232,13 +233,14 @@ final class HomeViewModel {
     """
 
     private static let inboxListThreadsQuery = """
-    query inboxListThreads($rid: ID!) {
+    query inboxListThreads($rid: ID!, $cursor: Cursor) {
         list(rid: $rid) {
-            threads {
+            threads(cursor: $cursor) {
                 results {
                     updated
                     subject
                 }
+                cursor
             }
         }
     }
@@ -280,7 +282,7 @@ final class HomeViewModel {
         async let projectsTask = loadProjects()
         async let jobsTask = loadRecentJobs()
         async let assignedTicketsTask = loadAssignedTickets()
-        async let inboxUnreadTask = loadInboxUnreadState()
+        async let inboxUnreadTask = loadInboxUnreadCount()
 
         let projectsResult = await projectsTask
         switch projectsResult {
@@ -316,7 +318,9 @@ final class HomeViewModel {
         }
         isLoadingAssignedTickets = false
 
-        hasUnreadInboxThreads = (await inboxUnreadTask) ?? false
+        unreadInboxThreadCount = await inboxUnreadTask
+        hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
+        persistNeedsAttentionSnapshot()
     }
 
     func resolveTicket(_ ticket: HomeAssignedTicket) async {
@@ -347,6 +351,7 @@ final class HomeViewModel {
                 responseType: UnassignResponse.self
             )
             assignedTickets.removeAll { $0.id == ticket.id }
+            persistNeedsAttentionSnapshot()
         } catch {
             homeLogger.error("Unassign from me failed: \(error, privacy: .public)")
         }
@@ -380,6 +385,7 @@ final class HomeViewModel {
                     repositoryOwner: build.repositoryOwner
                 )
             }
+            persistNeedsAttentionSnapshot()
         } catch {
             homeLogger.error("Cancel build failed: \(error, privacy: .public)")
         }
@@ -406,19 +412,21 @@ final class HomeViewModel {
         }
     }
 
-    private func loadInboxUnreadState() async -> Bool? {
+    private func loadInboxUnreadCount() async -> Int? {
         do {
-            return try await fetchHasUnreadInboxThreads()
+            return try await fetchUnreadInboxThreadCount()
         } catch {
             return nil
         }
     }
 
-    private func fetchHasUnreadInboxThreads() async throws -> Bool {
+    private func fetchUnreadInboxThreadCount() async throws -> Int {
         let mailingLists = try await fetchInboxMailingLists()
-        guard !mailingLists.isEmpty else { return false }
+        guard !mailingLists.isEmpty else { return 0 }
 
         var startIndex = mailingLists.startIndex
+        var unreadCount = 0
+        var successfulFetchCount = 0
         while startIndex < mailingLists.endIndex {
             let endIndex = mailingLists.index(
                 startIndex,
@@ -427,30 +435,41 @@ final class HomeViewModel {
             ) ?? mailingLists.endIndex
             let batch = Array(mailingLists[startIndex..<endIndex])
 
-            let batchHasUnread = await withTaskGroup(of: Bool.self) { group in
+            let batchResult = await withTaskGroup(of: Result<Int, Error>.self) { group in
                 for mailingList in batch {
                     group.addTask {
-                        (try? await self.fetchHasUnreadThreads(for: mailingList)) ?? false
+                        do {
+                            return .success(try await self.fetchUnreadThreadCount(for: mailingList))
+                        } catch {
+                            return .failure(error)
+                        }
                     }
                 }
 
-                for await hasUnread in group {
-                    if hasUnread {
-                        group.cancelAll()
-                        return true
+                var counts: [Int] = []
+                var errors: [Error] = []
+                for await result in group {
+                    switch result {
+                    case .success(let count):
+                        counts.append(count)
+                    case .failure(let error):
+                        errors.append(error)
                     }
                 }
-                return false
+                return (counts, errors)
             }
 
-            if batchHasUnread {
-                return true
-            }
+            unreadCount += batchResult.0.reduce(0, +)
+            successfulFetchCount += batchResult.0.count
 
             startIndex = endIndex
         }
 
-        return false
+        guard successfulFetchCount > 0 else {
+            throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
+        }
+
+        return unreadCount
     }
 
     private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
@@ -481,23 +500,40 @@ final class HomeViewModel {
         return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
     }
 
-    private func fetchHasUnreadThreads(for mailingList: InboxMailingListReference) async throws -> Bool {
-        let response = try await client.execute(
-            service: .lists,
-            query: Self.inboxListThreadsQuery,
-            variables: ["rid": mailingList.rid],
-            responseType: HomeInboxListThreadsResponse.self
-        )
+    private func fetchUnreadThreadCount(for mailingList: InboxMailingListReference) async throws -> Int {
+        var unreadCount = 0
+        var cursor: String?
+
+        while true {
+            var variables: [String: any Sendable] = ["rid": mailingList.rid]
+            if let cursor {
+                variables["cursor"] = cursor
+            }
 
-        return response.list.threads.results.prefix(inboxUnreadThreadPreviewLimit).contains { 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)
+            let response = try await client.execute(
+                service: .lists,
+                query: Self.inboxListThreadsQuery,
+                variables: variables,
+                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
+
+            guard let nextCursor = response.list.threads.cursor else {
+                break
+            }
+            cursor = nextCursor
         }
+
+        return unreadCount
     }
 
     private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
@@ -616,11 +652,32 @@ final class HomeViewModel {
                 responseType: StatusEventResponse.self
             )
             assignedTickets.removeAll { $0.id == ticket.id }
+            persistNeedsAttentionSnapshot()
         } catch {
             homeLogger.error("Ticket status update failed: \(error, privacy: .public)")
         }
     }
 
+    private func persistNeedsAttentionSnapshot() {
+        NeedsAttentionSnapshotStore.save(
+            NeedsAttentionSnapshot(
+                unreadInboxThreads: unreadInboxThreadCount,
+                assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil,
+                failedBuilds: recentBuildsError == nil
+                    ? recentBuilds.filter {
+                        switch $0.job.status {
+                        case .failed, .timeout:
+                            true
+                        default:
+                            false
+                        }
+                    }.count
+                    : nil,
+                updatedAt: .now
+            )
+        )
+    }
+
     nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
         jobs.map { job in
             let repository = primaryRepositoryReference(in: job.manifest)
diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift
index 42ce223..1d4cfd3 100644
--- a/Hutch/Views/Inbox/InboxViewModel.swift
+++ b/Hutch/Views/Inbox/InboxViewModel.swift
@@ -136,6 +136,7 @@ final class InboxViewModel {
                 }
                 return lhs.lastActivityAt > rhs.lastActivityAt
             }
+            NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count)
         } catch {
             inboxListLogger.error("Inbox request failed")
             self.error = "Failed to load inbox"
@@ -146,11 +147,13 @@ final class InboxViewModel {
         let viewedAt = max(Date(), thread.lastActivityAt)
         InboxReadStateStore.markViewed(viewedAt, for: thread.id)
         threads.removeAll { $0.id == thread.id }
+        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
     }
 
     func markThreadUnread(_ thread: InboxThreadSummary) {
         InboxReadStateStore.markUnread(for: thread.id)
         updateThread(thread, isUnread: true)
+        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
     }
 
     func toggleThreadReadState(_ thread: InboxThreadSummary) {
diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift
index e044efb..39645be 100644
--- a/Hutch/Views/Projects/ProjectMailingListView.swift
+++ b/Hutch/Views/Projects/ProjectMailingListView.swift
@@ -92,11 +92,13 @@ final class MailingListDetailViewModel {
         let viewedAt = max(Date(), thread.lastActivityAt)
         InboxReadStateStore.markViewed(viewedAt, for: thread.id)
         updateThread(thread, isUnread: false)
+        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
     }
 
     func markThreadUnread(_ thread: InboxThreadSummary) {
         InboxReadStateStore.markUnread(for: thread.id)
         updateThread(thread, isUnread: true)
+        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
     }
 
     private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
diff --git a/HutchWidgetExtension/HutchWidgetExtension.entitlements b/HutchWidgetExtension/HutchWidgetExtension.entitlements
new file mode 100644
index 0000000..9ed683a
--- /dev/null
+++ b/HutchWidgetExtension/HutchWidgetExtension.entitlements
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>com.apple.security.application-groups</key>
+    <array>
+        <string>group.net.cleberg.Hutch</string>
+    </array>
+</dict>
+</plist>
diff --git a/HutchWidgetExtension/Info.plist b/HutchWidgetExtension/Info.plist
new file mode 100644
index 0000000..7b41344
--- /dev/null
+++ b/HutchWidgetExtension/Info.plist
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>CFBundleDevelopmentRegion</key>
+    <string>$(DEVELOPMENT_LANGUAGE)</string>
+    <key>CFBundleDisplayName</key>
+    <string>Needs Attention</string>
+    <key>CFBundleExecutable</key>
+    <string>$(EXECUTABLE_NAME)</string>
+    <key>CFBundleIdentifier</key>
+    <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+    <key>CFBundleInfoDictionaryVersion</key>
+    <string>6.0</string>
+    <key>CFBundleName</key>
+    <string>$(PRODUCT_NAME)</string>
+    <key>CFBundlePackageType</key>
+    <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
+    <key>CFBundleShortVersionString</key>
+    <string>$(MARKETING_VERSION)</string>
+    <key>CFBundleVersion</key>
+    <string>$(CURRENT_PROJECT_VERSION)</string>
+    <key>NSExtension</key>
+    <dict>
+        <key>NSExtensionPointIdentifier</key>
+        <string>com.apple.widgetkit-extension</string>
+    </dict>
+</dict>
+</plist>
diff --git a/HutchWidgetExtension/NeedsAttentionWidget.swift b/HutchWidgetExtension/NeedsAttentionWidget.swift
new file mode 100644
index 0000000..fff030f
--- /dev/null
+++ b/HutchWidgetExtension/NeedsAttentionWidget.swift
@@ -0,0 +1,226 @@
+import SwiftUI
+import WidgetKit
+
+struct NeedsAttentionEntry: TimelineEntry {
+    let date: Date
+    let snapshot: NeedsAttentionSnapshot?
+}
+
+struct NeedsAttentionTimelineProvider: TimelineProvider {
+    func placeholder(in context: Context) -> NeedsAttentionEntry {
+        NeedsAttentionEntry(
+            date: .now,
+            snapshot: NeedsAttentionSnapshot(
+                unreadInboxThreads: 3,
+                assignedOpenTickets: 2,
+                failedBuilds: 1,
+                updatedAt: .now
+            )
+        )
+    }
+
+    func getSnapshot(in context: Context, completion: @escaping (NeedsAttentionEntry) -> Void) {
+        completion(
+            NeedsAttentionEntry(
+                date: .now,
+                snapshot: NeedsAttentionSnapshotStore.load()
+            )
+        )
+    }
+
+    func getTimeline(in context: Context, completion: @escaping (Timeline<NeedsAttentionEntry>) -> Void) {
+        let refreshDate = Calendar.current.date(byAdding: .minute, value: 20, to: .now) ?? .now.addingTimeInterval(1200)
+        let entry = NeedsAttentionEntry(date: .now, snapshot: NeedsAttentionSnapshotStore.load())
+        completion(Timeline(entries: [entry], policy: .after(refreshDate)))
+    }
+}
+
+struct NeedsAttentionWidget: Widget {
+    static let kind = NeedsAttentionWidgetConfiguration.kind
+
+    var body: some WidgetConfiguration {
+        StaticConfiguration(kind: Self.kind, provider: NeedsAttentionTimelineProvider()) { entry in
+            NeedsAttentionWidgetView(entry: entry)
+        }
+        .configurationDisplayName("Needs Attention")
+        .description("Unread inbox threads, assigned tickets, and failed builds.")
+        .supportedFamilies([.systemSmall, .systemMedium])
+    }
+}
+
+private struct NeedsAttentionWidgetView: View {
+    let entry: NeedsAttentionEntry
+    @Environment(\.widgetFamily) private var family
+
+    var body: some View {
+        Group {
+            if let snapshot = entry.snapshot, snapshot.hasAnyData {
+                if snapshot.allCountsAreZero {
+                    clearState
+                } else {
+                    switch family {
+                    case .systemMedium:
+                        mediumView(snapshot: snapshot)
+                    default:
+                        smallView(snapshot: snapshot)
+                    }
+                }
+            } else {
+                fallbackState
+            }
+        }
+        .widgetURL(URL(string: "hutch://home"))
+        .containerBackground(for: .widget) {
+            Color(.systemBackground)
+        }
+    }
+
+    private var header: some View {
+        VStack(alignment: .leading, spacing: 1) {
+            Text("Hutch")
+                .font(.caption.weight(.semibold))
+                .foregroundStyle(.secondary)
+            Text("Needs Attention")
+                .font(.headline)
+                .lineLimit(1)
+        }
+    }
+
+    private func smallView(snapshot: NeedsAttentionSnapshot) -> some View {
+        VStack(alignment: .leading, spacing: 12) {
+            Spacer(minLength: 0)
+            VStack(alignment: .leading, spacing: 8) {
+                compactMetric(
+                    count: snapshot.unreadInboxThreads,
+                    label: "Unread",
+                    tint: unreadTint(for: snapshot.unreadInboxThreads)
+                )
+                compactMetric(count: snapshot.assignedOpenTickets, label: "Assigned")
+                compactMetric(
+                    count: snapshot.failedBuilds,
+                    label: "Failed",
+                    tint: failedTint(for: snapshot.failedBuilds)
+                )
+            }
+            Spacer(minLength: 0)
+        }
+        .padding()
+    }
+
+    private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View {
+        VStack(alignment: .leading, spacing: 18) {
+            header
+                .padding(.top, 6)
+            HStack(alignment: .top, spacing: 12) {
+                metricColumn(
+                    count: snapshot.unreadInboxThreads,
+                    label: "Unread",
+                    systemImage: "tray",
+                    tint: unreadTint(for: snapshot.unreadInboxThreads)
+                )
+                metricColumn(
+                    count: snapshot.assignedOpenTickets,
+                    label: "Assigned",
+                    systemImage: "ticket"
+                )
+                metricColumn(
+                    count: snapshot.failedBuilds,
+                    label: "Failed",
+                    systemImage: "hammer",
+                    tint: failedTint(for: snapshot.failedBuilds)
+                )
+            }
+            Spacer(minLength: 0)
+        }
+        .padding(.horizontal, 16)
+        .padding(.top, 18)
+        .padding(.bottom, 16)
+    }
+
+    private var clearState: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            header
+            Spacer(minLength: 0)
+            Text("All clear")
+                .font(.title3.weight(.semibold))
+            Text("No unread threads, assigned tickets, or failed builds.")
+                .font(.caption)
+                .foregroundStyle(.secondary)
+                .fixedSize(horizontal: false, vertical: true)
+        }
+        .padding()
+    }
+
+    private var fallbackState: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            header
+            Spacer(minLength: 0)
+            Text("Open Hutch")
+                .font(.title3.weight(.semibold))
+            Text("Unable to load recent counts.")
+                .font(.caption)
+                .foregroundStyle(.secondary)
+        }
+        .padding()
+    }
+
+    private func compactMetric(count: Int?, label: String, tint: Color = .primary) -> some View {
+        HStack(alignment: .center, spacing: 6) {
+            Text(displayCount(count))
+                .font(.title2.weight(.semibold))
+                .monospacedDigit()
+                .foregroundStyle(tint)
+            Text(label)
+                .font(.subheadline)
+                .foregroundStyle(.secondary)
+            Spacer(minLength: 0)
+        }
+    }
+
+    private func metricColumn(count: Int?, label: String, systemImage: String, tint: Color = .primary) -> some View {
+        VStack(alignment: .leading, spacing: 8) {
+            HStack(alignment: .center, spacing: 10) {
+                Image(systemName: systemImage)
+                    .font(.title3.weight(.semibold))
+                    .foregroundStyle(tint)
+                    .frame(width: 24, alignment: .leading)
+                    .padding(.trailing, 4)
+                Text(displayCount(count))
+                    .font(.system(size: 28, weight: .semibold, design: .rounded))
+                    .monospacedDigit()
+                    .lineLimit(1)
+                    .minimumScaleFactor(0.8)
+                    .foregroundStyle(tint)
+            }
+            Text(label)
+                .font(.caption)
+                .foregroundStyle(.secondary)
+                .lineLimit(1)
+                .minimumScaleFactor(0.85)
+                .frame(height: 16, alignment: .topLeading)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+    }
+
+    private func displayCount(_ count: Int?) -> String {
+        guard let count else { return "—" }
+        return "\(count)"
+    }
+
+    private func unreadTint(for count: Int?) -> Color {
+        guard let count, count > 0 else { return .primary }
+        return .blue
+    }
+
+    private func failedTint(for count: Int?) -> Color {
+        guard let count else { return .primary }
+        return count > 0 ? .red : .green
+    }
+}
+
+@main
+struct HutchWidgets: WidgetBundle {
+    var body: some Widget {
+        NeedsAttentionWidget()
+    }
+}
diff --git a/Shared/NeedsAttentionSnapshot.swift b/Shared/NeedsAttentionSnapshot.swift
new file mode 100644
index 0000000..f386af6
--- /dev/null
+++ b/Shared/NeedsAttentionSnapshot.swift
@@ -0,0 +1,109 @@
+import Foundation
+#if canImport(WidgetKit)
+import WidgetKit
+#endif
+
+enum HutchAppGroup {
+    static let identifier = "group.net.cleberg.Hutch"
+}
+
+enum NeedsAttentionWidgetConfiguration {
+    static let kind = "NeedsAttentionWidget"
+}
+
+struct NeedsAttentionSnapshot: Codable, Sendable {
+    let unreadInboxThreads: Int?
+    let assignedOpenTickets: Int?
+    let failedBuilds: Int?
+    let updatedAt: Date
+
+    static let unavailable = NeedsAttentionSnapshot(
+        unreadInboxThreads: nil,
+        assignedOpenTickets: nil,
+        failedBuilds: nil,
+        updatedAt: .now
+    )
+
+    var hasAnyData: Bool {
+        unreadInboxThreads != nil || assignedOpenTickets != nil || failedBuilds != nil
+    }
+
+    var allCountsAreZero: Bool {
+        let counts = [unreadInboxThreads, assignedOpenTickets, failedBuilds].compactMap { $0 }
+        return !counts.isEmpty && counts.allSatisfy { $0 == 0 }
+    }
+}
+
+enum NeedsAttentionSnapshotStore {
+    private static let snapshotKey = "needsAttention.snapshot"
+
+    static func load(defaults: UserDefaults? = sharedDefaults()) -> NeedsAttentionSnapshot? {
+        guard let defaults,
+              let data = defaults.data(forKey: snapshotKey) else {
+            return nil
+        }
+
+        return try? JSONDecoder().decode(NeedsAttentionSnapshot.self, from: data)
+    }
+
+    static func save(_ snapshot: NeedsAttentionSnapshot, defaults: UserDefaults? = sharedDefaults()) {
+        guard let defaults,
+              let data = try? JSONEncoder().encode(snapshot) else {
+            return
+        }
+
+        defaults.set(data, forKey: snapshotKey)
+        reloadWidgetTimelines()
+    }
+
+    static func update(
+        unreadInboxThreads: Int? = nil,
+        assignedOpenTickets: Int? = nil,
+        failedBuilds: Int? = nil,
+        defaults: UserDefaults? = sharedDefaults()
+    ) {
+        let existing = load(defaults: defaults)
+        let snapshot = NeedsAttentionSnapshot(
+            unreadInboxThreads: unreadInboxThreads ?? existing?.unreadInboxThreads,
+            assignedOpenTickets: assignedOpenTickets ?? existing?.assignedOpenTickets,
+            failedBuilds: failedBuilds ?? existing?.failedBuilds,
+            updatedAt: .now
+        )
+        save(snapshot, defaults: defaults)
+    }
+
+    static func adjustUnreadInboxThreads(
+        by delta: Int,
+        defaults: UserDefaults? = sharedDefaults()
+    ) {
+        guard let existing = load(defaults: defaults),
+              let unreadInboxThreads = existing.unreadInboxThreads else {
+            return
+        }
+
+        save(
+            NeedsAttentionSnapshot(
+                unreadInboxThreads: max(0, unreadInboxThreads + delta),
+                assignedOpenTickets: existing.assignedOpenTickets,
+                failedBuilds: existing.failedBuilds,
+                updatedAt: .now
+            ),
+            defaults: defaults
+        )
+    }
+
+    static func clear(defaults: UserDefaults? = sharedDefaults()) {
+        defaults?.removeObject(forKey: snapshotKey)
+        reloadWidgetTimelines()
+    }
+
+    private static func sharedDefaults() -> UserDefaults? {
+        UserDefaults(suiteName: HutchAppGroup.identifier)
+    }
+
+    private static func reloadWidgetTimelines() {
+        #if canImport(WidgetKit)
+        WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind)
+        #endif
+    }
+}