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
+24
View File
@@ -9,9 +9,12 @@
import MapView from './views/MapView.svelte';
import FlowView from './views/FlowView.svelte';
import NotFoundView from './views/NotFoundView.svelte';
import Toast from './components/Toast.svelte';
import { router, navigate, back, mapHref, flowHref } from './lib/router.svelte';
import { trail, resolveTrailNames } from './lib/trail.svelte';
import { project } from './lib/project.svelte';
import { live } from './lib/live.svelte';
import { toast } from './lib/toast.svelte';
// One `/api/stats` for the whole app: the top bar's counts and the Symbol
// view's blast-radius denominator come out of the same payload.
@@ -19,6 +22,26 @@
void project.ensure();
});
// The live channel: one connection for the page, opened once. Every screen
// reads its counters; nothing polls.
$effect(() => {
live.start();
});
// The index moving is the one thing worth a note — the screen under it has
// already refetched by the time this shows. `/api/stats` is re-read for the
// same reason: the top bar's counts came from the graph that just changed.
let seenIndexTick = live.indexTick;
$effect(() => {
const tick = live.indexTick;
untrack(() => {
if (tick === seenIndexTick) return;
seenIndexTick = tick;
void project.reload();
toast.show('Index updated · reloaded');
});
});
let topbar: TopBar | null = $state(null);
let route = $derived(router.route);
@@ -115,6 +138,7 @@
<HomeView project={project.name} />
{/if}
</main>
<Toast />
<style>
/* The shell grid lives on #app (index.html's mount host) in app.css —