This branch introduces per-user, commit-aware review tracking for the forge inbox in pika-news. Previously, inbox items were binary: present or dismissed. Now each inbox entry tracks the last artifact (commit) a user actually reviewed via a new last_reviewed_artifact_id column. When a user opens a branch detail page, the frontend automatically marks the current head as reviewed. If new commits arrive and produce a new artifact, the item resurfaces as needing review without losing its inbox presence. The inbox list API returns both total active items and a separate review-needed count, the sort order changes from creation-time ascending to needs-review-first with most-recently-updated descending, and navigation helpers filter to only unreviewed items. A new POST endpoint /news/api/inbox/reviewed/:pr_id exposes the mark-reviewed operation, and the re-inbox reason string changes from generation_ready to new_commits to reflect the updated semantics.
Tutorial Steps
Add review-progress columns via migration
Intent: Extend the `branch_inbox_states` table with two new columns to track which artifact version a user last reviewed and when, enabling the system to distinguish reviewed vs. unreviewed inbox entries.
Migration 0022 adds two nullable columns to branch_inbox_states:
last_reviewed_artifact_id — a foreign key to branch_artifact_versions(id) with ON DELETE SET NULL. This records the specific artifact version the user last looked at.
last_reviewed_at — a text timestamp recording when the review happened.
The migration is registered in storage.rs as version 22 inside the migrations() function. Because both columns are nullable and have no NOT NULL constraint, existing rows receive NULL values, which the application interprets as "never reviewed" — exactly the desired default.
Extend InboxItem and BranchInboxStateRow structs
Intent: Propagate the new review-tracking data through the Rust type system so it can be queried, serialized, and used in merge logic.
needs_review: bool — a computed flag indicating whether the current artifact differs from the last-reviewed artifact.
last_reviewed_at: Option<String> — the timestamp of the most recent review, if any.
The internal BranchInboxStateRow (used for alias merging) also gains last_reviewed_artifact_id and last_reviewed_at so that merge operations can carry this data across accounts.
Compute needs_review in the inbox list query
Intent: Surface the review status directly in SQL so the application can sort and display items based on whether they need attention.
Affected files: crates/pika-news/src/storage.rs
Evidence
@@ -1074,7 +1076,14 @@ impl Store {
+ CASE
+ WHEN bis.last_reviewed_artifact_id IS NULL
+ OR bis.last_reviewed_artifact_id != bis.artifact_id
+ THEN 1
+ ELSE 0
+ END AS needs_review,
+ bis.last_reviewed_at
@@ -1087,7 +1096,7 @@ impl Store {
- ORDER BY bis.created_at ASC, bis.branch_id ASC
+ ORDER BY needs_review DESC, bis.updated_at DESC, bis.branch_id DESC
The list_branch_inbox query now includes a CASE expression that computes needs_review as 1 when last_reviewed_artifact_id is either NULL (never reviewed) or differs from the current artifact_id (new commits arrived since last review).
The sort order changes from created_at ASC to needs_review DESC, updated_at DESC, branch_id DESC. This means unreviewed items always appear first, ordered by most-recently-updated, giving users a natural triage flow where the freshest unreviewed content surfaces at the top.
Update inbox count to reflect review-needed semantics
Intent: Change the inbox badge count to show only items that actually need review, not all active inbox items, while adding a separate total count method.
Affected files: crates/pika-news/src/storage.rs
Evidence
@@ -1128,7 +1139,11 @@ impl Store {
- AND bis.state = 'inbox'",
+ AND bis.state = 'inbox'
+ AND (
+ bis.last_reviewed_artifact_id IS NULL
+ OR bis.last_reviewed_artifact_id != bis.artifact_id
+ )",
@@ -1136,6 +1151,41 @@ impl Store {
+ pub fn branch_inbox_total(&self, npub: &str) -> anyhow::Result<i64> {
+ self.with_connection(|conn| {
+ conn.query_row(
+ "SELECT COUNT(*)
+ FROM branch_inbox_states bis
+ WHERE bis.npub = ?1
+ AND bis.state = 'inbox'",
+ params![npub],
+ |row| row.get(0),
+ )
+ .context("count total branch inbox")
+ })
+ }
branch_inbox_count now filters to only items where the review artifact doesn't match — meaning only genuinely unreviewed items count toward the badge number. A new branch_inbox_total method provides the raw count of all active (non-dismissed) inbox items regardless of review state.
This separation is key: the UI badge shows review_needed while pagination uses total to determine page counts. Users see an accurate count of what demands attention without losing visibility of reviewed-but-not-dismissed items.
Add mark_branch_inbox_reviewed storage method
Intent: Provide a storage operation that stamps the current artifact as reviewed for a specific user and branch, implementing the core of the commit-aware tracking.
Affected files: crates/pika-news/src/storage.rs
Evidence
@@ -1136,6 +1151,41 @@ impl Store {
+ pub fn mark_branch_inbox_reviewed(&self, npub: &str, branch_id: i64) -> anyhow::Result<usize> {
+ self.with_connection(|conn| {
+ let rows = conn
+ .execute(
+ "UPDATE branch_inbox_states
+ SET last_reviewed_artifact_id = artifact_id,
+ last_reviewed_at = CURRENT_TIMESTAMP
+ WHERE npub = ?1
+ AND branch_id = ?2
+ AND state = 'inbox'
+ AND (
+ last_reviewed_artifact_id IS NULL
+ OR last_reviewed_artifact_id != artifact_id
+ )",
+ params![npub, branch_id],
+ )
+ .context("mark branch inbox reviewed")?;
+ Ok(rows)
+ })
+ }
mark_branch_inbox_reviewed performs a conditional UPDATE: it sets last_reviewed_artifact_id = artifact_id and last_reviewed_at = CURRENT_TIMESTAMP but only when the item actually needs review (the guard clause checks last_reviewed_artifact_id IS NULL OR last_reviewed_artifact_id != artifact_id). This idempotency guard prevents unnecessary writes when the user refreshes the page multiple times without new commits arriving.
The method returns the number of rows affected, allowing callers to know whether the review status actually changed.
Filter inbox navigation to unreviewed items only
Intent: Ensure prev/next navigation in review mode skips already-reviewed items, so users step through only the items that need attention.
Affected files: crates/pika-news/src/storage.rs
Evidence
@@ -1205,10 +1255,14 @@ impl Store {
- "SELECT bis.created_at, bis.branch_id
+ "SELECT bis.updated_at, bis.branch_id
FROM branch_inbox_states bis
WHERE bis.npub = ?1
AND bis.state = 'inbox'
+ AND (
+ bis.last_reviewed_artifact_id IS NULL
+ OR bis.last_reviewed_artifact_id != bis.artifact_id
+ )
AND bis.branch_id = ?2
@@ -1227,12 +1281,16 @@ impl Store {
+ AND (
+ bis.last_reviewed_artifact_id IS NULL
+ OR bis.last_reviewed_artifact_id != bis.artifact_id
+ )
AND bis.branch_id != ?3
AND (
- bis.created_at < ?2
- OR (bis.created_at = ?2 AND bis.branch_id < ?3)
+ bis.updated_at > ?2
+ OR (bis.updated_at = ?2 AND bis.branch_id > ?3)
)
- ORDER BY bis.created_at DESC, bis.branch_id DESC
+ ORDER BY bis.updated_at ASC, bis.branch_id ASC
All three neighbor queries (current-position lookup, prev, and next) now include the unreviewed filter and switch from created_at to updated_at for cursor-based pagination. The direction semantics are also reversed to match the new sort order (updated_at DESC):
Previous now finds items with updated_at > current (items appearing before the current one in descending order).
Next finds items with updated_at < current.
Position counts items at or before current in the descending sort.
When the current item has already been reviewed, the initial lookup returns no row, which surfaces as a 404 in the API, handled gracefully by the frontend as the "Reviewed" state.
Change re-inbox reason to new_commits
Intent: Clarify the semantic reason when an already-dismissed branch gets re-added to the inbox due to new artifact versions.
When insert_branch_inbox_rows_for_artifact re-activates a dismissed inbox item or inserts a new one, the reason string changes from generation_ready to new_commits. This more accurately reflects the trigger: the branch received new commits that produced a new artifact.
Notably, the UPDATE path no longer resets created_at to CURRENT_TIMESTAMP. Previously, re-inbox would overwrite the original creation timestamp; now only updated_at is refreshed. This preserves the original inbox creation time while signaling freshness through updated_at.
Propagate review fields through alias merge logic
Intent: Ensure that when user accounts are merged (alias consolidation), the review progress columns are carried over using max-wins semantics.
The alias merge functions (merge_branch_inbox_state_alias and merge_branch_inbox_state_rows) are updated to read, write, and merge the two new columns. The merge strategy uses max() for both last_reviewed_artifact_id and last_reviewed_at, which means if either account has reviewed a more recent artifact, the merged result retains that progress. This prevents merges from losing review state.
Add the mark-reviewed API endpoint
Intent: Expose the mark-reviewed operation over HTTP so the frontend can call it when a user views a branch detail page.
@@ -4876,6 +4889,40 @@ async fn api_inbox_dismiss_handler(
+async fn api_inbox_mark_reviewed_handler(
+ State(state): State<Arc<AppState>>,
+ Path(review_id): Path<i64>,
+ headers: axum::http::HeaderMap,
+) -> impl IntoResponse {
+ let forge_mode = state.config.effective_forge_repo().is_some();
+ let npub = match require_inbox_auth(&state.auth, &headers, forge_mode) {
+ Ok(n) => n,
+ Err(resp) => return resp,
+ };
+ let store = state.store.clone();
+ match tokio::task::spawn_blocking(move || {
+ if forge_mode {
+ store.mark_branch_inbox_reviewed(&npub, review_id)
+ } else {
+ Ok(0)
+ }
+ })
+ .await
A new POST /news/api/inbox/reviewed/:pr_id endpoint is registered in the router and handled by api_inbox_mark_reviewed_handler. The handler:
Authenticates the request using the same require_inbox_auth mechanism as other inbox endpoints.
Only operates in forge mode; in non-forge mode it returns {"marked": 0} as a no-op.
Delegates to store.mark_branch_inbox_reviewed on a blocking task.
Returns {"marked": N} where N is the number of rows updated (0 or 1).
The endpoint follows the existing error-handling pattern with proper status codes for internal errors.
Update inbox list API to return review_needed count
Intent: Give the frontend both the total active inbox count (for pagination) and the review-needed count (for the badge), so the UI can distinguish between them.
Affected files: crates/pika-news/src/web.rs
Evidence
@@ -4765,18 +4769,27 @@ async fn api_inbox_list_handler(
- let count = if forge_mode {
+ let review_needed = if forge_mode {
store.branch_inbox_count(&npub)?
} else {
store.inbox_count(&npub)?
};
- Ok::<_, anyhow::Error>((items, count))
+ let total = if forge_mode {
+ store.branch_inbox_total(&npub)?
+ } else {
+ review_needed
+ };
+ Ok::<_, anyhow::Error>((items, total, review_needed))
The api_inbox_list_handler now queries both branch_inbox_count (unreviewed only) and branch_inbox_total (all active) in forge mode. The JSON response gains a review_needed field alongside the existing total. In non-forge mode, both values are identical since the original inbox doesn't have the reviewed/unreviewed distinction.
Frontend: auto-mark reviewed and handle 404 in navigation
Intent: Automatically mark a branch as reviewed when the user views it, and gracefully handle the case where navigation returns 404 because the current item was just reviewed.
The detail page template adds a markCurrentReviewSeen() function that fires a POST /news/api/inbox/reviewed/${branchId} request. This is called:
On initial page load in review mode (before refreshing navigation controls).
On the pika-auth event (when authentication completes asynchronously).
The call uses .finally(refreshReviewControls) to ensure navigation controls update regardless of whether the mark-reviewed request succeeds.
The refreshReviewControls function now handles a 404 response from the neighbors endpoint — this occurs when the current item is no longer in the unreviewed set (because it was just marked reviewed). In this case, prev/next buttons are disabled, the dismiss button stays active, and the position indicator shows "Reviewed".
The dismiss flow also handles 404 from neighbors gracefully, setting nextTarget = null instead of throwing.
Update inbox list UI with review status display
Intent: Show users whether each inbox item needs review and display separate counts for active items vs. items needing review.
Tracks reviewNeededItems from the new review_needed API field.
Each inbox card now displays either "needs review" or "reviewed" in the metadata line.
The pagination status text changes from (N items) to (N active, M need review), giving users a clear picture of their review queue.
The explanatory note at the top of the page is updated to explain the new behavior: "Opening a branch marks the current head as reviewed; new commits make it reviewable again."
Add comprehensive tests for review tracking
Intent: Validate the new review-progress behavior with unit tests covering marking as reviewed, per-user isolation, re-inbox after new commits, and the API endpoint.
branch_inbox_state helper — a test utility that queries the raw inbox state row for assertions on artifact_id, state, last_reviewed_artifact_id, and last_reviewed_at.
Backfill test extension — the existing backfill test now verifies that needs_review starts as true, becomes false after mark_branch_inbox_reviewed, and that branch_inbox_count drops to 0 while the item remains in the list. It also verifies that after a new artifact arrives, needs_review returns to true.
merged_branch_stays_in_inbox_until_user_dismisses_it — verifies that merged branches remain in the inbox with needs_review: true.
dismiss_is_per_user_and_review_progress_is_stable — creates two users (alice and bob) with the same branch, marks alice's as reviewed, and verifies bob's is unaffected. Then dismisses alice's and verifies bob's item persists.
forge_inbox_mark_reviewed_updates_review_needed_count — an integration test that exercises the HTTP handler end-to-end, calling the mark-reviewed endpoint and then verifying the count endpoint returns 0.
Sort order test updates — existing ordering assertions are updated to reflect the new needs_review DESC, updated_at DESC sort order.