Ver Fonte

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.
Colby McHenry há 1 semana atrás
pai
commit
ecd6e1cd15

+ 10 - 0
CHANGELOG.md

@@ -36,6 +36,16 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
   In the left margin is an arc for every call that stays inside the file, drawn from the calling line to the line the callee is defined on. Nothing is laid out by an algorithm — the author already put the symbols in order, so source order does the work, and this is the one place a file's internal call structure is legible at a glance. Hover a line to light the arcs the function under your cursor takes part in, and click an arc to jump to the other end. On a file with more than forty of them the picture narrows to the symbol you're reading instead of drawing a wash of overlapping sweeps, with the total in the header. A rail on the far left lists the file's symbols and follows you as you scroll, when the window is wide enough for it.
 
+- **The viewer keeps up with your project while it's open.** Save a file and `codegraph ui` says so within about a third of a second: a banner on the file's screen explaining that the index hasn't caught up yet, and the file's **current** source in place of a body sliced at line numbers it no longer has — the call arcs, gutter markers and call list go away with the old numbering rather than pointing at the wrong lines. It's the same answer `codegraph_node` gives your agent about a file that changed after its last sync.
+
+  When anything re-indexes the project — your agent's background sync, `codegraph sync`, a git hook — whatever is on screen re-reads the graph and a small "Index updated · reloaded" note appears. A symbol that moved because you added a line above it is followed to its new place, with your trail intact, instead of turning into a dead link.
+
+  Nothing polls: the viewer watches for these two things and is told about them. If it loses touch with the server it retries a few times with a growing delay, then stops and says "Not live" in the top bar rather than hammering a port that isn't answering.
+
+### Fixes
+
+- Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it.
+
 
 ## [1.6.0] - 2026-08-26
 

+ 1 - 0
README.md

@@ -348,6 +348,7 @@ What you get on that screen:
 - Click any file path to open the **file view**: everything that file depends on, its outline in source order, and everything that depends on it. Its **Source** tab shows the whole file with the same gutter markers, plus an arc in the left margin for every call that stays inside the file — the one place a file's internal call structure is legible, because source order does the layout. A 6,800-line file scrolls at full speed.
 - **Ask for a path.** Type "how does execute reach getFile" (or `execute -> getFile`) and you get the **flow**: one card per hop, each opened at the line that makes the next call. Hops that no static edge records — a callback, an interface dispatch, a React re-render — are drawn dashed and name where the handler was wired. "Read as flow" turns a walk you did by hand into the same strip.
 - **The map**: the whole project at module granularity, laid out from the graph with dependencies pointing down — never drawn by hand, and the same picture every time. Cycles are listed rather than straightened away.
+- **It keeps up.** Save a file and a banner appears within about a third of a second saying the index hasn't caught up yet — and the screen switches to the file's current source rather than a body sliced at lines it no longer has. When something re-indexes, whatever is on screen refetches itself and says "Index updated · reloaded". A symbol that moved because you added a line above it is followed, not lost. Nothing polls: the viewer watches, and if it loses touch with the server it retries a few times and then says so instead of hammering it.
 
 Options: `--port <n>` to pin a port (without it the viewer takes 4747, or the next free one),
 `--no-open` to just print the URL for a headless box or an SSH session, and

+ 479 - 0
__tests__/ui-events-api.test.ts

@@ -0,0 +1,479 @@
+/**
+ * The viewer's live channel and its drift parity (CG-53).
+ *
+ * Two things are proved here that a unit test could not:
+ *
+ * - `GET /api/events` is a real SSE stream over the real loopback server, and
+ *   it says something the moment a source file changes and again when the index
+ *   moves underneath it. Both watchers are edge-triggered, so a test that
+ *   passed by polling would be testing the wrong thing entirely.
+ * - `/api/source?ondrift=current` serves a drifted file's CURRENT bytes rather
+ *   than nothing, flagged `showing: 'current'` — the parity with
+ *   `codegraph_node`'s behaviour on a file that changed after its last sync.
+ *
+ * Every test that rewrites a fixture file restores it, because the fixture is
+ * indexed once for the whole suite.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
+import { HEARTBEAT_MS, MAX_EVENT_FILES } from '../src/ui-server/api/events';
+
+let server: UiServerHandle;
+let api: GraphApi;
+let tempDir: string;
+let projectRoot: string;
+
+const ORIGINAL = `export function greet(name: string): string {
+  return 'hello ' + name;
+}
+
+export function shout(name: string): string {
+  return greet(name).toUpperCase();
+}
+`;
+
+function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      {
+        host: '127.0.0.1',
+        port: server.port,
+        path: requestPath,
+        method: 'GET',
+        headers: { Host: `127.0.0.1:${server.port}` },
+        setHost: false,
+      },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({
+            status: res.statusCode ?? 0,
+            body: Buffer.concat(chunks).toString('utf-8'),
+            type: res.headers['content-type'],
+          })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+interface SseEvent {
+  event: string;
+  data: any;
+}
+
+/**
+ * One open SSE connection, with the frames it has received so far.
+ *
+ * The parser is the whole SSE grammar this server uses: `retry:`, `event:`,
+ * `data:` and a blank line. Comment frames (`: ping`) are counted separately —
+ * they are the heartbeat, and a client must never see them as events.
+ */
+class Stream {
+  readonly events: SseEvent[] = [];
+  comments = 0;
+  status = 0;
+  contentType: string | undefined;
+  private buffer = '';
+  private req: http.ClientRequest | null = null;
+  private res: http.IncomingMessage | null = null;
+
+  open(requestPath = '/api/events'): Promise<void> {
+    return new Promise((resolve, reject) => {
+      const req = http.request(
+        {
+          host: '127.0.0.1',
+          port: server.port,
+          path: requestPath,
+          method: 'GET',
+          headers: { Host: `127.0.0.1:${server.port}`, Accept: 'text/event-stream' },
+          setHost: false,
+        },
+        (res) => {
+          this.res = res;
+          this.status = res.statusCode ?? 0;
+          this.contentType = res.headers['content-type'];
+          res.setEncoding('utf-8');
+          res.on('data', (chunk: string) => this.ingest(chunk));
+          resolve();
+        }
+      );
+      this.req = req;
+      req.on('error', reject);
+      req.end();
+    });
+  }
+
+  private ingest(chunk: string): void {
+    this.buffer += chunk;
+    let split = this.buffer.indexOf('\n\n');
+    while (split !== -1) {
+      const frame = this.buffer.slice(0, split);
+      this.buffer = this.buffer.slice(split + 2);
+      this.parse(frame);
+      split = this.buffer.indexOf('\n\n');
+    }
+    // A heartbeat is its own frame and ends the same way, but node may deliver
+    // it alone; the loop above already handled it.
+  }
+
+  private parse(frame: string): void {
+    let name = 'message';
+    let data = '';
+    for (const line of frame.split('\n')) {
+      if (line.startsWith(':')) {
+        this.comments += 1;
+        continue;
+      }
+      if (line.startsWith('event: ')) name = line.slice(7);
+      else if (line.startsWith('data: ')) data += line.slice(6);
+    }
+    if (data === '') return;
+    try {
+      this.events.push({ event: name, data: JSON.parse(data) });
+    } catch {
+      this.events.push({ event: name, data });
+    }
+  }
+
+  /** Wait for an event of `type`, or give up. Never polls the server. */
+  async waitFor(type: string, timeoutMs = 12_000): Promise<SseEvent> {
+    const deadline = Date.now() + timeoutMs;
+    for (;;) {
+      const hit = this.events.find((e) => e.event === type);
+      if (hit) return hit;
+      if (Date.now() > deadline) {
+        throw new Error(
+          `No "${type}" event within ${timeoutMs}ms. Saw: ${this.events.map((e) => e.event).join(', ') || '(nothing)'}`
+        );
+      }
+      await new Promise((r) => setTimeout(r, 25));
+    }
+  }
+
+  close(): void {
+    this.res?.destroy();
+    this.req?.destroy();
+  }
+}
+
+function fixture(rel: string): string {
+  return path.join(projectRoot, rel);
+}
+
+beforeAll(async () => {
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-events-'));
+  projectRoot = path.join(tempDir, 'project');
+  fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
+  fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
+  fs.writeFileSync(
+    fixture('src/other.ts'),
+    `import { greet } from './greet';\n\nexport const hi = greet('there');\n`
+  );
+
+  const cg = CodeGraph.initSync(projectRoot, {
+    config: { include: ['src/**/*.ts'], exclude: [] },
+  });
+  await cg.indexAll();
+  cg.resolveReferences();
+  cg.close();
+
+  const viewerDir = path.join(tempDir, 'viewer');
+  fs.mkdirSync(viewerDir, { recursive: true });
+  fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
+
+  api = createGraphApi({ projectRoot });
+  server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
+}, 120_000);
+
+afterAll(async () => {
+  api?.close();
+  await server?.close();
+  if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('GET /api/events', () => {
+  it('is listed by the API index', async () => {
+    const index = JSON.parse((await request('/api')).body);
+    const paths = index.endpoints.map((e: any) => e.path);
+    expect(paths).toContain('/api/events');
+  });
+
+  it('answers as an event stream and opens with the index revision', async () => {
+    const stream = new Stream();
+    await stream.open();
+    try {
+      const hello = await stream.waitFor('hello');
+      expect(stream.status).toBe(200);
+      expect(stream.contentType).toBe('text/event-stream; charset=utf-8');
+      expect(hello.data.type).toBe('hello');
+      // The revision the client is synchronised against — the same numbers
+      // /api/stats reports.
+      expect(hello.data.index.files).toBe(2);
+      expect(typeof hello.data.index.lastIndexedAt).toBe('number');
+      expect(hello.data.heartbeatMs).toBe(HEARTBEAT_MS);
+      // Whether each observer came up is stated, never implied.
+      expect(typeof hello.data.watching.source).toBe('boolean');
+      expect(typeof hello.data.watching.index).toBe('boolean');
+      expect(hello.data.degraded).toBeNull();
+    } finally {
+      stream.close();
+    }
+  });
+
+  it('never sends a heartbeat as an event', async () => {
+    const stream = new Stream();
+    await stream.open();
+    try {
+      await stream.waitFor('hello');
+      // The heartbeat is a comment frame; if it ever became an event, every
+      // client would refetch every 25 seconds forever.
+      expect(stream.events.every((e) => e.event !== 'ping' && e.event !== 'message')).toBe(true);
+    } finally {
+      stream.close();
+    }
+  });
+
+  it('answers HEAD with the stream headers and no body', async () => {
+    const res = await new Promise<{ status: number; type?: string; body: string }>((resolve, reject) => {
+      const req = http.request(
+        {
+          host: '127.0.0.1',
+          port: server.port,
+          path: '/api/events',
+          method: 'HEAD',
+          headers: { Host: `127.0.0.1:${server.port}` },
+          setHost: false,
+        },
+        (r) => {
+          const chunks: Buffer[] = [];
+          r.on('data', (c: Buffer) => chunks.push(c));
+          r.on('end', () =>
+            resolve({
+              status: r.statusCode ?? 0,
+              type: r.headers['content-type'],
+              body: Buffer.concat(chunks).toString('utf-8'),
+            })
+          );
+        }
+      );
+      req.on('error', reject);
+      req.end();
+    });
+    expect(res.status).toBe(200);
+    expect(res.type).toBe('text/event-stream; charset=utf-8');
+    expect(res.body).toBe('');
+  });
+
+  it('announces a source file that changed on disk, before any sync', async () => {
+    const stream = new Stream();
+    await stream.open();
+    try {
+      await stream.waitFor('hello');
+      // Give the watcher a moment to install its watch before the write; an
+      // event that predates the watch is not a bug, just an untestable one.
+      await new Promise((r) => setTimeout(r, 300));
+      fs.writeFileSync(fixture('src/greet.ts'), `${ORIGINAL}\nexport const EXTRA = 1;\n`);
+
+      const changed = await stream.waitFor('changed');
+      expect(changed.data.type).toBe('changed');
+      expect(changed.data.scan === true || changed.data.files.includes('src/greet.ts')).toBe(true);
+      // A count always equals a list, or says it was cut.
+      expect(changed.data.total).toBeGreaterThanOrEqual(changed.data.files.length);
+      expect(changed.data.files.length).toBeLessThanOrEqual(MAX_EVENT_FILES);
+
+      // ...and the index has NOT moved: this server watches, it never syncs.
+      const source = JSON.parse((await request('/api/source?file=src/greet.ts')).body);
+      expect(source.drift).toBe(true);
+    } finally {
+      fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
+      stream.close();
+    }
+  });
+
+  it('announces the index moving, and names what the sync picked up', async () => {
+    const stream = new Stream();
+    await stream.open();
+    try {
+      await stream.waitFor('hello');
+      await new Promise((r) => setTimeout(r, 300));
+
+      // Another process re-indexes — exactly what a daemon's watcher or a
+      // `codegraph sync` does while the viewer is open.
+      fs.writeFileSync(fixture('src/greet.ts'), `${ORIGINAL}\nexport const SYNCED = 2;\n`);
+      const writer = CodeGraph.openSync(projectRoot);
+      await writer.sync();
+      writer.close();
+
+      const moved = await stream.waitFor('index');
+      expect(moved.data.type).toBe('index');
+      expect(moved.data.index.files).toBe(2);
+      expect(moved.data.files).toContain('src/greet.ts');
+      expect(moved.data.total).toBeGreaterThanOrEqual(moved.data.files.length);
+
+      // And the graph really did move: the new symbol is there.
+      const search = JSON.parse((await request('/api/search?q=SYNCED')).body);
+      expect(search.results.items.some((r: any) => r.name === 'SYNCED')).toBe(true);
+    } finally {
+      fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
+      const writer = CodeGraph.openSync(projectRoot);
+      await writer.sync();
+      writer.close();
+      stream.close();
+    }
+  }, 60_000);
+
+  it('stops serving a symbol a sync in another process deleted', async () => {
+    // A node's id contains its start line, so pushing two lines in above
+    // `shout` gives it a different id. The old one must go — the query layer
+    // keeps an LRU of nodes by id that only its OWN writes invalidate, so
+    // without `GraphSession` dropping it this endpoint would keep answering
+    // 200 with a row that is no longer in the database, while `/api/search`
+    // beside it correctly says the symbol moved.
+    const before = JSON.parse((await request('/api/search?q=shout')).body);
+    const oldId = before.results.items[0].id as string;
+    expect((await request(`/api/node/${encodeURIComponent(oldId)}`)).status).toBe(200);
+
+    fs.writeFileSync(fixture('src/greet.ts'), `// one
+// two
+${ORIGINAL}`);
+    const writer = CodeGraph.openSync(projectRoot);
+    await writer.sync();
+    writer.close();
+
+    try {
+      expect((await request(`/api/node/${encodeURIComponent(oldId)}`)).status).toBe(404);
+      const after = JSON.parse((await request('/api/search?q=shout')).body);
+      const newId = after.results.items[0].id as string;
+      expect(newId).not.toBe(oldId);
+      const moved = JSON.parse((await request(`/api/node/${encodeURIComponent(newId)}`)).body);
+      expect(moved.node.line).toBe(7);
+      // ...and its rails came back with it, rather than an empty shell — the
+      // exact symptom of a cached row whose edges were re-keyed around it.
+      expect(moved.counts.callees).toBeGreaterThan(0);
+    } finally {
+      fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
+      const restore = CodeGraph.openSync(projectRoot);
+      await restore.sync();
+      restore.close();
+    }
+  }, 60_000);
+
+  it('closes every stream when the API is closed', async () => {
+    const own = createGraphApi({ projectRoot });
+    const handle = await startUiServer({
+      projectRoot,
+      viewerDir: path.join(tempDir, 'viewer'),
+      port: 0,
+      api: own.handler,
+    });
+    const ended = new Promise<void>((resolve, reject) => {
+      const req = http.request(
+        {
+          host: '127.0.0.1',
+          port: handle.port,
+          path: '/api/events',
+          method: 'GET',
+          headers: { Host: `127.0.0.1:${handle.port}` },
+          setHost: false,
+        },
+        (res) => {
+          res.resume();
+          res.on('end', () => resolve());
+        }
+      );
+      req.on('error', reject);
+      req.end();
+    });
+    // Let the subscription land before pulling the rug.
+    await new Promise((r) => setTimeout(r, 200));
+    own.close();
+    await ended;
+    await handle.close();
+  });
+});
+
+describe('GET /api/source?ondrift=', () => {
+  it('omits the slice by default when the file drifted', async () => {
+    fs.writeFileSync(fixture('src/greet.ts'), `// a new first line\n${ORIGINAL}`);
+    try {
+      const body = JSON.parse((await request('/api/source?file=src/greet.ts&from=1&to=3')).body);
+      expect(body.drift).toBe(true);
+      expect(body.showing).toBe('none');
+      expect(body.lines).toBeUndefined();
+      expect(body.highlight).toBeUndefined();
+      expect(body.reason).toMatch(/changed on disk/);
+    } finally {
+      fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
+    }
+  });
+
+  it('serves the CURRENT bytes when asked, flagged as current', async () => {
+    const rewritten = `// a new first line\n${ORIGINAL}`;
+    fs.writeFileSync(fixture('src/greet.ts'), rewritten);
+    try {
+      const body = JSON.parse(
+        (await request('/api/source?file=src/greet.ts&from=1&ondrift=current')).body
+      );
+      expect(body.drift).toBe(true);
+      expect(body.showing).toBe('current');
+      // The bytes on disk right now, not the ones that were indexed.
+      expect(body.lines[0]).toBe('// a new first line');
+      expect(body.totalLines).toBe(rewritten.replace(/\n$/, '').split('\n').length);
+      // Highlighting rides with them, or the code block paints plain text and
+      // then reflows.
+      expect(body.highlight).toBeTruthy();
+      expect(body.highlight.lines.length).toBe(body.lines.length);
+      expect(body.reason).toMatch(/current lines/);
+    } finally {
+      fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
+    }
+  });
+
+  it('says showing: indexed when there is no drift, with or without the flag', async () => {
+    const plain = JSON.parse((await request('/api/source?file=src/greet.ts&from=1&to=2')).body);
+    expect(plain.drift).toBe(false);
+    expect(plain.showing).toBe('indexed');
+    const asked = JSON.parse(
+      (await request('/api/source?file=src/greet.ts&from=1&to=2&ondrift=current')).body
+    );
+    expect(asked.showing).toBe('indexed');
+    expect(asked.lines).toEqual(plain.lines);
+  });
+
+  it('rejects an ondrift value it does not implement', async () => {
+    const res = await request('/api/source?file=src/greet.ts&ondrift=guess');
+    expect(res.status).toBe(400);
+    expect(res.type).toBe('application/json; charset=utf-8');
+    expect(JSON.parse(res.body).code).toBe('bad-request');
+  });
+
+  it('answers an empty slice rather than a 400 when a drifted file shrank', async () => {
+    fs.writeFileSync(fixture('src/greet.ts'), 'export const only = 1;\n');
+    try {
+      const res = await request('/api/source?file=src/greet.ts&from=5&to=9&ondrift=current');
+      expect(res.status).toBe(200);
+      const body = JSON.parse(res.body);
+      expect(body.showing).toBe('current');
+      expect(body.lines).toEqual([]);
+      expect(body.totalLines).toBe(1);
+    } finally {
+      fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
+    }
+  });
+
+  it('still refuses a path outside the project, ondrift or not', async () => {
+    const res = await request('/api/source?file=/etc/passwd&ondrift=current');
+    expect(res.status).toBe(403);
+    expect(JSON.parse(res.body).code).toBe('refused');
+  });
+});

+ 25 - 1
docs/design/codegraph-ui-design-spec.md

@@ -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".
 
 ---
 

+ 11 - 1
site/src/content/docs/guides/viewer.md

@@ -31,7 +31,17 @@ The viewer never presents a guess as a fact:
 - Edges CodeGraph resolved by name alone, below its confidence threshold, fold into an "uncertain" line rather than sitting among the resolved ones. Nothing is silently dropped — the count is always there.
 - A symbol that no test reaches within three caller hops wears a badge saying exactly that.
 - Calls into symbols that aren't in the index are counted and marked, not omitted.
-- A file that changed on disk since it was indexed shows a banner instead of source that may no longer line up.
+- A file that changed on disk since it was indexed wears a banner and switches to the file's **current** source, with everything the graph anchors to a line number — the gutter markers, the call arcs, the right-hand list — switched off. The bytes on disk are right by construction; the line numbers the index recorded are the part that stopped being true.
+
+## It keeps up with your project
+
+The viewer follows the project while it is open, and it does it by watching, never by asking on a timer.
+
+- **Save a file and the banner appears** — about a third of a second later, before any sync has run. That is the honest state: the file on disk and the index have parted company, and the screen says so rather than showing you a body sliced at the wrong lines.
+- **When something re-indexes** — your agent's background sync, `codegraph sync`, a git hook — whatever is on screen refetches itself and a small "Index updated · reloaded" note appears at the bottom. The symbol, the file, the map and the flow are all answers about the graph as a whole, so all of them re-read it.
+- **A symbol that moved is followed, not lost.** Adding two lines above a function changes its identity in the graph; the viewer finds it again in its file and carries your trail across, rather than telling you the thing you were reading no longer exists.
+
+If the viewer ever loses touch with the server, it retries a handful of times with a growing delay and then stops and says **"Not live"** in the top bar — it never falls back to polling. Focus the tab to reconnect.
 
 ## Getting around
 

+ 4 - 0
src/bin/codegraph.ts

@@ -1889,6 +1889,10 @@ box for the flow between two symbols: one card per hop, opened at the line that
 makes the next call, with dynamic-dispatch hops drawn dashed and named. The Map
 tab draws the whole project by module, with dependencies pointing down.
 
+The page keeps up with the project while it is open: save a file and it says so
+within about a third of a second, and whatever is on screen re-reads the graph
+when something re-indexes it. It watches for that; it never polls.
+
 The viewer listens on 127.0.0.1 only, so nothing on your network can reach it,
 and it is read-only: it opens an index that already exists and never changes
 your project or your graph. Requests from any other host are refused, and

+ 36 - 0
src/db/queries.ts

@@ -2620,6 +2620,42 @@ export class QueryBuilder {
     return row?.last ?? null;
   }
 
+  /**
+   * The index's revision marker: how far the last sync got, and how many files
+   * it left behind — one query, both numbers.
+   *
+   * This is the cheapest honest answer to "has the index moved since I last
+   * looked". `MAX(indexed_at)` alone is not enough: a sync that only DELETES
+   * files (a branch checkout that removed a directory) advances nothing, and
+   * the graph the viewer is showing has still changed underneath it. The row
+   * count catches exactly that case.
+   */
+  getIndexRevision(): { lastIndexedAt: number | null; fileCount: number } {
+    const row = this.db
+      .prepare('SELECT MAX(indexed_at) AS last, COUNT(*) AS files FROM files')
+      .get() as { last: number | null; files: number } | undefined;
+    return { lastIndexedAt: row?.last ?? null, fileCount: row?.files ?? 0 };
+  }
+
+  /**
+   * Files re-indexed strictly after `since` (ms since epoch), newest first.
+   *
+   * `total` is the real count; `paths` is capped at `limit`. Used by the
+   * viewer's live channel to name what a sync just picked up. A file the same
+   * sync DELETED cannot appear here — it has no row left — which is why the
+   * caller compares {@link getIndexRevision} as well rather than treating an
+   * empty list as "nothing happened".
+   */
+  getFilesIndexedSince(since: number, limit: number): { paths: string[]; total: number } {
+    const count = this.db
+      .prepare('SELECT COUNT(*) AS n FROM files WHERE indexed_at > ?')
+      .get(since) as { n: number } | undefined;
+    const rows = this.db
+      .prepare('SELECT path FROM files WHERE indexed_at > ? ORDER BY indexed_at DESC, path LIMIT ?')
+      .all(since, Math.max(0, limit)) as Array<{ path: string }>;
+    return { paths: rows.map((r) => r.path), total: count?.n ?? rows.length };
+  }
+
   /**
    * Get files that need re-indexing (hash changed)
    */

+ 37 - 0
src/index.ts

@@ -1154,6 +1154,43 @@ export class CodeGraph {
     return this.queries.getLastIndexedAt();
   }
 
+  /**
+   * How far the last sync got and how many files it left behind — the cheapest
+   * marker of "has this index moved". One query; safe to call on every
+   * filesystem event a live viewer sees.
+   */
+  getIndexRevision(): { lastIndexedAt: number | null; fileCount: number } {
+    return this.queries.getIndexRevision();
+  }
+
+  /**
+   * Files re-indexed strictly after `since`, newest first — what a sync just
+   * picked up. `total` is the real count, `paths` is capped at `limit`.
+   */
+  getFilesIndexedSince(since: number, limit: number): { paths: string[]; total: number } {
+    return this.queries.getFilesIndexedSince(since, limit);
+  }
+
+  /**
+   * Forget everything held in memory about rows another process may have
+   * changed.
+   *
+   * The query layer keeps an LRU of nodes by id, invalidated by writes made
+   * through THIS instance — which is exactly right for a process that owns the
+   * index, and wrong for one that is only reading a database somebody else is
+   * writing. A long-lived reader (the `codegraph ui` server, a daemon holding a
+   * graph open across an agent's edits) will otherwise answer `getNode(id)`
+   * with a row a sync deleted minutes ago, while every SQL-backed query beside
+   * it reports the truth — a disagreement that reads as a bug in whichever
+   * screen shows both.
+   *
+   * Cheap (clearing a bounded Map) and safe to call whenever the database file
+   * looks like it moved.
+   */
+  dropReadCaches(): void {
+    this.queries.clearCache();
+  }
+
   /**
    * Completeness of the last full index run. `'complete'` is the only good
    * state. `'indexing'` after the fact means a run was killed mid-index (OOM,

+ 480 - 0
src/ui-server/api/events.ts

@@ -0,0 +1,480 @@
+/**
+ * `GET /api/events` — the viewer's live channel (server-sent events).
+ *
+ * Two questions the open browser cannot answer for itself, and one stream that
+ * answers both:
+ *
+ * - **"has the file I'm looking at changed on disk?"** — the drift banner. The
+ *   verdict itself comes from `/api/source` (it hashes the bytes); this stream
+ *   only says *when to ask again*, so the banner appears about a third of a
+ *   second after a save instead of on the next navigation.
+ * - **"has the index moved?"** — the live refresh. Something else (an agent's
+ *   MCP daemon, `codegraph sync`, a git hook) writes the graph; when it does,
+ *   every screen the viewer is showing is one round-trip out of date.
+ *
+ * ## This server watches. It never syncs.
+ *
+ * `codegraph ui` is read-only in every sense — the banner it prints says so —
+ * so the obvious implementation (run the engine's watcher, let it sync) is out.
+ * What is left is *observation*, from two independent directions:
+ *
+ * - the project tree, through the engine's own {@link FileWatcher} with a
+ *   notify-only `syncFn`. It never writes: the callback that would have run a
+ *   sync fans the changed paths out to the browser instead. Everything else
+ *   about it — the per-platform watch strategy, the indexer's ignore scope, the
+ *   adaptive debounce, the degrade latch — is behaviour we would otherwise have
+ *   had to write again, worse.
+ * - the index itself, through one non-recursive `fs.watch` on the data
+ *   directory. That is the only cross-process signal there is: the writer is a
+ *   different process, and the thing it changes is a file. A settled write is
+ *   followed by ONE cheap query (`getIndexRevision`), and only a revision that
+ *   actually moved becomes an event.
+ *
+ * **Nothing polls.** Both watchers are edge-triggered, and both start on the
+ * first subscriber and stop with the last one — a viewer nobody has open costs
+ * no watch descriptors, which matters on Linux where the strategy is
+ * per-directory.
+ *
+ * ## Boundary
+ *
+ * A long-lived response sits inside the loopback boundary exactly like every
+ * other route: `Host`, `Origin` and the GET-only rule are already enforced by
+ * `startUiServer` before this module is reached, and nothing here reads the
+ * repository — the paths it names came from the watcher and the index, and the
+ * viewer has to go back through `/api/source` (and therefore through
+ * `resolveProjectFile`) to see a byte of any of them.
+ */
+
+import * as fs from 'fs';
+import type { IncomingMessage, ServerResponse } from 'http';
+import type { CodeGraph } from '../../index';
+import { getCodeGraphDir } from '../../directory';
+import { FileWatcher } from '../../sync/watcher';
+import type { GraphSession } from './session';
+
+/**
+ * Paths carried in one event. `total` is always the real number — a burst of
+ * two thousand files still says two thousand, it just does not list them.
+ */
+export const MAX_EVENT_FILES = 200;
+
+/** Comment frame keeping the connection (and the client's idea of it) alive. */
+export const HEARTBEAT_MS = 25_000;
+
+/**
+ * Quiet window before an index write is treated as finished.
+ *
+ * A sync writes the WAL continuously, so the *end* of the writing is the signal
+ * — not its start. Long enough that a multi-second sync produces one event
+ * rather than a dozen.
+ */
+const INDEX_SETTLE_MS = 400;
+
+/**
+ * Ceiling on that quiet window. A sync large enough that the WAL never goes
+ * quiet for 400 ms would otherwise hold the first event until it finished; the
+ * cap makes the viewer refresh mid-way instead, which is still true — the graph
+ * really has moved — and costs one query.
+ */
+const INDEX_SETTLE_MAX_MS = 3_000;
+
+/**
+ * Debounce for source-file events. The watcher's own adaptive rule fires a lone
+ * save after `min(300, this)` ms of quiet and keeps the full window for a
+ * burst, so a single edit reaches the browser well inside the one-second bar
+ * while an agent rewriting forty files still arrives as one event.
+ */
+const SOURCE_DEBOUNCE_MS = 500;
+
+/* --------------------------------------------------------------- the wire -- */
+
+export interface WireIndexRevision {
+  lastIndexedAt: number | null;
+  files: number;
+}
+
+/** Sent once, immediately, so a client knows what it is synchronised against. */
+export interface WireEventHello {
+  type: 'hello';
+  index: WireIndexRevision | null;
+  /** Which of the two observers actually came up. */
+  watching: { source: boolean; index: boolean };
+  /** Non-null when live watching has given up; the client must NOT start polling. */
+  degraded: string | null;
+  heartbeatMs: number;
+  at: number;
+}
+
+/** Source files changed on disk. The index has NOT caught up yet. */
+export interface WireEventChanged {
+  type: 'changed';
+  files: string[];
+  total: number;
+  truncated: boolean;
+  /**
+   * True when the change could not be described file by file (a directory
+   * removal, or a burst past the watcher's scoped ceiling). Treat any open file
+   * as possibly affected.
+   */
+  scan: boolean;
+  at: number;
+}
+
+/** The index moved: some other process finished writing the graph. */
+export interface WireEventIndex {
+  type: 'index';
+  index: WireIndexRevision;
+  /** Files this sync re-indexed, newest first. Empty when it only deleted. */
+  files: string[];
+  total: number;
+  truncated: boolean;
+  at: number;
+}
+
+/** Live watching has stopped for good. Sent once; the stream stays open. */
+export interface WireEventDegraded {
+  type: 'degraded';
+  reason: string;
+  at: number;
+}
+
+export type WireEvent =
+  | WireEventHello
+  | WireEventChanged
+  | WireEventIndex
+  | WireEventDegraded;
+
+/* ---------------------------------------------------------------- the hub -- */
+
+interface Client {
+  res: ServerResponse;
+  heartbeat: ReturnType<typeof setInterval>;
+}
+
+/**
+ * Fans filesystem and index changes out to every open viewer.
+ *
+ * One hub per server. It owns the watchers, and owns them lazily: they exist
+ * only while somebody is listening.
+ */
+export class EventHub {
+  private readonly projectRoot: string;
+  private readonly session: GraphSession;
+  private readonly clients = new Set<Client>();
+
+  private sourceWatcher: FileWatcher | null = null;
+  private indexWatcher: fs.FSWatcher | null = null;
+  private indexTimer: ReturnType<typeof setTimeout> | null = null;
+  /** When the current settle window started, for the {@link INDEX_SETTLE_MAX_MS} cap. */
+  private indexPendingSince = 0;
+  private revision: WireIndexRevision | null = null;
+  private sourceUp = false;
+  private indexUp = false;
+  private degraded: string | null = null;
+  private closed = false;
+
+  constructor(projectRoot: string, session: GraphSession) {
+    this.projectRoot = projectRoot;
+    this.session = session;
+  }
+
+  /**
+   * Attach one browser to the stream.
+   *
+   * Returns `true` in every case — the response is answered here, streaming or
+   * not — so it slots into the API's `switch` like any other endpoint.
+   */
+  subscribe(req: IncomingMessage, res: ServerResponse, method: string): true {
+    if (this.closed) {
+      // The server is shutting down. Answer, do not attach: a client that got a
+      // stream here would hold the socket open against `close()`.
+      res.writeHead(503, {
+        'Content-Type': 'application/json; charset=utf-8',
+        'Cache-Control': 'no-store',
+      });
+      res.end(method === 'HEAD' ? undefined : JSON.stringify({ error: 'Shutting down.', code: 'internal' }));
+      return true;
+    }
+
+    res.writeHead(200, {
+      'Content-Type': 'text/event-stream; charset=utf-8',
+      'Cache-Control': 'no-store',
+      // Node would otherwise chunk small writes; an event that sits in a buffer
+      // is an event that did not happen.
+      Connection: 'keep-alive',
+      'X-Accel-Buffering': 'no',
+    });
+
+    if (method === 'HEAD') {
+      res.end();
+      return true;
+    }
+
+    // No keep-alive timeout on this socket: the server sets one globally so
+    // Ctrl-C does not wait on browser connections, and it would close a healthy
+    // stream between heartbeats.
+    res.socket?.setTimeout(0);
+    res.socket?.setNoDelay(true);
+
+    this.ensureWatching();
+
+    const client: Client = {
+      res,
+      heartbeat: setInterval(() => {
+        // A comment frame. Not an event, so no client handler ever sees it —
+        // it exists to notice a socket the other end has already dropped.
+        if (!res.writableEnded) res.write(': ping\n\n');
+      }, HEARTBEAT_MS),
+    };
+    // `unref` so a live stream never keeps the process alive on its own.
+    client.heartbeat.unref?.();
+    this.clients.add(client);
+
+    const drop = (): void => this.drop(client);
+    res.on('close', drop);
+    res.on('error', drop);
+    req.on('aborted', drop);
+
+    this.send(client, {
+      type: 'hello',
+      index: this.revision,
+      watching: { source: this.sourceUp, index: this.indexUp },
+      degraded: this.degraded,
+      heartbeatMs: HEARTBEAT_MS,
+      at: Date.now(),
+    });
+    return true;
+  }
+
+  /** Number of attached clients — for tests and for the watchers' lifetime. */
+  get size(): number {
+    return this.clients.size;
+  }
+
+  /** Stop watching and end every open stream. Idempotent. */
+  close(): void {
+    this.closed = true;
+    this.stopWatching();
+    for (const client of [...this.clients]) {
+      clearInterval(client.heartbeat);
+      this.clients.delete(client);
+      try {
+        client.res.end();
+      } catch {
+        /* the socket is already gone */
+      }
+    }
+  }
+
+  /* ------------------------------------------------------------ plumbing -- */
+
+  private drop(client: Client): void {
+    if (!this.clients.delete(client)) return;
+    clearInterval(client.heartbeat);
+    if (this.clients.size === 0) this.stopWatching();
+  }
+
+  private send(client: Client, event: WireEvent): void {
+    if (client.res.writableEnded) return;
+    try {
+      // `retry` on every frame is cheap and means a client that reconnects with
+      // the browser's own EventSource still backs off the way we asked.
+      client.res.write(`retry: 3000\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
+    } catch {
+      this.drop(client);
+    }
+  }
+
+  private broadcast(event: WireEvent): void {
+    for (const client of [...this.clients]) this.send(client, event);
+  }
+
+  /* ------------------------------------------------------------ watching -- */
+
+  private ensureWatching(): void {
+    if (this.closed) return;
+    this.revision ??= this.probe();
+    this.startSourceWatcher();
+    this.startIndexWatcher();
+  }
+
+  private stopWatching(): void {
+    if (this.indexTimer) {
+      clearTimeout(this.indexTimer);
+      this.indexTimer = null;
+    }
+    this.indexPendingSince = 0;
+    try {
+      this.indexWatcher?.close();
+    } catch {
+      /* already closed */
+    }
+    this.indexWatcher = null;
+    this.indexUp = false;
+    this.sourceWatcher?.stop();
+    this.sourceWatcher = null;
+    this.sourceUp = false;
+  }
+
+  /**
+   * The project tree, through the engine's watcher with the sync taken out.
+   *
+   * The `syncFn` is the whole trick: the watcher calls it with exactly the
+   * paths it would have handed to a scoped sync (or `undefined` when the events
+   * could not describe the change), we announce them and report zero files
+   * changed. It succeeds every time, so the watcher's failure ladder — lock
+   * retries, backoff, the degrade latch — is only ever reached by the watch
+   * layer itself, which is precisely the part we do want.
+   */
+  private startSourceWatcher(): void {
+    if (this.sourceWatcher) return;
+    const watcher = new FileWatcher(
+      this.projectRoot,
+      async (paths?: string[]) => {
+        this.announceChanged(paths);
+        return { filesChanged: 0, durationMs: 0 };
+      },
+      {
+        debounceMs: SOURCE_DEBOUNCE_MS,
+        onDegraded: (reason) => {
+          this.degraded = reason;
+          this.sourceUp = false;
+          this.broadcast({ type: 'degraded', reason, at: Date.now() });
+        },
+      }
+    );
+    this.sourceWatcher = watcher;
+    this.sourceUp = watcher.start();
+    if (!this.sourceUp) {
+      // Watching is off by policy (CODEGRAPH_NO_WATCH, a WSL2 /mnt drive) or
+      // the OS refused. The stream stays — the index watcher is independent —
+      // and `hello` already told the client which half is live.
+      this.sourceWatcher = null;
+    }
+  }
+
+  private announceChanged(paths?: string[]): void {
+    const all = paths ?? [];
+    const files = all.slice(0, MAX_EVENT_FILES);
+    this.broadcast({
+      type: 'changed',
+      files,
+      total: all.length,
+      truncated: files.length < all.length,
+      scan: paths === undefined,
+      at: Date.now(),
+    });
+  }
+
+  /**
+   * The index, through one watch on the data directory.
+   *
+   * Non-recursive and on the directory rather than the database file: SQLite
+   * writes land in `codegraph.db-wal`, and a full re-index REPLACES
+   * `codegraph.db` outright (a watch on the file itself would follow the
+   * unlinked inode and never fire again).
+   */
+  private startIndexWatcher(): void {
+    if (this.indexWatcher) return;
+    const dir = getCodeGraphDir(this.projectRoot);
+    try {
+      const watcher = fs.watch(dir, { persistent: false }, () => this.scheduleProbe());
+      watcher.on('error', () => {
+        // The data directory went away, or the OS dropped the watch. Nothing to
+        // retry against — a client that reloads gets a fresh one.
+        this.indexUp = false;
+        this.indexWatcher = null;
+        try {
+          watcher.close();
+        } catch {
+          /* already closed */
+        }
+      });
+      this.indexWatcher = watcher;
+      this.indexUp = true;
+    } catch {
+      this.indexUp = false;
+    }
+  }
+
+  /**
+   * Wait for the writing to stop, then look once.
+   *
+   * Re-armed by every write, so a sync that takes four seconds produces one
+   * probe at its end — except that {@link INDEX_SETTLE_MAX_MS} caps how long
+   * the first probe can be deferred, so a continuously-writing full index still
+   * refreshes the viewer while it runs.
+   */
+  private scheduleProbe(): void {
+    if (this.closed) return;
+    const now = Date.now();
+    if (this.indexPendingSince === 0) this.indexPendingSince = now;
+    const remaining = Math.max(0, this.indexPendingSince + INDEX_SETTLE_MAX_MS - now);
+    if (this.indexTimer) clearTimeout(this.indexTimer);
+    const timer = setTimeout(() => {
+      this.indexTimer = null;
+      this.indexPendingSince = 0;
+      this.checkIndex();
+    }, Math.min(INDEX_SETTLE_MS, remaining));
+    timer.unref?.();
+    this.indexTimer = timer;
+  }
+
+  /** One query. An unmoved revision is not an event. */
+  private checkIndex(): void {
+    if (this.closed || this.clients.size === 0) return;
+    const next = this.probe();
+    if (next === null) return;
+    const previous = this.revision;
+    this.revision = next;
+    if (
+      previous !== null &&
+      previous.lastIndexedAt === next.lastIndexedAt &&
+      previous.files === next.files
+    ) {
+      return;
+    }
+
+    // Everything re-indexed since the mark we were holding. A sync that only
+    // removed files names nothing here — which is why the revision comparison
+    // above, not this list, decides whether an event happens at all.
+    let files: string[] = [];
+    let total = 0;
+    const since = previous?.lastIndexedAt ?? null;
+    if (since !== null) {
+      try {
+        const changed = this.session.acquire().getFilesIndexedSince(since, MAX_EVENT_FILES);
+        files = changed.paths;
+        total = changed.total;
+      } catch {
+        /* the index went away between the probe and here — the event still stands */
+      }
+    }
+
+    this.broadcast({
+      type: 'index',
+      index: next,
+      files,
+      total: Math.max(total, files.length),
+      truncated: files.length < total,
+      at: Date.now(),
+    });
+  }
+
+  /**
+   * The current revision, or null when there is no readable index.
+   *
+   * A missing index is not an error here: `codegraph ui` refuses to start
+   * without one, but a user can delete `.codegraph/` with the viewer open, and
+   * every endpoint already says so in its own words when asked.
+   */
+  private probe(): WireIndexRevision | null {
+    try {
+      const cg: CodeGraph = this.session.acquire();
+      const revision = cg.getIndexRevision();
+      return { lastIndexedAt: revision.lastIndexedAt, files: revision.fileCount };
+    } catch {
+      return null;
+    }
+  }
+}

+ 37 - 4
src/ui-server/api/index.ts

@@ -3,8 +3,10 @@
  *
  * Eleven endpoints, one per screen, each answering in a single round-trip — the
  * same principle as `codegraph_explore`: return enough that the caller does not
- * have to ask a follow-up question. Everything here is a *reader* of the
- * existing schema; nothing indexes, resolves, or writes.
+ * have to ask a follow-up question — plus one that does not answer at all and
+ * stays open instead (`/api/events`), so a screen learns that its answer went
+ * stale rather than waiting to be asked again. Everything here is a *reader* of
+ * the existing schema; nothing indexes, resolves, or writes.
  *
  * ```
  * GET /api/stats                     what this index is and how much to trust it
@@ -18,6 +20,7 @@
  * GET /api/entrypoints               where to start reading: routes, roots, hubs
  * GET /api/map?root=&depth=          the module map: modules, links, cycles
  * GET /api/flow?from=&to=            the flow strip: one card per hop
+ * GET /api/events                    the live channel (SSE): drift and refresh
  * ```
  *
  * It mounts on the `api` seam of `startUiServer`, which means it sits *behind*
@@ -43,6 +46,7 @@ import { buildEntryPoints } from './entrypoints';
 import { buildNodeRefs } from './nodes';
 import { buildMap } from './map';
 import { buildFlow } from './flow';
+import { EventHub } from './events';
 
 export { GraphSession } from './session';
 export { ApiError } from './respond';
@@ -63,6 +67,15 @@ export type {
   WireFileCall,
   WireFileOutsideRef,
 } from './filecode';
+export { EventHub, MAX_EVENT_FILES, HEARTBEAT_MS } from './events';
+export type {
+  WireEvent,
+  WireEventHello,
+  WireEventChanged,
+  WireEventIndex,
+  WireEventDegraded,
+  WireIndexRevision,
+} from './events';
 export type {
   WireMapPayload,
   WireMapModule,
@@ -117,6 +130,11 @@ const API_INDEX = {
       description: 'The call path between symbols: one hop per card, opened at the calling line.',
       params: ['from', 'to', 'symbols', 'hop', 'limit'],
     },
+    {
+      path: '/api/events',
+      description:
+        'Live channel (server-sent events): source files that changed on disk, and the index moving.',
+    },
     {
       path: '/api/entrypoints',
       description: 'Where to start reading: routes, files that run something, and hubs.',
@@ -127,10 +145,13 @@ const API_INDEX = {
 
 export function createGraphApi(options: GraphApiOptions): GraphApi {
   const session = new GraphSession(options.projectRoot);
+  // Watches nothing until a browser subscribes, and stops again when the last
+  // one goes away — mounting the API costs no watch descriptors.
+  const events = new EventHub(options.projectRoot, session);
 
   // Async because `/api/source` highlights: everything else answers straight
   // out of SQLite and resolves on the same tick.
-  const handler: UiApiHandler = async (_req, res, ctx) => {
+  const handler: UiApiHandler = async (req, res, ctx) => {
     const route = normalize(ctx.pathname);
     try {
       switch (route) {
@@ -152,6 +173,10 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
           return ok(res, await buildSource(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
         case '/api/flow':
           return ok(res, await buildFlow(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
+        case '/api/events':
+          // Streams instead of answering: it writes its own headers and keeps
+          // the socket open, so it never goes through `ok()`.
+          return events.subscribe(req, res, ctx.method);
         default:
           return dispatchPathRoutes(route, res, ctx, session);
       }
@@ -166,7 +191,15 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
     }
   };
 
-  return { handler, close: () => session.close() };
+  return {
+    handler,
+    close: () => {
+      // Streams first: a client still attached would hold the socket open
+      // against the server's own close.
+      events.close();
+      session.close();
+    },
+  };
 }
 
 /**

+ 61 - 3
src/ui-server/api/session.ts

@@ -15,6 +15,15 @@
  *   inode and happily serve a graph that no longer exists on disk. So the file
  *   identity is re-checked on acquisition — one `stat` — and a swapped file
  *   reopens the connection.
+ * - **A sync by ANOTHER process must not be served from memory.** The same
+ *   `stat` also notices the database growing, and when it has, the read caches
+ *   go (`dropReadCaches`). The query layer holds an LRU of nodes by id which
+ *   only a write through *this* instance invalidates, so without it
+ *   `/api/node/<id>` keeps answering with a symbol an agent's sync deleted
+ *   while `/api/search` — which never caches — correctly says it is gone. That
+ *   is not a stale screen, it is two screens contradicting each other; and
+ *   because a node's id contains its start line, ANY edit above a symbol
+ *   renames it, so this is the common case rather than the corner one.
  */
 
 import * as fs from 'fs';
@@ -23,16 +32,44 @@ import { getDatabasePath } from '../../db';
 import { isInitialized } from '../../directory';
 import { ApiError } from './respond';
 
-/** Identity of the database file, so a swap underneath us is detectable. */
+/**
+ * Identity of the database file, so a swap underneath us is detectable — plus
+ * the marks that say it was WRITTEN to without being replaced.
+ *
+ * The WAL is measured as well as the database: in WAL mode a commit lands in
+ * `codegraph.db-wal` and may not touch `codegraph.db` until a checkpoint, so a
+ * whole sync can go by with the main file's size and mtime unchanged.
+ */
 interface FileIdentity {
   ino: number;
   birthtimeMs: number;
+  size: number;
+  mtimeMs: number;
+  walSize: number;
+  walMtimeMs: number;
 }
 
 function identify(dbPath: string): FileIdentity | null {
   try {
     const st = fs.statSync(dbPath);
-    return { ino: st.ino, birthtimeMs: st.birthtimeMs };
+    let walSize = 0;
+    let walMtimeMs = 0;
+    try {
+      const wal = fs.statSync(`${dbPath}-wal`);
+      walSize = wal.size;
+      walMtimeMs = wal.mtimeMs;
+    } catch {
+      // No WAL sidecar: either not in WAL mode, or fully checkpointed. Both are
+      // "nothing pending", which is what zeroes mean here.
+    }
+    return {
+      ino: st.ino,
+      birthtimeMs: st.birthtimeMs,
+      size: st.size,
+      mtimeMs: st.mtimeMs,
+      walSize,
+      walMtimeMs,
+    };
   } catch {
     return null;
   }
@@ -45,6 +82,17 @@ function sameFile(a: FileIdentity | null, b: FileIdentity | null): boolean {
   return a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
 }
 
+/** Same file, but written to since we last looked. */
+function sameContent(a: FileIdentity | null, b: FileIdentity | null): boolean {
+  if (a === null || b === null) return false;
+  return (
+    a.size === b.size &&
+    a.mtimeMs === b.mtimeMs &&
+    a.walSize === b.walSize &&
+    a.walMtimeMs === b.walMtimeMs
+  );
+}
+
 /**
  * Guidance shown when there is no index to read. Deliberately the same three
  * facts the CLI prints: the viewer never creates an index, `codegraph init`
@@ -87,7 +135,17 @@ export class GraphSession {
     const current = identify(this.dbPath);
 
     if (this.cg !== null) {
-      if (sameFile(this.identity, current)) return this.cg;
+      if (sameFile(this.identity, current)) {
+        // Same file, but somebody wrote to it. SQLite itself is fine — a WAL
+        // reader sees the new commits — but our in-memory node cache is not,
+        // so it goes. One `stat` already paid for; clearing a bounded Map is
+        // the whole cost.
+        if (!sameContent(this.identity, current)) {
+          this.identity = current;
+          this.cg.dropReadCaches();
+        }
+        return this.cg;
+      }
       // The database was replaced (a re-index) or removed. Drop the stale
       // handle; falling through re-opens against whatever is there now.
       this.closeQuietly();

+ 101 - 18
src/ui-server/api/source.ts

@@ -10,13 +10,22 @@
  * `?file=../../.ssh/id_rsa` is a credential leak over a port the user opened to
  * read their own code.
  *
- * **A file that changed on disk since it was indexed is never sliced.** The
- * viewer asks for line ranges the *index* recorded; if the file moved on since,
- * those ranges can point at a different symbol's body, which would be served
- * under the requested name and look perfectly plausible. So the bytes are
- * hashed and compared against `files.content_hash`, and on a mismatch the slice
- * is omitted with `drift: true` — the same call `codegraph_node` makes when it
- * says "changed on disk after the last index sync".
+ * **A file that changed on disk since it was indexed is never sliced under the
+ * index's numbering.** The viewer asks for line ranges the *index* recorded; if
+ * the file moved on since, those ranges can point at a different symbol's body,
+ * which would be served under the requested name and look perfectly plausible.
+ * So the bytes are hashed and compared against `files.content_hash`, and on a
+ * mismatch the slice is omitted with `drift: true` — the same call
+ * `codegraph_node` makes when it says "changed on disk after the last index
+ * sync".
+ *
+ * A caller that has ALREADY decided the index's numbering is off — a viewer
+ * about to draw a drift banner — asks with `ondrift=current` and gets the
+ * file's CURRENT lines instead of nothing. That is the other half of
+ * `codegraph_node`'s behaviour (issue #1474): a drifted file is served whole
+ * and current rather than omitted, because current bytes are correct by
+ * construction. `showing` says which of the two came back, on every response,
+ * so nothing has to infer it from the presence of `lines`.
  *
  * Only files that are IN the index are served. That is a tighter boundary than
  * the MCP tools take, and it costs the viewer nothing (it only ever renders
@@ -230,8 +239,19 @@ export function readFileShape(
 export interface SourceResult {
   file: string;
   language: string;
-  /** The file on disk differs from what was indexed — no slice is served. */
+  /** The file on disk differs from what was indexed. */
   drift: boolean;
+  /**
+   * Which numbering the returned lines belong to.
+   *
+   * `'indexed'` — the file matches the index, so the two are the same thing.
+   * `'current'` — the file drifted and the caller asked for it anyway
+   * (`ondrift=current`): these are the bytes on disk right now, and NOTHING the
+   * graph holds about this file (symbol ranges, call-site lines, ports) lines
+   * up with them.
+   * `'none'` — the file drifted and no slice is served.
+   */
+  showing: 'indexed' | 'current' | 'none';
   contentHash: string;
   indexedAt: number;
   generated: boolean;
@@ -253,6 +273,32 @@ export interface SourceResult {
   highlight?: HighlightResult;
 }
 
+/**
+ * What to do when the file on disk no longer matches the index.
+ *
+ * `omit` (the default) is the safe answer for a caller that has not decided
+ * anything yet. `current` is for one that has: it is about to say, in the
+ * pixels, that these are the file's CURRENT lines and that nothing the graph
+ * holds about them applies.
+ */
+export type OnDrift = 'omit' | 'current';
+
+/** Said once, so the two places that answer with current bytes cannot diverge. */
+const DRIFT_CURRENT_REASON =
+  'This file changed on disk after the last index sync. These are its current ' +
+  'lines; the indexed line ranges — symbol bodies, call sites, ports — no longer ' +
+  'match them. The next sync picks it up.';
+
+export function parseOnDrift(query: URLSearchParams): OnDrift {
+  const raw = query.get('ondrift');
+  if (raw === null || raw === '' || raw === 'omit') return 'omit';
+  if (raw === 'current') return 'current';
+  throw badRequest(
+    `Parameter "ondrift" must be "omit" or "current" (got "${raw}").`,
+    'Omit it to leave a drifted file unsliced; "current" serves the bytes on disk instead.'
+  );
+}
+
 export async function buildSource(
   cg: CodeGraph,
   projectRoot: string,
@@ -267,11 +313,13 @@ export async function buildSource(
   if (to !== 0 && to < from) {
     throw badRequest(`Parameter "to" (${to}) must not be before "from" (${from}).`);
   }
+  const onDrift = parseOnDrift(query);
 
   const base: SourceResult = {
     file: storedPath.replace(/\\/g, '/'),
     language: record.language,
     drift: false,
+    showing: 'indexed',
     contentHash: record.contentHash,
     indexedAt: record.indexedAt,
     generated: record.generated === true,
@@ -283,8 +331,14 @@ export async function buildSource(
     stats = fs.statSync(absolute);
   } catch {
     // Indexed but gone. That IS drift, and the strongest kind: nothing on disk
-    // corresponds to the ranges the graph holds.
-    return { ...base, drift: true, reason: 'The file is in the index but no longer on disk.' };
+    // corresponds to the ranges the graph holds — and `ondrift=current` has
+    // nothing to fall back to either.
+    return {
+      ...base,
+      drift: true,
+      showing: 'none',
+      reason: 'The file is in the index but no longer on disk.',
+    };
   }
   if (stats.size > MAX_SOURCE_BYTES) {
     throw badRequest(
@@ -306,10 +360,12 @@ export async function buildSource(
   // string). A touch or a checkout that rewrote the same bytes must not count
   // as drift, which is exactly what hashing content rather than mtime buys.
   const hash = createHash('sha256').update(content).digest('hex');
-  if (hash !== record.contentHash) {
+  const drift = hash !== record.contentHash;
+  if (drift && onDrift === 'omit') {
     return {
       ...base,
       drift: true,
+      showing: 'none',
       reason:
         'This file changed on disk after the last index sync, so the indexed line ' +
         'ranges no longer reliably match. Source is omitted rather than risk showing ' +
@@ -322,10 +378,28 @@ export async function buildSource(
   // surfacing rather than answering with the last line as if that were meant.
   // `to` past the end is different — "line 30 to the end, whatever that is" is
   // an ordinary way to ask, so it clamps.
+  //
+  // The exception is a drifted file the caller asked for anyway: it has already
+  // been told the numbering does not hold, and a save that SHORTENED the file
+  // between the length it was given and this read is an ordinary race, not a
+  // bug. Those get an empty slice.
   if (from > all.length) {
-    throw badRequest(
-      `Parameter "from" (${from}) is past the end of ${base.file}, which has ${all.length} lines.`
-    );
+    if (!drift) {
+      throw badRequest(
+        `Parameter "from" (${from}) is past the end of ${base.file}, which has ${all.length} lines.`
+      );
+    }
+    return {
+      ...base,
+      drift: true,
+      showing: 'current',
+      totalLines: all.length,
+      from,
+      to: from - 1,
+      lines: [],
+      truncated: false,
+      reason: DRIFT_CURRENT_REASON,
+    };
   }
   const start = from;
   const requestedEnd = to === 0 ? all.length : Math.min(to, all.length);
@@ -334,17 +408,26 @@ export async function buildSource(
 
   return {
     ...base,
+    drift,
+    // The bytes are always the ones on disk. What changes with drift is what
+    // they can be *used* for: under `current` the caller must not map anything
+    // the index holds onto these numbers.
+    showing: drift ? 'current' : 'indexed',
+    ...(drift ? { reason: DRIFT_CURRENT_REASON } : {}),
     totalLines: all.length,
     from: start,
     to: end,
     lines: slice,
     truncated: end < requestedEnd,
-    // Keyed on the content hash, so the cache is invalidated by the file
-    // changing rather than by a clock, and two viewers looking at the same
-    // symbol share one tokenisation.
+    // Keyed on the hash of the bytes ACTUALLY BEING SERVED, so the cache is
+    // invalidated by the file changing rather than by a clock, and two viewers
+    // looking at the same symbol share one tokenisation. It must be the disk
+    // hash rather than the record's: on a drifted file those differ, and keying
+    // current lines under the indexed hash would serve the previous edit's
+    // colours over this one's text.
     highlight: await highlightLines(slice, {
       language: record.language,
-      cacheKey: `${record.contentHash}:${start}:${end}`,
+      cacheKey: `${hash}:${start}:${end}`,
     }),
   };
 }

+ 26 - 1
ui/README.md

@@ -45,7 +45,9 @@ src/
   lib/map-model.ts        the Map's deterministic layered layout (pure)
   lib/flow-model.ts       the Flow strip's card/link geometry — a DAG (pure)
   lib/filecode-model.ts   the whole-file view: fixed line height, arcs, paging (pure)
-  components/             TopBar, TrailBar, KindGlyph, map/, flow/, symbol/, file/
+  lib/live.svelte.ts      /api/events: two counters every screen refreshes from
+  lib/toast.svelte.ts     the one transient note ("Index updated · reloaded")
+  components/             TopBar, TrailBar, KindGlyph, DriftBanner, Toast, map/, flow/, symbol/, file/
   views/                  one component per route
 ```
 
@@ -66,6 +68,29 @@ announce the project to a font CDN.
 | `#/flow?symbols=a,b,c` | flow strip — `codegraph_explore`'s own question |
 | `#/flow?t=<trail>` | flow strip — the trail you walked, read as a flow |
 
+## Live updates
+
+The viewer never polls. `lib/live.svelte.ts` holds one `EventSource` on
+`/api/events` for the life of the page and exposes two counters:
+
+- **`indexTick`** — the graph moved (somebody synced). Every screen refetches:
+  a rail is an answer about the whole graph, and a symbol gains a caller when
+  some *other* file is edited, so filtering by the focused file would leave the
+  rails quietly wrong. One request per sync.
+- **`diskTick`** — source files changed on disk and the index has not caught up.
+  Only the screen showing one of those files reacts, and what it does is draw a
+  drift banner.
+
+`liveRefresh(file, refresh)` is the three lines of bookkeeping that turns a
+counter into a single call; the Map and the Flow strip instead read
+`live.indexTick` straight inside the effect that already fetches them.
+
+Reconnection is ours, not `EventSource`'s: each failure closes the stream and
+schedules ONE retry on a backoff that ends after eight attempts (~90 s), at
+which point the top bar says "Not live" and nothing more is requested until the
+tab is focused again. A `degraded` event — the server's watcher gave up — is
+shown the same way and never answered with a poll.
+
 Node ids and file paths are encoded per slash-separated segment, so
 `#/file/src/mcp/tools.ts` stays readable and still round-trips a segment
 containing a reserved character. Build hashes with `symbolHref()` /

+ 24 - 0
ui/src/App.svelte

@@ -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 —

+ 76 - 0
ui/src/components/DriftBanner.svelte

@@ -0,0 +1,76 @@
+<!--
+  "This file changed on disk after the last index sync."
+
+  One block, said the same way on every screen that can say it (design spec:
+  paper-2 fill, hairline rule, ⚠ in ink-3, 12.5px ink-2). Deliberately NOT
+  amber: amber is the untested badge's colour and nothing else's, and a warning
+  that borrows it makes two unrelated things look like the same kind of problem.
+  Deliberately not a modal either — the screen underneath is still mostly true,
+  and interrupting to say so would be the overclaim.
+
+  The caller supplies the tail of the sentence, because what follows the dash is
+  the only part that differs: what this particular screen did about it.
+-->
+<script lang="ts">
+  import type { Snippet } from 'svelte';
+
+  interface Props {
+    /** Project-relative path, shown in mono. */
+    file: string;
+    /** The rest of the sentence: what this screen is showing instead. */
+    children: Snippet;
+  }
+
+  let { file, children }: Props = $props();
+</script>
+
+<div class="drift" role="status">
+  <span class="glyph" aria-hidden="true">⚠</span>
+  <span class="body"><code>{file}</code> changed on disk after the last index sync — {@render children()}</span>
+</div>
+
+<style>
+  .drift {
+    display: grid;
+    grid-template-columns: 16px 1fr;
+    gap: 6px;
+    align-items: start;
+    padding: 8px 12px;
+    border: 1px solid var(--rule-soft);
+    background: var(--paper-2);
+    color: var(--ink-2);
+    font-size: 12.5px;
+    line-height: 1.5;
+  }
+
+  .glyph {
+    color: var(--ink-3);
+    font-size: 12px;
+    line-height: 1.55;
+  }
+
+  .body :global(code) {
+    font-family: var(--mono);
+    font-size: 12px;
+    color: var(--ink);
+  }
+
+  .body :global(button) {
+    background: none;
+    border: 0;
+    padding: 0;
+    color: var(--accent);
+    font: inherit;
+    cursor: pointer;
+    text-decoration: underline;
+    text-decoration-color: var(--accent-line);
+    text-underline-offset: 3px;
+  }
+
+  .body :global(a) {
+    color: var(--accent);
+    text-decoration: underline;
+    text-decoration-color: var(--accent-line);
+    text-underline-offset: 3px;
+  }
+</style>

+ 49 - 0
ui/src/components/Toast.svelte

@@ -0,0 +1,49 @@
+<!--
+  The bottom-centre note. Ink fill, paper text, 2.6 s (design spec §appendix).
+
+  `aria-live="polite"` rather than `alert`: the screen has already refreshed by
+  the time this appears, so it is a confirmation, not something to interrupt for.
+-->
+<script lang="ts">
+  import { toast } from '../lib/toast.svelte';
+</script>
+
+<div class="live-region" aria-live="polite">
+  {#if toast.message}
+    <div class="toast">{toast.message}</div>
+  {/if}
+</div>
+
+<style>
+  .toast {
+    position: fixed;
+    left: 50%;
+    bottom: 22px;
+    transform: translateX(-50%);
+    background: var(--ink);
+    color: var(--paper);
+    padding: 8px 14px;
+    font-size: 12.5px;
+    line-height: 1.4;
+    max-width: 70ch;
+    z-index: 50;
+    animation: rise 140ms ease-out;
+  }
+
+  @keyframes rise {
+    from {
+      opacity: 0;
+      transform: translate(-50%, 6px);
+    }
+    to {
+      opacity: 1;
+      transform: translate(-50%, 0);
+    }
+  }
+
+  @media (prefers-reduced-motion: reduce) {
+    .toast {
+      animation: none;
+    }
+  }
+</style>

+ 40 - 2
ui/src/components/TopBar.svelte

@@ -5,6 +5,7 @@
   import SearchPalette from './SearchPalette.svelte';
   import type { PaletteItem } from '../lib/search-model';
   import { walkTo } from '../lib/walk';
+  import { live } from '../lib/live.svelte';
 
   interface Props {
     /** Indexed project name, e.g. "codegraph/". Null until stats load. */
@@ -111,6 +112,31 @@
   }
 
   let searchBox: HTMLDivElement | null = $state(null);
+
+  /**
+   * Why this page has stopped updating itself, when it has.
+   *
+   * The whole point of the live channel is that the screen keeps up with the
+   * project; a screen that has silently stopped keeping up is worse than one
+   * that never claimed to. So both ways it can end say so, in the one place
+   * that is on every view.
+   */
+  let liveNote = $derived.by(() => {
+    if (live.degraded !== null) {
+      return {
+        text: 'Live updates off',
+        title: `${live.degraded} This page no longer refreshes itself — reload it after a sync.`,
+      };
+    }
+    if (live.stopped) {
+      return {
+        text: 'Not live',
+        title:
+          'Lost the connection to codegraph ui and stopped retrying. Focus this tab to try again, or reload the page.',
+      };
+    }
+    return null;
+  });
 </script>
 
 <svelte:window {onpointerdown} />
@@ -152,6 +178,7 @@
   </div>
 
   <div class="project" title="Indexed project">
+    {#if liveNote}<span class="offline" title={liveNote.title}>{liveNote.text}</span>{/if}
     {#if project}<span class="mono">{project}</span>{/if}
     {#if stats}<span class="dim">{stats}</span>{/if}
   </div>
@@ -246,9 +273,20 @@
     white-space: nowrap;
   }
 
-  /* Below ~1000px the stats are the first thing worth losing. */
+  .offline {
+    padding: 2px 6px;
+    margin-right: 8px;
+    border: 1px solid var(--rule-soft);
+    background: var(--paper-2);
+    color: var(--ink-3);
+    font-size: 11.5px;
+  }
+
+  /* Below ~1000px the stats are the first thing worth losing — but not the
+     note that the page has stopped updating itself. */
   @media (max-width: 1000px) {
-    .project {
+    .project .mono,
+    .project .dim {
       display: none;
     }
   }

+ 0 - 5
ui/src/components/symbol/SymbolHeader.svelte

@@ -94,11 +94,6 @@
       hub · {plural(payload.counts.callers, 'caller')}
     </span>
   {/if}
-  {#if payload.drift}
-    <span class="badge warn" title="The line ranges below come from the last index sync">
-      <span class="sw"></span>changed on disk after the last index sync
-    </span>
-  {/if}
   <span class="badge" class:warn={testBadge.warn} title={testBadge.title}>
     <span class="sw"></span>{testBadge.text}
   </span>

+ 26 - 3
ui/src/lib/api.ts

@@ -145,13 +145,20 @@ export interface WireSource {
   file: string;
   language: string;
   drift: boolean;
+  /**
+   * Which numbering `lines` belong to. `'indexed'` — the file matches the
+   * index. `'current'` — it drifted and we asked for the bytes anyway
+   * (`ondrift: 'current'`), so nothing the graph holds about this file lines up
+   * with them. `'none'` — it drifted and no slice came back.
+   */
+  showing: 'indexed' | 'current' | 'none';
   contentHash: string;
   indexedAt: number;
   generated: boolean;
   totalLines: number | null;
   from?: number;
   to?: number;
-  /** Absent when `drift` — a mis-sliced body is worse than no body. */
+  /** Absent when the file drifted and `ondrift` was left at its default. */
   lines?: string[];
   truncated?: boolean;
   reason?: string;
@@ -590,13 +597,29 @@ export function fetchFileCode(
   return getJson<WireFileCodePayload>(`api/filecode/${encoded}`, signal);
 }
 
+/**
+ * A slice of an indexed file.
+ *
+ * `ondrift` decides what happens when the file has changed since it was
+ * indexed. The default omits the slice — an indexed range over rewritten bytes
+ * can show a different symbol's code under the right name. `'current'` asks for
+ * the file's current lines instead, which is only correct for a caller that is
+ * also going to SAY so: the response comes back `showing: 'current'`, and every
+ * line-anchored thing the graph knows (ports, arcs, call sites, rail rows) has
+ * to be switched off over it.
+ */
 export function fetchSource(
   file: string,
   from: number,
   to: number,
-  signal?: AbortSignal
+  signal?: AbortSignal,
+  ondrift?: 'current'
 ): Promise<WireSource> {
-  const params = new URLSearchParams({ file, from: String(from), to: String(to) });
+  const params = new URLSearchParams({ file, from: String(from) });
+  // `to` is 1-based on the wire and absent means "to the end of the file" —
+  // sending 0 for that would be out of range, not a synonym.
+  if (to > 0) params.set('to', String(to));
+  if (ondrift) params.set('ondrift', ondrift);
   return getJson<WireSource>(`api/source?${params}`, signal);
 }
 

+ 325 - 0
ui/src/lib/live.svelte.ts

@@ -0,0 +1,325 @@
+/**
+ * The live channel — the viewer's end of `/api/events` (CG-53).
+ *
+ * The server watches two things and says so; this module turns that into two
+ * counters every screen can read:
+ *
+ *   `live.indexTick` — the graph moved. Every screen is one round-trip stale.
+ *   `live.diskTick`  — source files changed on disk. Only the screens showing
+ *                      one of them care, and what they care about is drift.
+ *
+ * A counter rather than a callback list because Svelte's effects already do the
+ * subscribing: a view that reads `live.indexTick` inside an `$effect` re-runs
+ * when it moves, and one that does not read it is not subscribed. `liveRefresh`
+ * below wraps the three lines of bookkeeping that turns "the counter moved"
+ * into "call this once".
+ *
+ * ## Nothing polls, and nothing loops
+ *
+ * `EventSource` is the transport, but its own reconnect is not: left alone it
+ * retries forever at a fixed interval, so a viewer left open against a stopped
+ * `codegraph ui` becomes a request every three seconds until the tab is closed.
+ * So each `error` closes the stream and schedules ONE reconnect on a backoff
+ * that ends: after {@link MAX_ATTEMPTS} consecutive failures the connection
+ * gives up and says so, and only a deliberate signal — the tab coming back to
+ * the foreground, or the window regaining focus — starts it again.
+ *
+ * The same rule covers the server's own bad day: a `degraded` event means live
+ * watching has stopped for good on that side. The client records it and shows
+ * it. It must never respond by asking again on a timer — a degraded watcher is
+ * exactly the case where a poll would run forever.
+ */
+
+import { untrack } from 'svelte';
+
+/* ----------------------------------------------------------- wire shapes -- */
+
+export interface LiveIndexRevision {
+  lastIndexedAt: number | null;
+  files: number;
+}
+
+export interface LiveHello {
+  type: 'hello';
+  index: LiveIndexRevision | null;
+  watching: { source: boolean; index: boolean };
+  degraded: string | null;
+  heartbeatMs: number;
+  at: number;
+}
+
+export interface LiveChanged {
+  type: 'changed';
+  files: string[];
+  total: number;
+  truncated: boolean;
+  /** The change could not be described file by file — assume any file is affected. */
+  scan: boolean;
+  at: number;
+}
+
+export interface LiveIndexEvent {
+  type: 'index';
+  index: LiveIndexRevision;
+  files: string[];
+  total: number;
+  truncated: boolean;
+  at: number;
+}
+
+/* --------------------------------------------------------------- backoff -- */
+
+/** Reconnect delays, in order. The last one repeats until the attempts run out. */
+export const BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 15_000, 30_000];
+/** Consecutive failures before the connection stops trying on its own. */
+export const MAX_ATTEMPTS = 8;
+
+/* ----------------------------------------------------------------- state -- */
+
+let connected = $state(false);
+/** Gave up reconnecting. Only a foreground/focus signal restarts it. */
+let stopped = $state(false);
+let degraded = $state<string | null>(null);
+let watching = $state<{ source: boolean; index: boolean } | null>(null);
+let indexTick = $state(0);
+let diskTick = $state(0);
+let lastIndex = $state<LiveIndexEvent | null>(null);
+let lastChanged = $state<LiveChanged | null>(null);
+
+let source: EventSource | null = null;
+let retry: ReturnType<typeof setTimeout> | null = null;
+let attempts = 0;
+let started = false;
+
+/**
+ * Ticks that arrived while the tab was in the background.
+ *
+ * A hidden tab still gets every event — the stream does not care — but making
+ * it refetch is work nobody is looking at. The counters move when it comes
+ * back, and because they are counters, ten syncs in the background still cost
+ * exactly one refresh.
+ */
+let deferredIndex = false;
+let deferredDisk = false;
+
+function hidden(): boolean {
+  return typeof document !== 'undefined' && document.visibilityState === 'hidden';
+}
+
+function bumpIndex(event: LiveIndexEvent): void {
+  lastIndex = event;
+  if (hidden()) {
+    deferredIndex = true;
+    return;
+  }
+  indexTick += 1;
+}
+
+function bumpDisk(event: LiveChanged): void {
+  lastChanged = event;
+  if (hidden()) {
+    deferredDisk = true;
+    return;
+  }
+  diskTick += 1;
+}
+
+function flushDeferred(): void {
+  if (deferredIndex) {
+    deferredIndex = false;
+    indexTick += 1;
+  }
+  if (deferredDisk) {
+    deferredDisk = false;
+    diskTick += 1;
+  }
+}
+
+/* ------------------------------------------------------------ connection -- */
+
+function open(): void {
+  if (source || typeof EventSource === 'undefined') return;
+  if (retry !== null) {
+    clearTimeout(retry);
+    retry = null;
+  }
+  stopped = false;
+
+  const es = new EventSource('api/events');
+  source = es;
+
+  es.addEventListener('open', () => {
+    connected = true;
+  });
+
+  es.addEventListener('hello', (event) => {
+    const hello = parse<LiveHello>(event);
+    if (!hello) return;
+    // A hello is the only proof the stream is really working: `open` fires on
+    // the response headers, and a server that answered and then died would
+    // otherwise reset the backoff it should have been paying.
+    attempts = 0;
+    connected = true;
+    watching = hello.watching;
+    degraded = hello.degraded;
+  });
+
+  es.addEventListener('changed', (event) => {
+    const changed = parse<LiveChanged>(event);
+    if (changed) bumpDisk(changed);
+  });
+
+  es.addEventListener('index', (event) => {
+    const moved = parse<LiveIndexEvent>(event);
+    if (moved) bumpIndex(moved);
+  });
+
+  es.addEventListener('degraded', (event) => {
+    const note = parse<{ reason: string }>(event);
+    if (note) degraded = note.reason;
+  });
+
+  es.addEventListener('error', () => {
+    connected = false;
+    es.close();
+    if (source === es) source = null;
+    attempts += 1;
+    if (attempts >= MAX_ATTEMPTS) {
+      // Out of attempts. Nothing on a timer from here — the tab coming back to
+      // the foreground is the only thing that tries again.
+      stopped = true;
+      return;
+    }
+    const delay = BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)] ?? 30_000;
+    retry = setTimeout(open, delay);
+  });
+}
+
+function parse<T>(event: Event): T | null {
+  const data = (event as MessageEvent<string>).data;
+  if (typeof data !== 'string') return null;
+  try {
+    return JSON.parse(data) as T;
+  } catch {
+    return null;
+  }
+}
+
+/** Connect, once, for the life of the page. */
+function start(): void {
+  if (started || typeof window === 'undefined') return;
+  started = true;
+
+  document.addEventListener('visibilitychange', () => {
+    if (hidden()) return;
+    flushDeferred();
+    // Back in the foreground is the deliberate signal a stopped connection
+    // waits for. A tab that has been asleep for an hour reconnects when it is
+    // looked at, and not before.
+    if (stopped) {
+      attempts = 0;
+      open();
+    }
+  });
+  window.addEventListener('focus', () => {
+    if (!stopped) return;
+    attempts = 0;
+    open();
+  });
+  window.addEventListener('pagehide', () => {
+    source?.close();
+    source = null;
+  });
+
+  open();
+}
+
+/* ----------------------------------------------------------------- store -- */
+
+export const live = {
+  get connected(): boolean {
+    return connected;
+  },
+  /** True once the client has stopped trying to reconnect on its own. */
+  get stopped(): boolean {
+    return stopped;
+  },
+  /** Why the SERVER stopped watching, when it has. Never a reason to poll. */
+  get degraded(): string | null {
+    return degraded;
+  },
+  get watching(): { source: boolean; index: boolean } | null {
+    return watching;
+  },
+  get indexTick(): number {
+    return indexTick;
+  },
+  get diskTick(): number {
+    return diskTick;
+  },
+  get lastIndex(): LiveIndexEvent | null {
+    return lastIndex;
+  },
+  get lastChanged(): LiveChanged | null {
+    return lastChanged;
+  },
+  start,
+};
+
+/**
+ * Whether the latest on-disk change is one a screen showing `file` should react
+ * to.
+ *
+ * `scan: true` means the watcher could not name the files (a directory removal,
+ * or a burst past its ceiling), so the honest answer is yes.
+ */
+export function touchesFile(file: string | null): boolean {
+  const changed = lastChanged;
+  if (!changed) return false;
+  if (changed.scan || changed.truncated) return true;
+  if (file === null) return false;
+  return changed.files.includes(file);
+}
+
+/**
+ * Call `refresh` when what a screen is showing has gone stale.
+ *
+ * Two different staleness signals, deliberately not merged:
+ *
+ * - **the index moved** — every screen refetches. Not "the file I am showing
+ *   changed": a rail is the answer to a question about the whole graph, and a
+ *   symbol gains a caller when some *other* file is edited. Filtering by the
+ *   focused file here would leave the rails quietly wrong, which is the failure
+ *   this whole task exists to remove. One request per sync is the cost, and a
+ *   sync is not a thing that happens in a loop.
+ * - **the file changed on disk** — only the screen showing that file, and only
+ *   so its drift banner appears without waiting for the sync.
+ *
+ * Must be called during component initialisation (it creates an `$effect`).
+ */
+export function liveRefresh(
+  file: () => string | null,
+  refresh: (reason: 'index' | 'disk') => void
+): void {
+  let seenIndex = indexTick;
+  let seenDisk = diskTick;
+  $effect(() => {
+    const index = live.indexTick;
+    const disk = live.diskTick;
+    const path = file();
+    untrack(() => {
+      if (index !== seenIndex) {
+        seenIndex = index;
+        // An index event supersedes any disk event before it: the sync that
+        // just landed is what those edits became.
+        seenDisk = disk;
+        refresh('index');
+        return;
+      }
+      if (disk !== seenDisk) {
+        seenDisk = disk;
+        if (touchesFile(path)) refresh('disk');
+      }
+    });
+  });
+}

+ 9 - 0
ui/src/lib/project.svelte.ts

@@ -46,4 +46,13 @@ export const project = {
     return `${n(stats.graph.nodes)} symbols · ${n(stats.graph.edges)} edges · ${n(stats.graph.files)} files indexed`;
   },
   ensure: load,
+  /**
+   * Re-read `/api/stats` because the index moved (the live channel's `index`
+   * event). Distinct from `ensure`, which memoises the first request forever —
+   * memoising this one would mean the top bar's counts never move again.
+   */
+  reload(): Promise<void> {
+    inflight = null;
+    return load();
+  },
 };

+ 36 - 0
ui/src/lib/toast.svelte.ts

@@ -0,0 +1,36 @@
+/**
+ * The one transient message the viewer has: "Index updated · reloaded".
+ *
+ * A note, not a dialog — nothing was asked of the reader and nothing is waiting
+ * on them. It replaces itself rather than stacking, because the only thing it
+ * ever reports is the most recent state of one fact.
+ */
+
+/** How long a note stays up (design spec). */
+export const TOAST_MS = 2_600;
+
+let message = $state<string | null>(null);
+let timer: ReturnType<typeof setTimeout> | null = null;
+
+function show(text: string): void {
+  if (timer !== null) clearTimeout(timer);
+  message = text;
+  timer = setTimeout(() => {
+    message = null;
+    timer = null;
+  }, TOAST_MS);
+}
+
+function clear(): void {
+  if (timer !== null) clearTimeout(timer);
+  timer = null;
+  message = null;
+}
+
+export const toast = {
+  get message(): string | null {
+    return message;
+  },
+  show,
+  clear,
+};

+ 21 - 0
ui/src/lib/trail.svelte.ts

@@ -73,6 +73,27 @@ export const trail = {
     ];
   },
 
+  /**
+   * The same symbol, under a new id.
+   *
+   * A node's id contains its start LINE (`generateNodeId`), so any edit above a
+   * symbol gives it a different id at the next sync — while it is the same
+   * symbol, in the same place in the reader's path. Swapping it in place keeps
+   * the trail a path; pushing the new id would draw a hop that describes no
+   * call, and dropping the trail would lose the walk that got here.
+   */
+  rename(oldId: string, next: { id: string; name?: string | null; kind?: string | null }): void {
+    const at = hops.findIndex((h) => h.id === oldId);
+    if (at < 0) return;
+    remember(next.id, next);
+    const hop = hops[at] as TrailHop;
+    hops = [
+      ...hops.slice(0, at),
+      { ...hop, id: next.id, name: next.name ?? hop.name, kind: next.kind ?? hop.kind },
+      ...hops.slice(at + 1),
+    ];
+  },
+
   /** Drop every hop after `index`, making it the current one. */
   truncateTo(index: number): void {
     if (index < 0 || index >= hops.length) return;

+ 106 - 44
ui/src/views/FileCodeView.svelte

@@ -30,6 +30,7 @@
   import FileCodeRail from '../components/file/FileCodeRail.svelte';
   import FileModeTabs from '../components/file/FileModeTabs.svelte';
   import KindGlyph from '../components/KindGlyph.svelte';
+  import DriftBanner from '../components/DriftBanner.svelte';
   import {
     ApiFailure,
     fetchFileCode,
@@ -61,6 +62,7 @@
     type FileArc,
     type FileCallRow,
   } from '../lib/filecode-model';
+  import { liveRefresh } from '../lib/live.svelte';
   import { plural, synthesizedBy, type Connector, type LineRef } from '../lib/symbol-model';
   import { walkTo } from '../lib/walk';
 
@@ -98,25 +100,49 @@
     return () => controller.abort();
   });
 
-  async function load(file: string, signal: AbortSignal): Promise<void> {
-    loading = true;
-    failure = null;
-    payload = null;
+  /** Aborts a live-triggered reload when the screen moves on without it. */
+  let liveController: AbortController | null = null;
+
+  // The index moved, or this file changed on disk. Both change what is drawn in
+  // the margins AND what the source says, so both drop every cached page — but
+  // the scroll position stays, because the reader has not moved.
+  liveRefresh(
+    () => payload?.file.path ?? path,
+    () => {
+      const wanted = path;
+      liveController?.abort();
+      liveController = new AbortController();
+      void load(wanted, liveController.signal, true);
+    }
+  );
+
+  /**
+   * @param quiet a live refresh rather than a navigation: the pages are still
+   *   thrown away (the file changed — that is the whole point) but the scroll
+   *   position, the landing and the header stay put.
+   */
+  async function load(file: string, signal: AbortSignal, quiet = false): Promise<void> {
+    if (!quiet) {
+      loading = true;
+      failure = null;
+      payload = null;
+      landed = null;
+      hoverLine = null;
+      hoverFocus = null;
+      highlight = null;
+      if (stageEl) stageEl.scrollTop = 0;
+    }
     tokens = new Map();
     loadedPages = new Set();
     inflightPages = new Set();
     pageError = null;
     pageController?.abort();
     pageController = new AbortController();
-    landed = null;
-    hoverLine = null;
-    hoverFocus = null;
-    highlight = null;
-    if (stageEl) stageEl.scrollTop = 0;
     try {
       const next = await fetchFileCode(file, signal);
       if (signal.aborted) return;
       payload = next;
+      failure = null;
     } catch (cause) {
       if (signal.aborted) return;
       failure =
@@ -143,7 +169,17 @@
     const page = pageFor(index, file.totalLines);
     const signal = pageController?.signal;
     try {
-      const slice = await fetchSource(file.path, page.requestFrom, page.to, signal);
+      // A drifted file is paged as its CURRENT bytes: the numbering the pages
+      // use is the file's own, and every graph-derived marking over it is off
+      // (see `driftMode` below). Showing nothing would be honest and useless —
+      // the source itself is still exactly readable.
+      const slice = await fetchSource(
+        file.path,
+        page.requestFrom,
+        page.to,
+        signal,
+        payload?.drift ? 'current' : undefined
+      );
       // A different file (or a reload) landed while this was in flight.
       if (signal?.aborted || payload?.file.path !== file.path) return;
       if (!slice.lines) {
@@ -166,18 +202,35 @@
 
   /* -------------------------------------------------------------- models -- */
 
+  /**
+   * The file has changed on disk since it was indexed.
+   *
+   * Everything in this screen's margins — the arcs, the gutter ports, the rail
+   * rows, the outline's line numbers — is a line number the graph recorded, and
+   * the file no longer has those lines. So in this mode the margins go away and
+   * the source stays: current bytes are correct by construction, and a call arc
+   * drawn between two lines that have moved is the one thing here that could be
+   * confidently wrong. Parity with `codegraph_node`, which serves a drifted
+   * file whole and current rather than slicing it (issue #1474).
+   */
+  let driftMode = $derived(payload?.drift === true);
+
   let totalLines = $derived(payload?.file.totalLines ?? 0);
-  let refs = $derived(payload ? buildFileRefs(payload) : new Map<number, LineRef[]>());
-  let rows = $derived(payload ? buildFileCallRows(payload) : []);
-  let arcs = $derived(payload ? buildFileArcs(payload, rows) : []);
+  let refs = $derived(
+    payload && !driftMode ? buildFileRefs(payload) : new Map<number, LineRef[]>()
+  );
+  let rows = $derived(payload && !driftMode ? buildFileCallRows(payload) : []);
+  let arcs = $derived(payload && !driftMode ? buildFileArcs(payload, rows) : []);
   let crowded = $derived(arcs.length > ARC_CROWD_LIMIT);
 
   let outlineRows = $derived<OutlineEntryRow[]>(
-    (payload?.outline.items ?? []).map((entry) => ({
-      entry,
-      indent: Math.min(entry.depth, 3),
-      dimmed: QUIET_KINDS.has(entry.kind),
-    }))
+    driftMode
+      ? []
+      : (payload?.outline.items ?? []).map((entry) => ({
+          entry,
+          indent: Math.min(entry.depth, 3),
+          dimmed: QUIET_KINDS.has(entry.kind),
+        }))
   );
 
   const QUIET_KINDS = new Set(['property', 'field', 'enum_member', 'variable', 'constant']);
@@ -185,6 +238,7 @@
   /** Lines a definition starts on → its name, so the name is bold in the body. */
   let defNames = $derived.by(() => {
     const map = new Map<number, string>();
+    if (driftMode) return map;
     for (const entry of payload?.outline.items ?? []) {
       if (!map.has(entry.line)) map.set(entry.line, entry.name);
     }
@@ -242,7 +296,7 @@
   // rather than inside the block so a fast scroll past a page does not leave a
   // request for it half-applied to a screen that has moved on.
   $effect(() => {
-    if (!payload || payload.drift) return;
+    if (!payload) return;
     const wanted = pagesForRange(visible.first, visible.last, totalLines);
     untrack(() => {
       for (const index of wanted) void loadPage(index);
@@ -417,7 +471,7 @@
           <FileModeTabs path={payload.file.path} {line} source={true} />
         </div>
 
-        <div class="toolbar">
+        <div class="toolbar" class:hidden={driftMode}>
           <span class="arcnote">
             {arcSummary(payload.intraFileCalls)}{#if crowded}{' '}<span class="dim"
                 >— showing the ones the symbol under the pointer takes part in</span
@@ -436,22 +490,25 @@
         </div>
 
         {#if payload.drift}
-          <div class="drift">
-            {payload.reason ??
-              'This file changed on disk after the last index sync.'} The source is not shown, because
-            the line numbers the graph holds no longer match it. Run <code>codegraph sync</code> to bring
-            them up to date.
+          <div class="banner">
+            <DriftBanner file={payload.file.path}>
+              indexed line ranges may be shifted; showing the file's current source, with the
+              call arcs, ports and rail switched off — they are drawn from lines this file no
+              longer has. The next sync picks it up.
+            </DriftBanner>
           </div>
         {:else if payload.file.totalLines === null}
-          <div class="drift">
-            {payload.reason ?? 'This file could not be read from disk.'}
+          <div class="banner">
+            <DriftBanner file={payload.file.path}>
+              {payload.reason ?? 'it could not be read from disk.'}
+            </DriftBanner>
           </div>
         {:else if pageError}
-          <div class="drift">{pageError}</div>
+          <div class="note">{pageError}</div>
         {/if}
       </header>
 
-      {#if !payload.drift && payload.file.totalLines !== null}
+      {#if payload.file.totalLines !== null}
         <div
           class="stage"
           bind:this={stageEl}
@@ -462,7 +519,9 @@
         >
           <div class="stage-inner" style:height={`${docHeight}px`}>
             <div class="arccol">
-              <CodeArcs arcs={windowArcs} height={docHeight} {hoverLine} onfollow={followArc} />
+              {#if !driftMode}
+                <CodeArcs arcs={windowArcs} height={docHeight} {hoverLine} onfollow={followArc} />
+              {/if}
             </div>
 
             <div class="codecol" bind:this={codeEl}>
@@ -481,13 +540,15 @@
             </div>
 
             <aside class="rail" bind:this={railEl} aria-label="Calls">
-              <FileCodeRail
-                rows={windowRows}
-                focalFile={payload.file.path}
-                {focusId}
-                onopen={openNode}
-                onhover={onhoverRow}
-              />
+              {#if !driftMode}
+                <FileCodeRail
+                  rows={windowRows}
+                  focalFile={payload.file.path}
+                  {focusId}
+                  onopen={openNode}
+                  onhover={onhoverRow}
+                />
+              {/if}
             </aside>
 
             <Connectors {connectors} width={columns.width} height={docHeight} />
@@ -592,18 +653,19 @@
     white-space: nowrap;
   }
 
-  .drift {
+  .banner {
+    margin-top: 10px;
+  }
+
+  .note {
     margin-top: 10px;
-    padding: 8px 12px;
-    border: 1px solid var(--amber);
-    background: var(--amber-soft);
-    color: var(--amber);
+    color: var(--ink-3);
     font-size: 12.5px;
     line-height: 1.5;
   }
 
-  .drift code {
-    font: 12px var(--mono);
+  .toolbar.hidden {
+    display: none;
   }
 
   .stage {

+ 41 - 24
ui/src/views/FileView.svelte

@@ -21,6 +21,7 @@
   import FileRail from '../components/file/FileRail.svelte';
   import FileModeTabs from '../components/file/FileModeTabs.svelte';
   import KindGlyph from '../components/KindGlyph.svelte';
+  import DriftBanner from '../components/DriftBanner.svelte';
   import { ApiFailure, fetchFile, type WireFilePayload, type WireNodeRef } from '../lib/api';
   import {
     basename,
@@ -29,6 +30,7 @@
     fileMetaLine,
   } from '../lib/file-model';
   import { fileHref, navigate } from '../lib/router.svelte';
+  import { liveRefresh } from '../lib/live.svelte';
   import { plural } from '../lib/symbol-model';
   import { walkTo } from '../lib/walk';
 
@@ -55,19 +57,41 @@
     return () => controller.abort();
   });
 
-  async function load(file: string, signal: AbortSignal): Promise<void> {
-    loading = true;
-    failure = null;
-    payload = null;
-    pane = 'outline';
-    index = -1;
-    // Leaving and coming back to the same `?hl=` URL must land again; the
-    // guard below only exists to stop a re-render re-selecting.
-    landed = null;
+  /** Aborts a live-triggered reload when the screen moves on without it. */
+  let liveController: AbortController | null = null;
+
+  // The index moved, or this file changed on disk (the drift banner). Refetch
+  // in place — the outline is where the reader's eye is.
+  liveRefresh(
+    () => payload?.file.path ?? path,
+    () => {
+      const wanted = path;
+      liveController?.abort();
+      liveController = new AbortController();
+      void load(wanted, liveController.signal, true);
+    }
+  );
+
+  /**
+   * @param quiet a live refresh rather than a navigation: keep the outline on
+   *   screen (and the reader's selection in it) until the new payload lands.
+   */
+  async function load(file: string, signal: AbortSignal, quiet = false): Promise<void> {
+    if (!quiet) {
+      loading = true;
+      failure = null;
+      payload = null;
+      pane = 'outline';
+      index = -1;
+      // Leaving and coming back to the same `?hl=` URL must land again; the
+      // guard below only exists to stop a re-render re-selecting.
+      landed = null;
+    }
     try {
       const next = await fetchFile(file, signal);
       if (signal.aborted) return;
       payload = next;
+      failure = null;
     } catch (cause) {
       if (signal.aborted) return;
       failure =
@@ -280,10 +304,13 @@
       </div>
 
       {#if payload.drift}
-        <div class="drift">
-          This file changed on disk after the last index sync, so the line numbers below
-          are the ones it had when it was indexed. Run <code>codegraph sync</code> to bring
-          them up to date.
+        <div class="banner">
+          <DriftBanner file={payload.file.path}>
+            indexed line ranges may be shifted, so the outline below is the shape the file had
+            when it was indexed —
+            <a href={fileHref(payload.file.path, { source: true })}>read its current source</a>.
+            The next sync picks it up.
+          </DriftBanner>
         </div>
       {/if}
 
@@ -403,18 +430,8 @@
     text-underline-offset: 3px;
   }
 
-  .drift {
+  .banner {
     margin-top: 12px;
-    padding: 10px 12px;
-    border: 1px solid var(--amber);
-    background: var(--amber-soft);
-    color: var(--amber);
-    font-size: 12.5px;
-    line-height: 1.5;
-  }
-
-  .drift code {
-    font: 12px var(--mono);
   }
 
   @media (max-width: 1100px) {

+ 8 - 2
ui/src/views/FlowView.svelte

@@ -19,6 +19,7 @@
   import FlowCard from '../components/flow/FlowCard.svelte';
   import FlowLink from '../components/flow/FlowLink.svelte';
   import { fetchFlow, type WireFlow, type WireFlowPayload } from '../lib/api';
+  import { live } from '../lib/live.svelte';
   import { navigate, symbolHref } from '../lib/router.svelte';
   import { trail, encodeTrail, type TrailHop } from '../lib/trail.svelte';
   import { decodeTrail } from '../lib/trail-codec';
@@ -72,14 +73,19 @@
       error = null;
       return;
     }
+    // Re-run when the index moves: a path is a walk over edges that a sync can
+    // add, remove or re-route, and a strip drawn from the previous graph would
+    // disagree with `codegraph_explore` about the same question.
+    void live.indexTick;
     const controller = new AbortController();
     loading = true;
     error = null;
+    const keep = picked;
     fetchFlow(spec, controller.signal)
       .then((next) => {
         payload = next;
-        picked = next.flows[0]?.id ?? null;
-        showAll = false;
+        // A refresh keeps the reader's chosen path when it survived the sync.
+        picked = next.flows.some((f) => f.id === keep) ? keep : (next.flows[0]?.id ?? null);
         loading = false;
       })
       .catch((err: unknown) => {

+ 6 - 0
ui/src/views/MapView.svelte

@@ -19,6 +19,7 @@
   import ModuleEdge from '../components/map/ModuleEdge.svelte';
   import MapSidePanel from '../components/map/MapSidePanel.svelte';
   import { fetchMap, type WireMapPayload } from '../lib/api';
+  import { live } from '../lib/live.svelte';
   import { mapHref, navigate } from '../lib/router.svelte';
   import {
     buildMapLayout,
@@ -62,6 +63,11 @@
   $effect(() => {
     const wantRoot = root;
     const wantDepth = depth;
+    // Read so the effect re-runs when the index moves: the map IS the graph,
+    // and the layering changes with it. The canvas stays on screen while the
+    // new aggregation lands (the server answers a cached one in milliseconds
+    // when nothing actually changed).
+    void live.indexTick;
     const controller = new AbortController();
     loading = true;
     error = null;

+ 161 - 34
ui/src/views/SymbolView.svelte

@@ -22,7 +22,17 @@
   import MembersOutline from '../components/symbol/MembersOutline.svelte';
   import SourceBlock from '../components/symbol/SourceBlock.svelte';
   import SymbolHeader from '../components/symbol/SymbolHeader.svelte';
-  import { ApiFailure, fetchSource, fetchSymbol, type WireNodeRef, type WireSource, type WireSymbolPayload } from '../lib/api';
+  import DriftBanner from '../components/DriftBanner.svelte';
+  import {
+    ApiFailure,
+    fetchFile,
+    fetchSource,
+    fetchSymbol,
+    type WireNodeDetail,
+    type WireNodeRef,
+    type WireSource,
+    type WireSymbolPayload,
+  } from '../lib/api';
   import { tokensByLine, type Token } from '../lib/highlight';
   import { hot, railFocus } from '../lib/focus.svelte';
   import { project } from '../lib/project.svelte';
@@ -38,7 +48,9 @@
     type Connector,
     type LineRef,
   } from '../lib/symbol-model';
-  import { trail } from '../lib/trail.svelte';
+  import { encodeTrail, trail } from '../lib/trail.svelte';
+  import { liveRefresh } from '../lib/live.svelte';
+  import { fileHref, navigate, symbolHref } from '../lib/router.svelte';
   import { arrivedFrom, walkTo } from '../lib/walk';
 
   interface Props {
@@ -56,6 +68,21 @@
   /** Fallback for the sticky rail header before it has been measured. */
   const RAIL_HEADER_FALLBACK = 38;
 
+  /**
+   * How much of a DRIFTED file this screen will show in place of the body.
+   *
+   * When the file has moved on, the symbol's indexed range names nothing, so
+   * the only correct source to show is the whole current file — the same call
+   * `codegraph_node` makes (issue #1474), for the same reason: current bytes
+   * are right by construction, a slice of them is a guess. Past this length
+   * that stops being a symbol view and becomes a file view badly done, so the
+   * banner points at the real one instead.
+   */
+  const DRIFT_INLINE_MAX_LINES = 400;
+
+  /** One shared empty map, so the drift path does not allocate per render. */
+  const EMPTY_REFS: Map<number, LineRef[]> = new Map();
+
   /* --------------------------------------------------------------- state -- */
 
   let payload = $state<WireSymbolPayload | null>(null);
@@ -92,14 +119,35 @@
     return () => controller.abort();
   });
 
-  async function load(nodeId: string, signal: AbortSignal): Promise<void> {
-    loading = true;
-    failure = null;
-    payload = null;
-    source = null;
-    railFocus.reset();
-    hot.set(null);
-    placed = false;
+  /** Aborts a live-triggered reload when the screen moves on without it. */
+  let liveController: AbortController | null = null;
+
+  // The graph moved, or the file on screen changed on disk. Either way what is
+  // drawn is out of date; refetch it in place rather than blanking the screen.
+  liveRefresh(
+    () => payload?.node.file ?? null,
+    () => {
+      const wanted = id;
+      liveController?.abort();
+      liveController = new AbortController();
+      void load(wanted, liveController.signal, true);
+    }
+  );
+
+  /**
+   * @param quiet a live refresh rather than a navigation: keep what is on
+   *   screen until the new payload lands, so a sync does not blink the view.
+   */
+  async function load(nodeId: string, signal: AbortSignal, quiet = false): Promise<void> {
+    if (!quiet) {
+      loading = true;
+      failure = null;
+      payload = null;
+      source = null;
+      railFocus.reset();
+      hot.set(null);
+      placed = false;
+    }
     void project.ensure();
 
     let node: WireSymbolPayload;
@@ -107,25 +155,73 @@
       node = await fetchSymbol(nodeId, signal);
     } catch (cause) {
       if (signal.aborted) return;
+      // A node's id contains its start line, so a sync that moved this symbol
+      // down two lines answers 404 for an id that was valid a second ago. On a
+      // live refresh — and only there, because only there do we still hold the
+      // symbol's name — find it again in its file rather than telling the
+      // reader their screen no longer exists.
+      if (quiet && asFailure(cause).code === 'not-found' && payload !== null) {
+        const moved = await refind(payload.node, signal);
+        if (signal.aborted) return;
+        if (moved !== null) {
+          trail.rename(nodeId, moved);
+          navigate(symbolHref(moved.id, { trail: encodeTrail(trail.hops) }), { replace: true });
+          return;
+        }
+      }
       failure = asFailure(cause);
       loading = false;
       return;
     }
     if (signal.aborted) return;
     payload = node;
+    failure = null;
     loading = false;
     trail.resolve(nodeId, { name: node.node.name, kind: node.node.kind });
 
     // The body is only fetched when it will be drawn: a 2,000-line file node
     // shows its outline, and asking for 2,000 lines to throw them away is the
     // difference between a screen that settles at once and one that does not.
-    if (!showsBody(node.node.kind, node.node.lines) || node.drift) return;
+    if (!showsBody(node.node.kind, node.node.lines)) {
+      source = null;
+      return;
+    }
     try {
-      const slice = await fetchSource(node.node.file, node.node.line, node.node.endLine, signal);
+      // A drifted file is asked for WHOLE and CURRENT — its indexed range is
+      // the one thing about it that is certainly wrong — and the answer comes
+      // back flagged `showing: 'current'`, which is what switches every
+      // line-anchored marking below off.
+      const slice = node.drift
+        ? await fetchSource(node.node.file, 1, 0, signal, 'current')
+        : await fetchSource(node.node.file, node.node.line, node.node.endLine, signal);
       if (!signal.aborted) source = slice;
     } catch {
       // No slice: the header, the rails and the blast strip are all still
       // true, so the screen loses the body and says so rather than erroring.
+      if (!signal.aborted) source = null;
+    }
+  }
+
+  /**
+   * The same symbol after a sync renumbered its file.
+   *
+   * The file's outline is the exact answer — every symbol in that file with its
+   * new id — so this is one request and no ranking. Same name and kind is the
+   * match; when a file holds several (overloads), the one that moved least is
+   * the one the reader was on.
+   */
+  async function refind(previous: WireNodeDetail, signal: AbortSignal): Promise<WireNodeRef | null> {
+    try {
+      const file = await fetchFile(previous.file, signal);
+      const candidates = file.outline.items.filter(
+        (entry) => entry.name === previous.name && entry.kind === previous.kind
+      );
+      if (candidates.length === 0) return null;
+      return candidates.reduce((best, entry) =>
+        Math.abs(entry.line - previous.line) < Math.abs(best.line - previous.line) ? entry : best
+      );
+    } catch {
+      return null;
     }
   }
 
@@ -143,8 +239,29 @@
 
   let wantsBody = $derived(payload ? showsBody(payload.node.kind, payload.node.lines) : false);
 
+  /**
+   * The body on screen is the file's CURRENT source, not this symbol's.
+   *
+   * Everything the graph knows is anchored to line numbers the file no longer
+   * has, so in this mode the ports, the call-site links, the definition-name
+   * weight and the `?line=` highlight are all switched off together. Leaving
+   * any one of them on would put a marking from the previous version of the
+   * file over a line of the new one — a lie that looks exactly like the truth.
+   */
+  let showingCurrent = $derived(source?.showing === 'current');
+
+  /** A drifted file too long to stand in for the body; the banner links out. */
+  let driftTooLong = $derived(
+    payload?.drift === true &&
+      (source === null || (source.totalLines ?? 0) > DRIFT_INLINE_MAX_LINES)
+  );
+
   let codeBlock = $derived.by(() => {
     if (!payload || !source?.lines) return null;
+    if (showingCurrent) {
+      if (driftTooLong) return null;
+      return buildCodeBlock(source.from ?? 1, source.lines, []);
+    }
     const from = source.from ?? payload.node.line;
     return buildCodeBlock(from, source.lines, graphCallLines(payload));
   });
@@ -290,7 +407,16 @@
     const headerHeight =
       rail.querySelector<HTMLElement>('[data-rail-header]')?.offsetHeight ?? RAIL_HEADER_FALLBACK;
 
+    // A drifted file's body is the CURRENT source under CURRENT numbering, and
+    // the rail's anchors are the numbers the index recorded. A line that
+    // happens to exist in both is a coincidence, not a call site — so in that
+    // mode nothing is anchored: the rows stack in source order and no
+    // connector is drawn. The rail is still true about WHAT this symbol calls;
+    // it has stopped being true about WHERE, and says so by not pointing.
+    const anchored = !showingCurrent;
+
     const lineCentre = (n: number): number | null => {
+      if (!anchored) return null;
       const el = center.querySelector<HTMLElement>(`[data-line="${n}"]`);
       return el ? el.offsetTop + el.offsetHeight / 2 : null;
     };
@@ -437,21 +563,33 @@
           <SymbolHeader {payload} onopen={open} />
 
           {#if payload.drift}
-            <div class="drift">
-              {payload.node.file} changed on disk after the last index sync — the body is not shown, because
-              the line ranges the graph holds no longer match the file. Run <code>codegraph sync</code>
-              to bring it up to date.
+            <div class="banner">
+              <DriftBanner file={payload.node.file}>
+                {#if driftTooLong}
+                  indexed line ranges may be shifted, and the file is too long to stand in for
+                  this symbol's body here —
+                  <a href={fileHref(payload.node.file, { source: true })}>open its current source</a>.
+                  The next sync picks it up.
+                {:else}
+                  indexed line ranges may be shifted; showing the file's current source. The next
+                  sync picks it up.
+                {/if}
+              </DriftBanner>
             </div>
-          {:else if codeBlock}
+          {/if}
+
+          {#if codeBlock}
             <SourceBlock
               block={codeBlock}
               tokens={codeTokens}
-              {refs}
-              defLine={payload.node.line}
-              defName={payload.node.name}
-              highlight={line}
+              refs={showingCurrent ? EMPTY_REFS : refs}
+              defLine={showingCurrent ? -1 : payload.node.line}
+              defName={showingCurrent ? '' : payload.node.name}
+              highlight={showingCurrent ? null : line}
               onfollow={followRef}
             />
+          {:else if payload.drift}
+            <!-- The banner above is the whole answer for this file. -->
           {:else if !wantsBody}
             <!-- The outline below IS the body for a container this size. -->
           {:else if source}
@@ -538,19 +676,8 @@
     border-left: 1px solid var(--rule-faint);
   }
 
-  .drift {
-    margin-top: 16px;
-    padding: 10px 12px;
-    border: 1px solid var(--amber);
-    background: var(--amber-soft);
-    color: var(--amber);
-    font-size: 12.5px;
-    line-height: 1.5;
-  }
-
-  .drift code {
-    font-family: var(--mono);
-    font-size: 12px;
+  .banner {
+    margin: 16px 0 4px;
   }
 
   .note {