internal/tui/components/notificationssection/DESIGN.md
The notifications feature adds a new view to gh-dash that displays GitHub notifications, allowing users to triage their inbox directly from the terminal. The implementation follows the existing patterns established by the PR and Issue views.
internal/
├── data/
│ ├── notificationapi.go # GitHub API interactions for notifications
│ ├── bookmarks.go # Local bookmark storage (singleton)
│ ├── donestore.go # Timestamp-based Done tracking (singleton)
│ ├── donestore_test.go # Tests for Done store
│ └── donestore_testing.go # Test helpers (create/override DoneStore)
├── tui/
│ ├── keys/
│ │ └── notificationKeys.go # Key bindings specific to notifications
│ └── components/
│ ├── notificationrow/
│ │ ├── data.go # Data model implementing RowData interface
│ │ ├── data_test.go # Tests for data accessors
│ │ ├── notificationrow.go # Row rendering for the table
│ │ └── notificationrow_test.go # Tests for rendering logic
│ ├── notificationssection/
│ │ ├── notificationssection.go # Main section component
│ │ ├── commands.go # Tea commands (mark done, mark read, diff, checkout, etc.)
│ │ ├── commands_test.go # Tests for command functions
│ │ └── filters_test.go # Tests for filter parsing
│ └── notificationview/
│ └── notificationview.go # Detail view in sidebar
Unlike PRs and Issues which auto-fetch content when selected, notifications require an explicit action (Enter key) to view content. This design choice exists because:
When a notification is selected but not yet viewed, a prompt is displayed in the Preview pane:
Press [Enter] to view the PR
(Note: this will mark it as read)
Other Actions
[D] mark as done
[m] mark as read
[u] unsubscribe
[b] toggle bookmark
[t] toggle filtering
[S] sort by repo
[o] open in browser
[Enter] view
GitHub REST API
│
▼
notificationapi.go (FetchNotifications)
│
▼
notificationssection.go (stores []notificationrow.Data)
│
├──▶ notificationrow.go (renders table rows)
│
└──▶ notificationview.go (renders sidebar detail)
OR
renderNotificationPrompt() (shows action prompt)
For PR and Issue notifications, the system fetches additional data to show new comment counts:
LastReadAt timestamp with comment timestampsFor PR and Issue notifications, the username of the person who triggered the notification is displayed:
latest_comment_url (the comment that triggered the notification)theme.colors.text.actor (defaults to secondary text color)Each notification row displays three lines of information:
repo/name #123 🔖 +5💬 2d ago
Title of the notification (bold if unread)
@username commented on this pull request
(The 🔖 bookmark icon only appears if the notification is bookmarked)
The three lines use distinct colors to create visual hierarchy: line 1 is secondary, line 2 is primary/bold, and line 3 is faint.
Unread indicator: A blue dot is displayed below the notification type icon for unread notifications. This is the sole indicator of read/unread status—text is not dimmed for read notifications.
Activity descriptions are generated based on the notification reason:
comment: "@username commented on this pull request/issue"review_requested: "@username requested your review" or "Review requested"mention: "@username mentioned you"author: "Activity on your thread"assign: "You were assigned"state_change: "Pull request/Issue state changed"ci_activity: "CI activity"subscribed: "@username commented on this pull request/issue"Notification titles from GitHub's API may contain control characters (e.g., trailing \r) that corrupt terminal rendering. The GetTitle() method sanitizes titles by:
\r)\n) with spacesThe table component applies cell styling to each line individually in multi-line content. This ensures the background color (for selected rows) extends properly across the entire cell.
To preserve parent background colors, row content uses raw ANSI escape codes for foreground styling without trailing reset sequences. The utils.GetStylePrefix() helper extracts ANSI codes from lipgloss styles while stripping the reset, preventing internal resets from breaking the cell's background color.
Title truncation is handled dynamically by the table component based on actual column width, with ellipsis added when content is truncated. This allows titles to adjust when the sidebar is shown/hidden.
Both bookmarks and Done status are tracked locally because GitHub’s API doesn’t provide these features.
Bookmarks use the generic NotificationIDStore type in data/bookmarks.go:
type NotificationIDStore struct {
ids map[string]bool
filePath string
// ... mutex, name for logging
}
~/.local/state/gh-dash/bookmarks.json as a JSON array of IDsdata.GetBookmarkStore() singletonis:unread, bookmarked+read items are excludedDone tracking uses its own DoneStore type in data/donestore.go, separate from the bookmark store. This is necessary because GitHub’s “mark as done” API (DELETE /notifications/threads/{id}) doesn’t actually delete notifications — they still appear in API responses with all=true. Without local tracking, Done notifications would reappear when filtering to is:read or is:all.
The DoneStore records timestamps, not just IDs:
type DoneStore struct {
entries map[string]time.Time // id -> updatedAt when marked done
filePath string
// ... mutex
}
When marking a notification as Done, the store records the notification’s current updated_at timestamp. When checking whether a notification is still Done, IsDone(id, updatedAt) compares the stored timestamp against the notification’s current updated_at: if the notification has been updated since it was Done (new comments, state changes, etc.), it resurfaces automatically. This prevents notifications with new activity from being permanently hidden.
~/.local/state/gh-dash/done.json as a JSON object mapping IDs to RFC 3339 timestamps: {"id": "2024-01-15T10:30:00Z", ...}data.GetDoneStore() singletonPruning: On load, the DoneStore removes stale entries, to prevent the file from growing indefinitely. Entries older than 90 days are pruned — because those are unlikely to still appear in API responses. Zero-time entries (from the legacy format) are also pruned, since removing them from the store has the same effect as keeping them: IsDone returns false either way, so active notifications still resurface.
Pagination with local filtering: Because Done notifications are filtered out locally after fetching from the API, a single page of results may yield very few visible notifications. To handle this, the fetch logic automatically requests additional pages from the API until the requested limit is reached or all pages are exhausted. This ensures users see a full page of results even when many notifications have been marked as Done.
The unsubscribe feature allows users to stop receiving notifications for a thread:
DELETE /notifications/threads/{id}/subscription APINotification state (read/unread, done) is tracked both:
notificationrow.Data struct for immediate UI updatesThe UpdateNotificationMsg and UpdateNotificationReadStateMsg types propagate state changes through the Bubble Tea update cycle.
Session persistence for read notifications: When a notification is marked as read (via m key or by viewing it), its ID is tracked in sessionMarkedRead. These notifications remain visible in the inbox even during automatic refreshes (e.g., when the terminal regains focus). They are only removed when:
Session persistence for Done notifications: When a notification is marked as Done, its ID is tracked in sessionMarkedDone in addition to being persisted to the DoneStore. The session map provides immediate filtering without waiting for the async DoneStore save to complete. Like sessionMarkedRead, it is cleared on manual refresh.
Notification commands are organized in commands.go, following the pattern established by prssection (which has checkout.go, diff.go). Commands fall into two categories:
Section methods — Commands that operate on section state and are invoked via key handling in the section's Update method:
markAsDone() — Marks the current notification as Done (persists ID + updated_at timestamp to DoneStore). Important: This captures updatedAt by value before the closure — because GetCurrNotification() returns a pointer into the Notifications slice, which may shift when other notifications are removed concurrently.markAllAsDone() — Marks all visible notifications as Done (persists each ID + updated_at to DoneStore)markAsRead() — Marks the current notification as readmarkAllAsRead() — Marks all notifications as readunsubscribe() — Unsubscribes from the current threadopenInBrowser() — Marks as read and opens in browserStandalone functions — Commands that require data from outside the section (e.g., the PR shown in the sidebar). These are called from ui.go with the necessary parameters:
DiffPR(ctx, prNumber, repoName) — Opens a diff view for a PRCheckoutPR(ctx, prNumber, repoName) — Checks out a PR branch locallyThis split exists because diff and checkout operate on the PR/Issue content shown in the notificationView sidebar, not the notification row itself. When viewing a PR notification, the sidebar displays the full PR details, and diff/checkout actions use that data. The section doesn't have access to this enriched data, so ui.go extracts the PR details from notificationView and passes them to the standalone functions.
Key events are routed through ui.go, which either:
updateSection() for section methodsnotificationrow.Data implements the data.RowData interface:
type RowData interface {
GetRepoNameWithOwner() string
GetNumber() int
GetTitle() string
GetUrl() string
}
GetNumber() extracts the PR/Issue number from the subject URLGetUrl() constructs the GitHub web URL from the API URLCommon styling functions were extracted to common/styles.go:
RenderPreviewHeader() — renders the repo/type header line with backgroundRenderPreviewTitle() — renders the title block with background highlightThese are used by PR view, Issue view, notification view, and notification prompt for consistent appearance.
The table component was extended to support per-column alignment via an Align property on the Column struct. This allows the comment count column to be right-aligned.
| Key | Action |
|---|---|
| D | Mark as done (removes from inbox) |
| Alt+d | Mark all as done |
| m | Mark as read |
| M | Mark all as read |
| u | Unsubscribe from thread |
| b | Toggle bookmark |
| t | Toggle smart filtering (filter to current repo) |
| y | Copy PR/Issue number |
| Y | Copy URL |
| S | Sort by repository |
| s | Switch to PRs view |
| o | Open in browser |
| Enter | View notification (fetches content, marks as read) |
When viewing a PR notification in the preview pane, all PR-specific keybindings become available:
| Key | Action |
|---|---|
| v | Approve PR |
| a | Assign |
| A | Unassign |
| c | Comment |
| d | View diff |
| C/Space | Checkout branch |
| x | Close PR |
| X | Reopen PR |
| W | Mark ready for review |
| m | Merge PR |
| u | Update from base branch |
| w | Watch checks |
| [ | Previous sidebar tab |
| ] | Next sidebar tab |
| e | Expand description |
Similarly, when viewing an Issue notification, Issue-specific keybindings are available:
| Key | Action |
|---|---|
| L | Add/remove labels |
| a | Assign |
| A | Unassign |
| c | Comment |
| x | Close issue |
| X | Reopen issue |
The ? help display dynamically updates to show the applicable keybindings based on what type of notification content is being viewed.
When viewing a PR or Issue notification, destructive actions (close, reopen, merge, etc.) require confirmation before execution. This uses a footer-based confirmation mechanism separate from the section-level confirmation used in PR/Issue views:
x for close)y, Y, or Enter to confirm, any other key cancelstasks package (same as PR/Issue views)This design is necessary because:
notificationView, not in the sectionThe confirmation state is managed by notificationView.Model:
pendingAction field tracks the pending action (e.g., "pr_close", "issue_reopen")SetPendingPRAction() / SetPendingIssueAction() set the pending action and return the confirmation prompt textUpdate() method handles confirmation key presses (y/Y/Enter to confirm, any other key cancels)onConfirmAction callback is invoked when confirmed, which ui.go sets to executeNotificationAction()This encapsulation keeps confirmation logic close to the view that displays it, while ui.go coordinates between the footer prompt and action execution.
User-defined keybindings (configured under keybindings.prs and keybindings.issues in config.yml) are also supported in the Notifications view. When a notification’s subject is a PR or Issue, the corresponding custom keybindings are recognized and dispatched.
The command template receives different fields depending on whether the sidebar has been opened:
| State | PR template fields |
|---|---|
| Sidebar not open | RepoName, PrNumber, RepoPath |
| Sidebar open | + HeadRefName, BaseRefName, Author |
| State | Issue template fields |
|---|---|
| Sidebar not open | RepoName, IssueNumber, RepoPath |
| Sidebar open | + Author |
If a template references a sidebar-only field (e.g., {{.HeadRefName}}) before the sidebar is opened, the template engine’s missingkey=error option produces an error message. This is intentional — users should open the notification first to populate the full data.
The implementation in modelUtils.go checks notificationView.GetSubjectPR() / GetSubjectIssue() and enriches the template fields map when the subject data is available. This avoids an extra API call — the sidebar fetch that already happened is reused.
Like PRs and Issues, the Notifications view includes a search section (indicated by a magnifying glass icon 🔍) as the first tab. This serves as a scratchpad for one-off searches without modifying your configured sections.
archived:falsesmartFilteringAtLaunch: when enabled and running from a git repository, the search automatically scopes to that repo/ key to focus the search bar and enter custom queriesis:unread, is:read, repo:owner/name, reason:*Notifications support multiple configurable sections, similar to PRs and Issues. Each section appears as a tab and filters notifications by reason:
notificationsSections:
- title: All
filters: ""
- title: Created
filters: "reason:author"
- title: Participating
filters: "reason:participating"
- title: Mentioned
filters: "reason:mention"
- title: Review Requested
filters: "reason:review-requested"
- title: Assigned
filters: "reason:assign"
- title: Subscribed
filters: "reason:subscribed"
- title: Team Mentioned
filters: "reason:team-mention"
These are the default sections. Users can customize by defining their own notificationsSections in config.yml.
The reason: filter matches GitHub's notification reason field:
| Filter | Description |
|---|---|
reason:author | Activity on threads you created |
reason:comment | Someone commented on a thread you're subscribed to |
reason:mention | You were @mentioned |
reason:review-requested | Your review was requested on a PR |
reason:assign | You were assigned |
reason:subscribed | Activity on threads you're watching |
reason:team-mention | Your team was @mentioned |
reason:state-change | Thread state changed (merged, closed, etc.) |
reason:ci-activity | CI workflow activity |
reason:participating | Meta-filter: expands to author, comment, mention, review-requested, assign, state-change |
Reason filters are applied client-side after fetching from GitHub's API.
The initial fetch limit is controlled by defaults.notificationsLimit (default: 20, matching PRs and Issues). Additional notifications are fetched automatically as the user scrolls through the list.
defaults:
notificationsLimit: 20
Notifications respect the global smartFilteringAtLaunch setting (enabled by default). When enabled and running from within a git repository, notifications are automatically scoped to that repository. The search bar displays repo:owner/name to indicate this filtering.
Users can:
t to toggle filtering on/off for the current sessionrepo: filter; submitting with Enter syncs the smart filter state to matchsmartFilteringAtLaunch: false in config to disable this behavior globallyGitHub's API returns subject.url=null for CheckSuite notifications, making it impossible to directly link to the specific workflow run. To work around this:
/actions page as a fallback/repos/{owner}/{repo}/actions/runsupdated_at timestampThis async resolution uses the existing UpdateNotificationUrlMsg message type, following the same pattern as async comment count fetching for PRs and Issues.
~/.local/state/gh-dash/) and are not synced across machines or with GitHub.all=true. We track Done IDs with timestamps locally to filter them out and detect new activity. Entries older than 90 days are pruned on startup.