# MR 6: Notification inbox Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Close the `notification inbox` parity row — read the inbox, mark things read, and carry the unread count on the Dashboard. **Architecture:** `notifications list` is paginated, so it goes through the existing `PagedListModel` like every other list. `notifications read` marks one or all. The unread badge costs no extra request: `dashboard` already emits `unread` for exactly this purpose and iOS simply does not decode it yet. **Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md` ## Global Constraints - Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s. - Swift Testing only — never XCTest. - `gitbayTests` is hermetic and offline; network goes through `StubProtocol`. - New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit. - The label model is `IssueLabel`, never `Label`. - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer. - Never commit to `main`. **The commands, verbatim from the registry:** ``` notifications list [--all] [--limit ] [--cursor ] notifications read ... | --all ``` `notifications list` **is paginated** — unlike `search` in MR 3 — so it uses `PagedListModel`, which appends `--limit`/`--cursor` itself. The default listing is **unread only**; `--all` includes read ones. **The item shape** (`internal/control/notifications.go:117-125`): ```json {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci", "summary":"build 966 failed: test on runner-podman-exec-144", "path":"krz/gitbay/builds/966","created_at":"2026-09-06T22:14:46.912Z", "read_at":"2026-09-06T22:20:24.040Z"} ``` Everything is always present except `read_at`, which is `omitempty` — **absent means unread**, and that is the common case for the default listing. **Counting tests reliably:** ```bash grep -cE "^Test case '[^']*' passed" /tmp/out.txt grep -cE "^Test case '[^']*' failed" /tmp/out.txt grep -cE "^Test case '[^']*' skipped" /tmp/out.txt grep -rhoE "@Test(\([^)]*\))? func" gitbayTests/*.swift | wc -l ``` `declared - skipped == passed`. One test is always skipped (`LiveInstanceTests`). Baseline: **285 declared, 284 passed, 1 skipped, 0 failed.** --- ## File Structure | File | Responsibility | |------|----------------| | `gitbay/Account/NotificationsViewModel.swift` (create) | `Notification` wire model, routing, and the paged view model | | `gitbayTests/NotificationTests.swift` (create) | Every test in this plan | | `gitbay/Views/Account/NotificationsView.swift` (create) | The inbox screen | | `gitbay/Dashboard/DashboardModels.swift` (modify) | Decode `unread` | | `gitbay/Views/Dashboard/DashboardView.swift` (modify) | Badge + entry point | | `gitbay/Views/Repos/RepoRoute.swift` (modify) | `notifications` case | | `gitbay/ContentView.swift` (modify) | Route to `NotificationsView` | --- ### Task 1: The model, its routing, and the view model **Files:** Create `gitbay/Account/NotificationsViewModel.swift`; test in `gitbayTests/NotificationTests.swift` **Interfaces produced:** - `nonisolated struct Notification: Decodable, Sendable, Hashable, Identifiable` — `id: Int64`, `repo: String`, `kind: String`, `actor: String`, `summary: String`, `path: String`, `createdAt: Date`, `readAt: Date?`; `var isUnread: Bool`; `var destination: NotificationDestination?` - `nonisolated enum NotificationDestination: Hashable, Sendable` — `.issue(repo:number:)`, `.mr(repo:number:)`, `.build(repo:number:)` - `@Observable @MainActor final class NotificationsViewModel` — `list: PagedListModel`, `showAll: Bool`, `state`, `actionError`, `working`, `load()`, `markRead(_:)`, `markAllRead()` **The routing table**, derived from the server (`internal/control/issue.go:153,279,349`, `mr.go:322,702,778,832,1173,1198,1382`, `diffcomment.go:111`, `build.go:522`, `deps/worker.go:249`) and confirmed against live data. `path` is a **web-style route**, `//
/`: | `kind` | `path` | destination | |---------|------------------------------|-------------| | `issue` | `//issues/` | `.issue` | | `mr` | `//mrs/` | `.mr` | | `build` | `//builds/` | `.build` | **The merge request segment is `mrs`, not `merge_requests`.** Matching the wrong spelling produces a silently untappable row — no error, no crash, just a row that does nothing. Parse defensively: split on `/`, require exactly four components, require the last to be an integer, and return `nil` for anything else. A `nil` destination renders an untappable row rather than crashing — the same rule the search results screen follows for a missing number. - [ ] **Step 1: Write the failing tests** Create `gitbayTests/NotificationTests.swift`: ```swift import Foundation import Testing @testable import gitbay private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private func argvFrom(_ url: URL) -> [String] { URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] } private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return try #require(body["argv"] as? [String]) } private func decode(_ json: String) throws -> Notification { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(Notification.self, from: Data(json.utf8)) } private let unreadBuild = """ {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\ "summary":"build 966 failed","path":"krz/gitbay/builds/966",\ "created_at":"2026-09-06T22:14:46Z"} """ struct NotificationDecodingTests { /// read_at is omitempty; absent means unread, and that is the common /// case for the default listing. @Test func anAbsentReadAtMeansUnread() throws { let n = try decode(unreadBuild) #expect(n.readAt == nil) #expect(n.isUnread) } @Test func aPresentReadAtMeansRead() throws { let n = try decode(""" {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\ "summary":"x","path":"krz/gitbay/builds/966",\ "created_at":"2026-09-06T22:14:46Z","read_at":"2026-09-06T22:20:24Z"} """) #expect(n.readAt != nil) #expect(n.isUnread == false) } @Test func everyKindRoutesToItsScreen() throws { let issue = try decode(""" {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\ "path":"krz/gitbay/issues/168","created_at":"2026-09-06T22:14:46Z"} """) #expect(issue.destination == .issue(repo: "krz/gitbay", number: 168)) // The segment is "mrs", not "merge_requests". let mr = try decode(""" {"id":2,"repo":"krz/gitbay","kind":"mr","actor":"cmc","summary":"x",\ "path":"krz/gitbay/mrs/282","created_at":"2026-09-06T22:14:46Z"} """) #expect(mr.destination == .mr(repo: "krz/gitbay", number: 282)) let build = try decode(unreadBuild) #expect(build.destination == .build(repo: "krz/gitbay", number: 966)) } /// A path the app cannot route must yield nil, not a crash and not a /// wrong destination. @Test func anUnroutablePathIsNil() throws { for path in ["krz/gitbay/wiki/Home", "krz/gitbay/issues", "krz/gitbay/issues/notanumber", "too/short", "krz/gitbay/issues/1/extra", ""] { let n = try decode(""" {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\ "path":"\(path)","created_at":"2026-09-06T22:14:46Z"} """) #expect(n.destination == nil, "\(path) should not route") } } /// The repo comes from the path, not the `repo` field, so a /// mismatch cannot send the user to the wrong repository. @Test func theDestinationRepoComesFromThePath() throws { let n = try decode(""" {"id":1,"repo":"other/repo","kind":"issue","actor":"cmc","summary":"x",\ "path":"krz/gitbay/issues/7","created_at":"2026-09-06T22:14:46Z"} """) #expect(n.destination == .issue(repo: "krz/gitbay", number: 7)) } } @MainActor struct NotificationsViewModelTests { private let page = """ {"protocol_version":1,"data":{"items":[\(unreadBuild)]},"exit_code":0} """ @Test func theDefaultListingIsUnreadOnly() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page)) let model = NotificationsViewModel(client: client) await model.load() let argv = argvFrom(try #require(stub.seen.last).url) #expect(argv.prefix(2) == ["notifications", "list"]) #expect(argv.contains("--all") == false) #expect(model.state.value?.count == 1) } @Test func showingAllAddsTheFlag() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page)) let model = NotificationsViewModel(client: client) await model.load() stub.enqueue(.init(status: 200, json: page)) model.showAll = true try await Task.sleep(for: .milliseconds(150)) #expect(argvFrom(try #require(stub.seen.last).url).contains("--all")) } @Test func markingOneReadSendsItsId() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page)) let model = NotificationsViewModel(client: client) await model.load() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) stub.enqueue(.init(status: 200, json: page)) await model.markRead(15) let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["notifications", "read", "15"]) } @Test func markingAllReadUsesTheFlagNotAList() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page)) let model = NotificationsViewModel(client: client) await model.load() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) stub.enqueue(.init(status: 200, json: page)) await model.markAllRead() let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["notifications", "read", "--all"]) } @Test func anEmptyInboxIsAnEmptyStateNotAFailure() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"items":[]},"exit_code":0} """)) let model = NotificationsViewModel(client: client) await model.load() guard case .empty = model.state else { Testing.Issue.record("expected empty, got \(model.state)") return } } @Test func aRefusalSurfacesAndDoesNotReload() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page)) let model = NotificationsViewModel(client: client) await model.load() let before = stub.seen.count stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"denied","exit_code":4} """)) await model.markRead(15) #expect(model.actionError?.isEmpty == false) #expect(model.working == false) // One POST, no reload after a refusal. #expect(stub.seen.count == before + 1) } } ``` - [ ] **Step 2: Run to verify failure.** - [ ] **Step 3: Implement.** Follow `IssueListViewModel` for the `PagedListModel` shape and `RepoSettingsViewModel.perform(argv:)` for the write half (copy it; do not extract a shared helper — that duplication is a standing decision in this codebase). - [ ] **Step 4: Run the tests.** - [ ] **Step 5: Commit** — `git commit -m "Notification inbox model and view model"` --- ### Task 2: The screen, the badge and the route **Files:** Create `gitbay/Views/Account/NotificationsView.swift`; modify `DashboardModels.swift`, `DashboardView.swift`, `RepoRoute.swift`, `ContentView.swift` **The badge is free.** `dashboard` already emits `unread: int` — the server comments it as existing "so a client showing one does not need a second read to fill it" (`internal/control/dashboard.go:78`). iOS does not decode it yet. **`DashboardData` has a hand-written `init(from:)`** (`DashboardModels.swift:27-37`) that decodes each field with `decodeIfPresent ?? []`. Adding `unread` means editing that initialiser — check field by field that you drop nothing, the same care MR 4's `MRDetail` initialiser needed. Decode it as `decodeIfPresent(Int.self, forKey: .unread) ?? 0`, since a server predating the field would omit it. **The screen:** a `List` over the paged notifications with `PageFooter`, a toggle for unread-only versus all, a "Mark all read" action, and per-row swipe-to-mark-read. An unread row should read as unread — the web uses the count, so weight or a dot is enough; do not invent a colour that means nothing elsewhere in the app. Each row shows `actor`, `summary` and the relative `createdAt`, and navigates to `destination` when it is non-nil. A row whose destination is `nil` renders plainly and is not tappable. **Entry point:** a toolbar bell on the Dashboard carrying the unread count, matching where the web puts it (its rail). `DashboardView` already has a `.toolbar` with a `.topBarTrailing` item — add beside it. Add `RepoRoute.notifications` and its `ContentView` destination. - [ ] **Step 1: Decode `unread` in `DashboardData`** - [ ] **Step 2: `NotificationsView`** - [ ] **Step 3: Route case + `ContentView` destination** - [ ] **Step 4: Dashboard bell with the count** - [ ] **Step 5: Build and run the full suite.** No drop. - [ ] **Step 6: Commit** — `git commit -m "Notification inbox screen and dashboard badge"` --- ### Task 3: Flip the parity row and open the merge request - [ ] **Step 1:** In the Accounts table, `notification inbox` goes to `yes` for iOS. Touch no other row. Land it via a worktree off `origin/main` in `krz/gitbay`, merged `--strategy ff`. That repo requires signed commits, so squash and rebase merges are refused, and its working tree usually holds unrelated work — never switch its branch. - [ ] **Step 2:** Run the full suite; record the real number. - [ ] **Step 3:** Open the merge request. --- ## Notes for whoever executes this **`notifications list` IS paginated.** Use `PagedListModel`. This differs from `search` in MR 3, which is not. **The merge request path segment is `mrs`.** Not `merge_requests`. The wrong spelling gives a silently untappable row. **Absent `read_at` means unread**, and it is the common case. Do not require the key. **Take the repo from the path, not the `repo` field** — a test pins this, so a row can never send the user to a different repository than the one its link names. **The badge needs no extra request.** If you find yourself adding a `notifications list` call to fill the Dashboard count, stop: `dashboard` already carries it.