Skip to content

refactor(web): decentralize cross-domain SSE event handlers into bus subscribers#32626

Merged
ashleeradka merged 5 commits into
mainfrom
devin/1780089465-lum-2053-decentralize-stream-handlers
May 29, 2026
Merged

refactor(web): decentralize cross-domain SSE event handlers into bus subscribers#32626
ashleeradka merged 5 commits into
mainfrom
devin/1780089465-lum-2053-decentralize-stream-handlers

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

@ashleeradka ashleeradka commented May 29, 2026

Prompt / plan

Closes LUM-2053. Phase 2 of streaming infrastructure decentralization (Phase 1: PR #32598).

The monolithic use-stream-event-handler.ts dispatched ~30 event types through a single switch statement, including 8 cross-domain events (home feed, identity, avatar, relationship state, disk pressure, notifications, document editor, conversation titles) that had no business being in domains/chat/. This violated domain encapsulation and bloated StreamHandlerContext with 3 cross-domain callback fields.

Approach: Move each cross-domain event to the bus subscriber that owns its side-effect domain, following the established useBusSubscription pattern documented in EVENT_BUS.md. The monolithic handler retains only chat-domain events (messages, tools, interactions, surfaces, subagents, queue, compaction).

Changes

Expanded existing bus subscribers:

  • useAssistantResourceSync — now handles home_feed_updated, relationship_state_updated, identity_changed, avatar_updated (in addition to existing sync_changed tag routing)
  • useConversationSync — now handles conversation_title_updated via patchConversation()
  • useDiskPressureMonitor — now subscribes to sse.event for disk_pressure_status_changed (complements existing poll + app.resume subscription)

New bus subscribers:

  • hooks/use-notification-intent-sync.ts — handles notification_intent events (local notifications, guardian filtering, delivery ack). Mounted in RootLayout so notifications are delivered on all routes, not just chat.
  • hooks/use-document-editor-sync.ts — handles document_editor_update events (forwards to viewer store)

Monolithic handler cleanup:

  • Removed 8 cross-domain cases → grouped no-op with explanatory comment
  • Removed 3 StreamHandlerContext fields (refreshAssistantIdentity, invalidateAvatar, applyDiskPressureStatusEvent) and their ref-stabilization boilerplate in ChatPage
  • Deleted home-handlers.ts entirely (2 trivial functions now covered by bus subscriber)
  • Shrunk metadata-handlers.ts from ~150 to 66 lines (5 exported functions removed)

Centralized query key:

  • Extracted HOME_STATE_QUERY_KEY_PREFIX and homeStateQueryKey() into lib/sync/query-tags.ts (was module-private in use-home-state-query.ts)

Bug fix: relationship_state_updated missing home-state invalidation

The monolithic handler was the only code path invalidating the "home-state" TanStack Query key on relationship_state_updated. The existing useAssistantResourceSync bus subscriber only invalidated "home-feed". Removing the monolithic handler without fixing the subscriber would have silently broken home state cache freshness. Fixed by adding HOME_STATE_QUERY_KEY_PREFIX invalidation to the subscriber.

Root cause: When useAssistantResourceSync was originally written, it only handled sync_changed tags. The relationship_state_updated discrete event was added later to the monolithic handler where both invalidations were performed, but the bus subscriber was never updated to match.

Bug fix: notification suppression on non-chat routes

Moving notification_intent handling from ChatPage-scoped useStreamEventHandler to RootLayout-scoped useNotificationIntentSync changed the suppression behavior. The old handler only ran on the chat route, so the "suppress if viewing active conversation" check was implicitly scoped. In RootLayout, activeConversationId (which is never cleared on navigation) could cause stale suppression on home/settings routes.

Fixed by adding a window.location.pathname.startsWith("/assistant/conversations/") guard alongside the store check.

Root cause: activeConversationId in useConversationStore persists across route changes — setActiveConversationId(null) is never called on navigation. This is a pre-existing store design issue; the URL check is the correct fix at the consumer level.

Not changed (intentional)

  • sync_changed handling in the monolithic handler's WebSyncRouter path — it has unique self-echo filtering and active conversation message refresh that bus subscribers don't replicate. Consolidating is a separate effort.
  • conversation_list_invalidated — legacy macOS-only broadcast, no-op'd with explanatory comment and TODO for retirement.
  • use-stream-event-handler.ts file size (472 lines) — pre-existing, reduced by this PR. Further splitting tracked in LUM-2055 / LUM-2056.

References

Test plan

  • Unit tests: 26 tests across 4 test files — all passing
    • use-assistant-resource-sync.test.tsx (10 tests, 4 new: home_feed, relationship_state, identity_changed, avatar_updated)
    • use-conversation-sync.test.tsx (7 tests, 1 new: conversation_title_updated)
    • use-stream-event-handler-guard.test.tsx (6 tests, updated for bus-handled no-ops)
    • metadata-handlers.test.ts (3 tests, removed tests for deleted functions)
  • Lint + typecheck: both exit 0
  • Cross-domain allowlist: net reduction (2 entries removed, 0 added)
  • CI: all checks green

Link to Devin session: https://app.devin.ai/sessions/4370103ab5e547ae8696d39b423f7103
Requested by: @ashleeradka

…subscribers

Move 8 cross-domain event handlers from the monolithic
use-stream-event-handler.ts dispatch into domain-scoped bus subscribers:

- identity_changed, avatar_updated, relationship_state_updated,
  home_feed_updated → useAssistantResourceSync
- disk_pressure_status_changed → useDiskPressureMonitor
- notification_intent → new useNotificationIntentSync
- document_editor_update → new useDocumentEditorSync
- conversation_title_updated → useConversationSync

Bug fix: relationship_state_updated now invalidates both
HOME_FEED_QUERY_KEY_PREFIX and HOME_STATE_QUERY_KEY_PREFIX. Previously
only the monolithic handler invalidated home-state; the bus subscriber
missed it.

Shrinks StreamHandlerContext by 3 fields, deletes home-handlers.ts
entirely, removes 5 functions from metadata-handlers.ts.

Closes LUM-2053

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@linear
Copy link
Copy Markdown

linear Bot commented May 29, 2026

LUM-2053

Remove 2 stale entries for metadata-handlers.ts and its test — they no
longer import from the conversations domain since those handlers moved
to bus subscribers.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 772b471b89

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/web/src/hooks/use-notification-intent-sync.ts
devin-ai-integration Bot and others added 2 commits May 29, 2026 21:39
activeConversationId persists across route changes (never cleared on
navigation). In the old monolithic handler, notification suppression was
implicitly scoped to ChatPage. Now that the bus subscriber runs in
RootLayout, we also verify the URL pathname matches the conversation
route to prevent stale-id suppression on home/settings/etc.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…sync

useBusSubscription stabilizes the handler ref so the closure always
captures the latest assistantId. The alias was a vestige of the old
monolithic handler's ref-based capture pattern.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Open in Devin Review

@ashleeradka
Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes May 29, 2026
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

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

APPROVE at 8ae2dd0b3b — Phase 2 of the streaming-infra decentralization arc (Phase 1 was #32598). The first-principles question Boss's superprompt keeps asking — should this even be in domains/chat/? — applied at the SSE-handler granularity. The monolith was always doing two jobs: dispatching ~22 chat-domain events (messages, tools, surfaces, subagents, queue, compaction) and dispatching 8 cross-domain events that lived there only because the dispatch entry point happened to be there. This PR dissolves the second category into the bus subscribers that own the side effects, with two real bugs surfaced as collateral that wouldn't have been visible without the structural move.

What landed at HEAD

  • 2 new RootLayout-mounted bus subscribers: useNotificationIntentSync (notification banner + guardian filtering + delivery ack), useDocumentEditorSync (forwards markdown updates to viewer store).
  • 3 existing subscribers expanded: useAssistantResourceSync (+4 events), useConversationSync (+ conversation_title_updated), useDiskPressureMonitor (+ sse.event subscription complementing existing poll + app.resume).
  • Monolith cleanup: 8 cross-domain cases → grouped no-op with // Cross-domain events handled by bus subscribers… comment that keeps the union exhaustiveness check live. StreamHandlerContext drops 3 fields + their ref-stabilization boilerplate in ChatPage. home-handlers.ts deleted; metadata-handlers.ts shrinks 150 → 66.
  • Centralization: HOME_STATE_QUERY_KEY_PREFIX + homeStateQueryKey() lifted from use-home-state-query.ts module-private → lib/sync/query-tags.ts. Monolith also retargets recordChatDiagnosticrecordDiagnostic from @/lib/diagnostics and ChatEventStream import from domains/chat/api/streamlib/streaming/stream-transport (continuation of #32598).
  • Allowlist: drops metadata-handlers.ts/metadata-handlers.test.ts → ["conversations"] — both exceptions dissolved because the cross-domain edges left the chat domain.

Two real bugs surfaced by the structural move

Both are flagged in the PR body. Both are substantive, not cosmetic.

  1. relationship_state_updated was missing home-state invalidation in the subscriber. The monolithic handler invalidated both HOME_FEED_QUERY_KEY_PREFIX and "home-state" (literal). The existing useAssistantResourceSync only invalidated HOME_FEED_QUERY_KEY_PREFIX. Removing the monolithic handler without fixing the subscriber would have silently broken relationship-state cache freshness on the home screen. Fixed by adding the second invalidation. New test invalidates both home-feed and home-state on relationship_state_updated proves it via call-key collection (not just count). ✅

  2. Notification suppression scoping regression (Codex P2 at 8ae2dd0b, fixed in f2a443b206). The old notification_intent handler ran ChatPage-scoped, so the "suppress if viewing active conversation" check was implicitly route-scoped. Hoisted to RootLayout, activeConversationId (never cleared on navigation — verified by Devin: no call site passes null to setActiveConversationId) could cause stale suppression on home/settings/document routes. Fix correctly requires both metadataConversationId === store.activeConversationId and window.location.pathname.startsWith("/assistant/conversations/") — the URL guard gates only the in-conversation suppression branch, not the guardian-scoped branch, which is the right asymmetry (guardian-scoped should ack-and-drop regardless of route). JSDoc on the new hook names both parts of the check. ✅

Verifications at HEAD 8ae2dd0b3b

  • 19 files read end-to-end. 7/7 CI green.
  • New hooks follow useBusSubscription contract per EVENT_BUS.md; JSDocs reference it; scope naming with reasons.
  • useConversationSync switch preserves the lookup-by-conversationId semantics the old handleConversationTitleUpdated had (via patchConversation); test verifies the cache patch lands under conversationsQueryKey("asst-1").
  • useAssistantResourceSync switch correctly routes all 4 new events to the right query keys (HOME_FEED_QUERY_KEY_PREFIX, both prefixes for relationship-state, assistantIdentityQueryKey(assistantId), avatarQueryKey(assistantId)); 4 new tests added.
  • useDiskPressureMonitor stays ChatPage-scoped (intentional per the monolith's farewell comment); the new sse.event subscription is additive to the existing poll + resume paths, not a replacement.
  • Guard test correctly flipped: forwards a global event (home_feed_updated)no-ops a cross-domain event handled by bus subscribers.
  • Mock context in use-stream-event-handler-guard.test.tsx drops the 3 removed fields cleanly (no type drift surface).
  • Last commit 8ae2dd0b is a pure 5-line vestige cleanup (ackAssistantId alias → direct assistantId use, since useBusSubscription already stabilizes the closure). Zero behavior delta.

Small non-blocking observations

  1. useDocumentEditorSync takes no assistantId, all other new/expanded subscribers do. That's correct — the viewer store is global and document surface ids are globally unique — but the asymmetry is worth a one-line JSDoc note for the next reader who wonders if it was an oversight.
  2. Notification suppression uses window.location.pathname directly, not useLocation(). For an SSE callback firing imperatively this is fine — window.location.pathname is always current and a stale closure can't trick it — and useLocation() would force the subscription to re-bind on every nav. If this ever moves into a useEffect-dependent flow, useLocation() becomes the React-idiomatic choice. Leave the current shape.
  3. Grouped no-op switch case with the explanatory comment is doing real work. Keeping the labels in the switch preserves exhaustiveness checking against the event union — if a future event type lands and someone forgets to wire it into a subscriber, the type-checker won't flag it (the union shrinks toward never), but the explicit case labels here will at least require an explicit edit on the chat side. Good pattern; worth keeping that comment block intact in future cleanups.

Merge gate

  • Vex: ✅ this approval at 8ae2dd0b3b.
  • Devin: "No Issues Found" at f2a443b206 (also a substantive inline at 8ae2dd0b confirming the P2 fix). HEAD is one cleanup commit past that — a 5-line vestige removal with zero behavior delta. Counts.
  • Codex: clean inline at 8ae2dd0b; Boss's @codex review ping at 21:49 is pending — recommended to let that one land at HEAD before merge, but the gate is effectively closed.
  • CI: 7/7 green at HEAD.

Carry-forward. This is the second Phase-2 unwind in the same arc (#32598 lifted the transport layer out of domains/chat/api/, this lifts the cross-domain dispatch out of domains/chat/hooks/). The pattern is identical and worth naming: when a domain's "main" hook accumulates cross-domain side effects, those side effects almost always have a more honest home. The chat handler is now what it should always have been — a chat-event dispatcher, not a global event router.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@ashleeradka ashleeradka merged commit 7833050 into main May 29, 2026
7 checks passed
@ashleeradka ashleeradka deleted the devin/1780089465-lum-2053-decentralize-stream-handlers branch May 29, 2026 21:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant