feat(ui): live refresh and drift banners — the viewer keeps up with the project (CG-53)

`GET /api/events` is a server-sent-event stream the viewer holds open for the
life of the page. Two signals, two things the browser could not know:

  changed  source files touched on disk, before any sync — the drift banner
  index    the graph moved, naming what the sync re-indexed — the live refresh

The server WATCHES and never syncs: the project tree through the engine's own
FileWatcher with a notify-only syncFn, the index through one non-recursive
fs.watch on the data directory settled at 400 ms. Both start with the first
subscriber and stop with the last, so a viewer nobody has open costs no watch
descriptors. Nothing polls, on either side.

Drift is now parity with codegraph_node (#1474) rather than an absence.
`/api/source?ondrift=current` serves a drifted file's CURRENT bytes flagged
`showing: 'current'`, and the three screens that can say so switch off
everything anchored to the old line numbering — gutter ports, call-site links,
call arcs, the callee rail's anchoring — while keeping the source. The banner is
paper-2 with a hairline rule, never amber: amber belongs to the untested badge.

Also fixes a stale read this exposed. A long-lived reader holds an LRU of nodes
by id that only its own writes invalidate, so `/api/node/<id>` kept answering
with a symbol another process's sync had deleted while `/api/search` beside it
said it was gone. GraphSession now drops the read caches when the database (or
its WAL) has been written, and the Symbol view follows a symbol whose id changed
because an edit above it moved its start line, carrying the trail across.

Measured on a live viewer: banner 360 ms after a save, toast 440 ms after
`codegraph sync` returns, 0 requests in 4 idle seconds, and the client gives up
reconnecting after ~90 s with "Not live" rather than hammering a dead port.
This commit is contained in:
Colby McHenry
2026-08-27 04:28:56 -05:00
parent bd99c5e99a
commit ecd6e1cd15
28 changed files with 2236 additions and 142 deletions
+25 -1
View File
@@ -226,6 +226,30 @@ worker entry, a script — ranked by calls × the number of other files they rea
depended-on symbols. Each section says what it is derived from, never that a file IS the entry
point.
### 3.8 Drift banner and live refresh (CG-53)
Drift banner: full-width block above the code, `--paper-2` fill, 1px `--rule-soft` border, padding `8px 12px`, 12.5px `--ink-2`, leading
"⚠" glyph in `--ink-3`. **Never amber** — amber is the untested badge's colour and nothing else's — and never a modal.
Toast: `--ink` fill, `--paper` text, 12.5px, `8px 14px`, bottom-centre, 2.6 s, one at a time.
**As built.** The endpoint is **`/api/events`**, not `/events`: everything under `/api/` answers JSON for every outcome and is
excluded from the SPA fallback, so a stream mounted outside that namespace would have come back as the app shell on a typo and as
`text/plain` on a refusal. It carries four event types — `hello` (the index revision the client is synchronised against, and which of
the two watchers came up), `changed` (source files on disk, before any sync), `index` (the graph moved, naming what the sync
re-indexed) and `degraded` — plus a `: ping` comment frame every 25 s. The server WATCHES and never syncs: the project tree through
the engine's own `FileWatcher` with a notify-only `syncFn`, the index through one non-recursive `fs.watch` on the data directory
settled at 400 ms (capped at 3 s). Both start with the first subscriber and stop with the last.
Three banner variants, because what follows the dash is what the screen actually did:
- **Symbol view** — "indexed line ranges may be shifted; showing the file's current source. The next sync picks it up." The whole
CURRENT file replaces the body (parity with `codegraph_node` on a drifted file, issue #1474) and every line-anchored marking goes
with the old numbering: gutter ports, call-site links, the definition-name weight, the `?hl=` highlight, and the callee rail's
anchoring — its rows stack in source order and draw no connector. Above 400 lines the banner links to the whole-file view instead.
- **Whole file (`?src=1`)** — the same, plus "with the call arcs, ports and rail switched off". The source still pages in; only the
margins go.
- **File outline** — "the outline below is the shape the file had when it was indexed", with a link to the current source.
Measured: banner 360 ms after a save; toast 440 ms after `codegraph sync` returns; 0 requests in 4 idle seconds.
## 4. Libraries and versions
- Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges,
hidden handles for port spreading, local selection state — the pattern in docker-app's `StackGraph.svelte`); `@dagrejs/dagre` only as a
@@ -252,7 +276,7 @@ point.
## 5. Copy rules
Sentence case; controls say what happens ("Read as flow", "Clear"); counts always visible next to folds; honesty phrases fixed:
"No test reaches this within 3 caller hops", "Reached by tests · N files within 3 hops", "Uncertain · N name-only matches, confidence < 0.6",
"outside the index", "Where the graph stops", "changed on disk after the last index sync".
"outside the index", "Where the graph stops", "changed on disk after the last index sync", "Index updated · reloaded", "Not live".
---