Skip to content

feat(web): live SSE updates in the dependency-graph drawer (TASK-1785) - #722

Merged
xarmian merged 9 commits into
mainfrom
feat/graph-sse
Jun 8, 2026
Merged

feat(web): live SSE updates in the dependency-graph drawer (TASK-1785)#722
xarmian merged 9 commits into
mainfrom
feat/graph-sse

Conversation

@xarmian

@xarmian xarmian commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Makes the per-item dependency graph drawer live: while it's open, ItemGraph subscribes to the workspace SSE stream and reflects changes as they happen.

  • Correlates events to visible nodes by item UUID (added id to the rendered node model).
  • item_updated → glow the node + debounced background refetch (no spinner, no view-refit) to pick up status/title/terminal changes.
  • item_created / item_archived / item_restored → debounced refetch (structural; a node may appear/disappear).
  • comment_created → glow only (ambient liveness).
  • onSyncRequired (bulk updates / replay-gap backfills) → folded into the same debounced refetch.

The load path was refactored into load(background): background mode keeps the last good graph visible and swallows errors, so an ambient blip never replaces a working view with an error card. Transient node glow auto-clears after 2.5s; a small "updating…" indicator shows during a background reload. The subscription is set up in onMount and torn down on close (drawer-scoped — no listeners when closed).

Context

Implements TASK-1785 under PLAN-1780. Builds on the drawer (#721). Reuses the existing sseService the workspace layout already connects.

Test plan

  • cd web && npm run check — 0 errors (preexisting unrelated warnings only)
  • cd web && npm run build — clean
  • Svelte MCP autofixer — issues: [] (only style suggestions / known false positives)
  • [n/a] go tests — no Go changes

xarmian added 9 commits June 8, 2026 22:49
Subscribe ItemGraph to the workspace SSE stream while open so the graph
reflects changes live:

- Correlate events to visible nodes by item UUID (added id to RenderNode).
- item_updated: glow the node + debounced BACKGROUND refetch (no spinner,
  no view-refit) to pick up status/title/terminal changes.
- item_created/archived/restored: debounced refetch (structural).
- comment_created: glow only (ambient liveness).
- onSyncRequired (bulk updates / replay-gap backfills): folded into the same
  debounced refetch.

Refactor the load path into load(background): background mode keeps the last
good graph visible and swallows errors so an ambient blip never replaces a
working view with an error card. Transient node glow auto-clears after 2.5s;
a small "updating…" indicator shows during a background reload. Subscription
is set up in onMount and torn down on close (drawer-scoped).

Parent: PLAN-1780.
…down per Codex review (round 1)

- handleItemEvent now no-ops unless the event's item is in the current
  neighborhood, so unrelated workspace edits no longer trigger background
  refetches against api.graph.getFocused. A newly-linked neighbor still
  surfaces: linking touches the visible endpoint (item_updated, in-view) and
  that refetches. item_created is ignored (never already in view).
- Bump loadToken in the onMount teardown so an in-flight load can't commit
  $state or queue fitView after the drawer closes / component unmounts.

Parent: PLAN-1780.
…nd 2)

The per-node glow setTimeout could fire after unmount and write touchedRefs
on a destroyed component. Track the handles and cancel them in teardown,
matching the refetchTimer/loadToken teardown discipline.

Parent: PLAN-1780.
…round 3)

touch() now resets a per-ref fade timer instead of stacking timeouts, so a
burst of events for the same node keeps it lit and the fade starts GLOW_MS
after the LAST event. Replaced the one-shot keyframe with a transition-based
glow (filter transition on .node-bg) so rapid re-touches don't fight an
animation that can't restart — the node stays lit while touched and fades out
when the ref leaves touchedRefs.

Kept onSyncRequired → debounced refetch (round 3 finding 2): it's the
staleness-recovery net for bulk-update and replay-gap-reconnect events, which
are rare and carry no item_id to filter on. The 400ms debounce collapses
bursts to a single background reload, so it's not a per-edit refresh path.

Parent: PLAN-1780.
…ex review (round 4)

scheduleRefetch() now no-ops unless loadState === 'ready'. Previously an
onSyncRequired/SSE event during the initial foreground load could start a
background load(true) that bumped loadToken (cancelling the foreground load)
while skipping fit + error handling — leaving the view un-fitted, or stuck on
the spinner forever if that background fetch failed. The in-flight initial
load already fetches the latest data, so dropping events before the first
ready render loses nothing.

Parent: PLAN-1780.
…review (round 5)

The loadState !== 'ready' guard prevented the spinner/fit race but dropped
the recovery signal for mutations arriving mid-initial-load: if a change
lands after the initial request is issued, that response can be stale and no
later event would correct it. Now such requests set a pendingRefetch flag
that load() flushes once it commits a ready graph, so the neighborhood always
reconciles.

Parent: PLAN-1780.
…use) per Codex review

Close the load/refetch race class generally rather than per-path:
- A foreground load (initial / reroot / depth / include-done) now clears any
  armed background refetch timer at start, so the delayed callback can't fire
  later, bump loadToken, and cancel the foreground load (which on a background
  failure left the view stuck on the spinner).
- The background timer callback re-checks loadState === 'ready' at fire time
  and defers (pendingRefetch) instead of clobbering if state has moved on.

Parent: PLAN-1780.
Reconcile the refetch-breadth tradeoff in favor of correctness. The server
publishes structural link changes (new child, reparent) on the CHILD — which
may be outside the current neighborhood — and the SSE payload carries no link
info, so gating refetch on in-view items missed newly-attached/reparented
neighbors. Refetch on any item_updated/created/archived/restored instead;
glow stays in-view-only (off-view items have no node to light up). Cost is
bounded by the 400ms debounce (one small getFocused per burst) and the
drawer's transient lifetime.

Parent: PLAN-1780.
… review (round 8)

Two concurrency fixes for the SSE refetch path:
- Coalesce: a loadInFlight guard prevents starting a new background load while
  one is awaiting. Previously a steady event stream (spaced > debounce but
  faster than getFocused+layout) kept bumping loadToken and invalidating
  in-flight responses, so live updates could starve forever. Now at most one
  load runs at a time; deferred events flush (exactly once) when it settles.
- Error recovery: refetch decisions moved into runRefetch(), which retries
  foreground from the 'error' state on the next live event. Previously events
  during/after a failed load only set pendingRefetch (flushed solely on a
  successful ready), so the graph could stay stuck in error until a manual
  retry. pendingRefetch is now flushed from load()'s finally on every outcome.

Parent: PLAN-1780.
@xarmian
xarmian merged commit 4456030 into main Jun 8, 2026
4 checks passed
@xarmian
xarmian deleted the feat/graph-sse branch June 8, 2026 23:21
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