Skip to content

refactor(app): model pending questions as a live condition (#1199) - #1200

Merged
Astro-Han merged 6 commits into
devfrom
claude/notif-pending-question-1199
Jun 7, 2026
Merged

refactor(app): model pending questions as a live condition (#1199)#1200
Astro-Han merged 6 commits into
devfrom
claude/notif-pending-question-1199

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Jun 7, 2026

Copy link
Copy Markdown
Owner

Summary

Rework how "a question is waiting on the user" is modeled across the app so the condition has a single source of truth. Before, the same condition was tracked twice: once per child store (external_result_question, live + hydrate) and once as a persisted type:"question" notification (drives badge + sidebar dot). The persisted copy is the modeling error — a point-in-time log entry standing in for a live condition, so its retraction had to be hand-maintained across every removal path, and any missed path left the unread dot / Dock badge claiming a session still needed input after the question was answered.

The four commits:

  1. feat(app): add global pending-question condition index — a pure, non-persisted model directory → askSessionID → questions, keyed by the server identity (directory, askSessionID, messageID, callID).
  2. refactor(app): render question dock/sidebar purely from parts — the dock and sidebar "asking" pip derive only from child-store parts (the authoritative renderable truth for an open session); the orphan-index fallback is gone.
  3. refactor(app): drive pending questions from a global index — a createPendingQuestionController ingests the SSE stream for every directory (covering background projects with no child store), reconciles the authoritative GET /external-result snapshot on bootstrap/reconnect, resolves each question's root session once, and emits a rising-edge alert. The child stores drop external_result_question; trimSessions swaps its question-specific option for a generic preserveSessionIDs set.
  4. refactor(app): model pending questions as a live condition, not an event — the notification layer drops the persisted question entirely and becomes a consumer of the index: OS alert via onQuestionAlert, Dock badge via the union of unseen turn-complete/error sessions and live pending-question roots.

Net result: parts are the renderable truth; the global index is the single cross-project condition signal (Dock badge, root attribution, trim preserve, hydrate prune, rising-edge alert); nothing about a question is persisted.

Why

Closes the root cause in #1199: a pending question is a live condition, not an event. Two parallel sources of truth for one condition meant the persisted side could drift from the live side, stranding a stale dot/badge. Collapsing to one non-persisted index that is rebuilt from the authoritative server snapshot on every reload removes the whole class of "forgot to retract" bugs.

Related Issue

Closes #1199

Human Review Status

Pending

Review Focus

  • Reconciliation boundary. message.part.updated is intentionally not in the SSE replay buffer, so reload recovery for parts must come from hydrate (GET /external-result). The load-bearing pieces are hydratePendingExternalResults (returns the active list and prunes stale local running-ready parts, scoped to a pre-fetch snapshot so a question arriving mid-fetch is not pruned) and the controller's reconcile.
  • Rising-edge alert. The controller fires onQuestionAlert exactly once per question identity and never on hydrate/reconnect, so a restart with an outstanding question does not re-nag. The notification provider only decides surface/visibility.
  • The two cross-project signals the index exists to serve: Dock badge union math (badgeSessionCount) and trim preservation (preserveSessionIDs via pendingSessionIDsForDirectory).

Risk Notes

  • Persisted notification migration. Notification is now turn-complete | error only, but the store key (notification.v1) is unchanged, so a type:"question" entry written by a pre-[Task] Notifications: model pending questions as live blockers (hydrate + full retraction) #1199 build can still be on disk after upgrade. Such entries are actively filtered out at the two load boundaries (buildNotificationIndex and pruneNotifications via isLiveNotification) so an already-answered question can never strand a sidebar unread dot, and they are dropped from disk on first load. Covered by a regression test.
  • Dock badge semantics (intentional divergence). Live pending-question roots are unioned into the badge without launch-scoping — a question still outstanding across a restart keeps badging. This diverges from the original launch-scoped plan and is deliberate: a pending question is a current condition (auto-cleared when answered), not point-in-time backlog, which is the whole point of [Task] Notifications: model pending questions as live blockers (hydrate + full retraction) #1199. Confirmed with the maintainer.
  • Platform. Dock badge (platform.setBadgeCount) and OS attention (platform.requestAttention) are macOS/Linux only; their call signatures are unchanged — only the badge count source changed. No updater/packaging/signing surface touched.
  • No skipped conditional checklist items.

How To Verify

  • tsgo -b (packages/app): clean.
  • bun test (packages/app, full suite): 1806 passed, 0 failed (4142 assertions). New: pending-question-controller.test.ts (8), pending-question-index.test.ts (13), notification-derive.test.ts isLiveNotification + buildNotificationIndex legacy-question regression (3). Rewritten: notification-derive.test.ts badge union (9), bootstrap.test.ts hydrate returns-active + part-prune, event-reducer.test.ts (question-index maintenance removed), session-trim/session-cache (preserveSessionIDs).
  • Review fixes folded in: removed a stray NUL byte that made the controller a binary blob (codex); filter legacy persisted type:"question" notifications on load (review P1).
  • eslint (changed source files): clean.
  • Visual — web: bun run snap sidebar-unread; reviewed grid PNG (light + dark) — unread dot renders correctly under the narrowed Notification shape.
  • Visual — Electron: bun run dev:desktop booted clean (main/preload/renderer built, sidecar + server ready, init done) with the new notification + global-sync wiring; no init errors. Live Dock-badge increment + OS alert need a running agent question and are left to manual QA; their logic is covered by the controller rising-edge and badgeSessionCount unit tests.

Screenshots or Recordings

sidebar-unread snap (light + dark): the orange unread dot renders on the unread row; the viewed/selected row shows its timestamp. No visual regression — the sidebar JSX is unchanged; only the data feeding the unread dot was narrowed (questions now surface via the live "asking" pip, not the persisted dot).

Checklist

How to use this checklist:

  • Tick a box by replacing [ ] with [x]. Do not edit, add, or remove items.
  • The bot-applied label items can only be honestly ticked AFTER the PR is opened and the labeler / priority-triage bots have run — return to the PR description and tick them then.
  • Most items are required. The few that are conditional are explicitly marked (conditional); for those, leave unticked if they truly do not apply and explain why in Risk Notes. All other items must be ticked before requesting human review.
  • Type label — this PR carries exactly one of bug, enhancement, task, documentation. Type labels are author-added; the labeler bot does NOT assign them. Add the label in the GitHub UI, then tick this.
  • Routing labels — this PR carries at least one of app, ui, platform, harness, ci. The labeler bot assigns these on PR open based on changed paths. Confirm the bot's choice (or override if wrong), then tick this.
  • Priority label — this PR carries exactly one of P0, P1, P2, P3. The priority-triage bot suggests one on PR open. Confirm or override, then tick this.
  • Human Review Status above is set to Pending, Approved by @<reviewer>, or Not required: <reason> (default is Pending; "not required" is restricted to bot-authored low-risk PRs).
  • I linked the related issue, or stated in Summary why there is no issue.
  • I described the review focus and any meaningful risks.
  • I replaced the example block in How To Verify with the real verification steps and the key result for each.
  • I did not introduce unrelated refactors, dependencies, generated files, or file changes beyond the stated scope.
  • (conditional) I manually checked visible UI or copy changes when needed, with screenshots or recordings. Leave unticked only if no visible UI or copy changed.
  • (conditional) I considered macOS and Windows impact for platform, packaging, updater, signing, paths, shell, or permissions changes. Leave unticked only if no platform/packaging surface was touched.
  • (conditional) I called out docs, release notes, dependencies, permissions, credentials, deletion behavior, generated content, or local file changes when relevant. Leave unticked only if none of those surfaces was touched.
  • I reviewed the final diff for unrelated changes and suspicious dependency changes.
  • I am targeting dev, and my PR title and commit messages use Conventional Commits in English.

Astro-Han added 4 commits June 7, 2026 13:04
Pure model + helpers for a single cross-project index of question tool calls currently awaiting the user, keyed by (directory, askSessionID, messageID, callID). Entries carry a resolved rootSessionID for badge attribution; helpers cover upsert (rising-edge add), retract by every removal path, authoritative directory reconcile (hydrate prune), distinct-root badge counting, and per-directory asking sessions for session-trim preserve.

Backs moving question liveness out of the persisted notification store: badge/trim/alert derive from this index while the dock and sidebar render from child-store parts. No wiring yet.
A running, ready question part is the renderable truth for an open session, so the dock and sidebar 'asking' pip read it directly via findRunningExternalResultQuestion. Drop findDescendantExternalResultQuestion's pendingQuestions index fallback and isKnownStalePendingQuestion: reload/cold-open already recovers because hydratePendingExternalResults writes the /external-result trio's parts back into the cache. Removes the index's role as a dock backfill, leaving it free to become a pure cross-project condition signal.

Tests drop the orphan-index cases (no message/part row) and keep the part-derived tree walk: active session, child fallback, out-of-tree isolation, terminal-part exclusion, transitive grandchildren.
Move "which question tool calls are waiting on the user" out of each child
store's external_result_question map into one non-persisted global index
owned by createPendingQuestionController. The controller ingests the SSE
stream for every directory (covering background projects with no child
store), reconciles the authoritative GET /external-result snapshot on
bootstrap/reconnect, resolves each question's root session once, and emits
a rising-edge alert for live arrivals (no consumer yet — notification wiring
follows).

The dock and sidebar already render purely from child-store parts (prior
commit); this index now feeds only the cross-project signals parts cannot:
session-trim preservation via preserveSessionIDs and root attribution. The
hydrate path returns the active question list for the controller to
reconcile and prunes stale local question parts so the part-derived dock
stops showing answered-while-away questions. trimSessions swaps its
externalResultQuestion option for a generic preserveSessionIDs set.

Dedupe the duplicated GlobalStore type: bootstrap.ts imports the canonical
one from ../global-sync instead of redeclaring a drifting copy (the drift
is what first surfaced the missing pendingQuestions field).

Verification: tsgo -b clean; bun test of global-sync, blockers, and layout
suites (373 pass).
A question awaiting the user is a live condition, not a point-in-time event,
so persisting it as a `type: "question"` notification was the root modeling
error behind issue #1199: the persisted entry could outlive the question and
strand a stale unread dot / Dock badge after the question was answered.

Drop the persisted question notification entirely (the QuestionNotification
type, its `ask` identity, dropQuestions, and the second alertedQuestionCalls
dedupe lifecycle). The notification list now holds only turn-complete and
error events. Question presence is already rendered live from child-store
parts (sidebar "asking" pip) and tracked by the global pending-question
index; this commit makes notification a consumer of that index instead of a
parallel source of truth:

- OS alert (sound / notification / Dock attention) fires from
  globalSync.onQuestionAlert — a single rising-edge signal the controller
  emits once per question and never on hydrate/reconnect, so a restart with
  an outstanding question does not re-nag. Root attribution and the
  answered-mid-resolution guard now live in the controller, not here.
- The Dock badge unions unseen turn-complete/error sessions (launch-scoped)
  with the live pending-question roots (pendingRootSessionIDs, never
  launch-scoped) via the new pure badgeSessionCount helper.

notification-derive.ts collapses to that one helper; the question lifecycle
helpers it used to hold moved to the controller in the prior commit.

Verification: tsgo -b clean; bun test (app package, 1803 pass); eslint clean
on the changed source files.
@github-actions github-actions Bot added app Application behavior and product flows ui Design system and user interface labels Jun 7, 2026
@Astro-Han Astro-Han added task Narrow execution, audit, spike, migration, tracking, or upstream follow-up work P2 Medium priority labels Jun 7, 2026
@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a dedicated in-memory pending-question index + controller, wires bootstrap reconciliation and global SSE events into it, removes legacy external_result_question state, updates session-trim and blocker lookup to use derived pending roots, and refactors notifications to use live alerts plus unified badge counting.

Changes

Pending Questions Live Index and Alert System

Layer / File(s) Summary
Pending question index data structure and operations
packages/app/src/context/global-sync/pending-question-index.ts, packages/app/src/context/global-sync/pending-question-index.test.ts
Adds PendingQuestion/PendingQuestionIndex and functions: pendingQuestionFromPart, upsertPendingQuestion, setPendingQuestionRoot, removePendingQuestions, reconcileDirectoryPending, pendingRootSessionIDs, pendingSessionIDsForDirectory with tests for identity, root attribution, removal, reconcile, and derivations.
Pending question controller and event routing
packages/app/src/context/global-sync/pending-question-controller.ts, packages/app/src/context/global-sync/pending-question-controller.test.ts, packages/app/src/context/global-sync.tsx
New controller exposing applyEvent, reconcile, and onAlert; attributes rootSessionID via child-store or SDK fallback, wires into global sync event routing and exposes onQuestionAlert for one-shot alerts.
Bootstrap external-result hydration and reconciliation
packages/app/src/context/global-sync/bootstrap.ts, packages/app/src/context/global-sync/bootstrap.test.ts
Refactors hydratePendingExternalResults to return active actives and accept pruneCandidateIDs, snapshots running-ready question part IDs, and forwards hydrated actives to pendingQuestions.reconcile(...). Tests updated to assert returned active lists and pruning semantics.
Remove event-reducer question tracking
packages/app/src/context/global-sync/event-reducer.ts, packages/app/src/context/global-sync/event-reducer.test.ts, packages/app/src/context/global-sync/external-result-question.ts, packages/app/src/context/global-sync/types.ts, packages/app/src/context/global-sync/session-cache.ts, packages/app/src/context/global-sync/session-cache.test.ts
Eliminates external_result_question handling from reducer/cache/types and removes legacy helper exports and tests tied to that state.
Session trimming and preservation via pending IDs
packages/app/src/context/global-sync/session-trim.ts, packages/app/src/context/global-sync/session-trim.test.ts, packages/app/src/context/global-sync/child-store.ts
Adds preserveSessionIDs option to trimSessions, wires global-sync to preserve pending-derived session IDs, updates child-store initial State shape and tests.
Notification badge counting and live alert system
packages/app/src/context/notification-derive.ts, packages/app/src/context/notification-derive.test.ts, packages/app/src/context/notification.tsx
Replaces persisted question notifications with live alerts; adds isLiveNotification, createNotificationIndex, buildNotificationIndex, and badgeSessionCount to unify unseen events with pending-root sessions; subscribes to controller alerts for OS notifications.
UI question blocker detection
packages/app/src/pages/session/blockers/running-external-result-question.ts, packages/app/src/pages/session/blockers/use-session-blockers.ts, packages/app/src/pages/session/blockers/use-session-blockers.test.ts, packages/app/src/pages/layout/sidebar-items.tsx
findDescendantExternalResultQuestion now derives solely from local cache (no pending-index), blocker wiring and sidebar usage updated, tests adjusted for active-tree/terminal semantics.
Test fixture and store shape updates
packages/app/src/context/global-sync.test.ts, packages/app/src/pages/layout/pawwork-session-commands.test.ts, various bootstrap/session-cache tests
Adds pendingQuestions: {} to global store fixtures, removes external_result_question from test store shapes, and updates bootstrap tests to pass pendingQuestions.reconcile callbacks.

Sequence Diagram

sequenceDiagram
  participant EventStream
  participant PendingController
  participant ChildStore
  participant GlobalSDK
  participant GlobalStore
  participant NotificationSystem
  EventStream->>PendingController: message.part.updated / other events
  PendingController->>GlobalStore: upsert / remove pending question
  PendingController->>ChildStore: resolveParentID(sessionID)?
  alt child-store knows parent
    ChildStore-->>PendingController: parent session id
  else fallback
    PendingController->>GlobalSDK: client.session.get(sessionID)
    GlobalSDK-->>PendingController: session parent id | undefined
  end
  PendingController->>GlobalStore: setPendingQuestionRoot(...)
  PendingController->>NotificationSystem: onAlert(...) (rising-edge)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Astro-Han/pawwork#1172: Prior changes touching pending external-result handling; related to replacing the old external_result_question mechanism.
  • Astro-Han/pawwork#748: Related bootstrap/external-result handling and filtering; overlaps with hydrate/reconcile logic.
  • Astro-Han/pawwork#1196: Earlier minimal retraction fixes for pending questions; this PR completes modeling as live blockers.

Suggested labels

enhancement, harness

Poem

🐰 A rabbit hops through pending queues so bright,
From reducer dust to controller's light.
Bootstrap listens, SSEs hum along,
Alerts rise once — a single tiny gong.
No stale dots now, just tidy truth in sight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main architectural change: refactoring the pending-questions model from a persisted event to a live condition.
Linked Issues check ✅ Passed The PR fully addresses #1199 objectives: establishes a single non-persisted pending-question index fed by SSE/hydrate, persists only true events, implements rising-edge alerts, and derives badge/sidebar from live blockers.
Out of Scope Changes check ✅ Passed All changes are scoped to the pending-questions live-condition model: index infrastructure, controller, notification integration, and session trimming preservation—no unrelated refactors or dependencies.
Description check ✅ Passed The PR description is comprehensive and thorough, covering all required sections with detailed context, risk analysis, and verification steps.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/notif-pending-question-1199

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested priority: P2 (includes user-path files (packages/app/src/context/global-sync.test.ts, packages/app/src/context/global-sync.tsx, packages/app/src/context/global-sync/bootstrap.test.ts, packages/app/src/context/global-sync/bootstrap.ts, packages/app/src/context/global-sync/child-store.ts, packages/app/src/context/global-sync/event-reducer.test.ts, packages/app/src/context/global-sync/event-reducer.ts, packages/app/src/context/global-sync/external-result-question.ts, packages/app/src/context/global-sync/pending-question-controller.test.ts, packages/app/src/context/global-sync/pending-question-controller.ts, packages/app/src/context/global-sync/pending-question-index.test.ts, packages/app/src/context/global-sync/pending-question-index.ts, packages/app/src/context/global-sync/session-cache.test.ts, packages/app/src/context/global-sync/session-cache.ts, packages/app/src/context/global-sync/session-trim.test.ts, packages/app/src/context/global-sync/session-trim.ts, packages/app/src/context/global-sync/types.ts, packages/app/src/context/notification-derive.test.ts, packages/app/src/context/notification-derive.ts, packages/app/src/context/notification.tsx, packages/app/src/pages/layout/pawwork-session-commands.test.ts, packages/app/src/pages/layout/sidebar-items.tsx, packages/app/src/pages/session/blockers/running-external-result-question.ts, packages/app/src/pages/session/blockers/use-session-blockers.test.ts, packages/app/src/pages/session/blockers/use-session-blockers.ts)).

P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors pending question tracking by replacing the directory-specific external_result_question state with a global pendingQuestions index. This allows tracking questions across all directories, including background projects, and simplifies the dock/sidebar rendering to derive directly from the local message/part cache. Feedback is provided regarding a critical runtime TypeError in global-sync.tsx where accessing .session on existing?.[0] requires optional chaining to prevent crashes when existing is undefined.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread packages/app/src/context/global-sync.tsx
Astro-Han added 2 commits June 7, 2026 15:01
The dedupe key for in-flight root resolution joined directory and question
id with a literal NUL byte, which made Git classify the whole controller
file as binary (`Bin 0 -> 6423 bytes`) and hid it from line-level review and
text tooling. Restore the intended printable space separator; runtime
behaviour is unchanged (the key is only an internal Set lookup).

Caught by codex review.
The persisted notification store key (notification.v1) is unchanged, so a
`type:"question"` entry written by a pre-#1199 build can still be on disk
after upgrade. buildNotificationIndex and pruneNotifications filtered only by
`viewed`/time, so such an entry — even for an already-answered question —
kept feeding the unread index and stranded a sidebar unread dot (the exact
bug #1199 set out to remove, reintroduced via migration).

Filter to live notification types (turn-complete | error) at the two load
boundaries: buildNotificationIndex (so the unread index never counts a legacy
question) and pruneNotifications (so the stale entries are dropped from disk
on first load). Move the index builder + types into notification-derive.ts as
pure helpers so the migration filter is unit-testable without rendering the
provider.

New tests: isLiveNotification, and buildNotificationIndex proving a persisted
type:"question" entry yields zero session/project unread while a turn-complete
still counts.

Caught in code review.
@Astro-Han
Astro-Han merged commit dbed534 into dev Jun 7, 2026
37 checks passed
@Astro-Han
Astro-Han deleted the claude/notif-pending-question-1199 branch June 7, 2026 09:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app Application behavior and product flows P2 Medium priority task Narrow execution, audit, spike, migration, tracking, or upstream follow-up work ui Design system and user interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Task] Notifications: model pending questions as live blockers (hydrate + full retraction)

1 participant