the preview reloads when a job restarts, not when a file changes

Restarting the dev server leaves the iframe holding a dead HMR socket. The page
keeps rendering and looks completely alive — it has simply stopped receiving
updates. So the agent edits a file, nothing moves, and there is no way to tell
whether the edit failed or the connection did. Reported after exactly that
sequence: stop, start, ask for a change, watch nothing happen.

A stale preview that is indistinguishable from a working one is worse than an
obviously broken one, because you debug the wrong thing.

── Which signal, and why not the obvious one ──

`preview:refresh` already exists and is bumped on every turn that touched a file.
Subscribing to it would fix this and ruin the feature: a reload throws away scroll
position, form state, and whatever route you had navigated to inside the app —
on every edit. HMR exists precisely so that does not happen.

So a new channel, bumped on `task:started` and on task completion. A job coming up
may be a dev server coming back; a job going down may be it going away. Both are
moments a preview pointed at it is now lying. Neither fires on an edit — this is a
few times a session, not once a keystroke.

Stated rather than hidden: it OVER-triggers. Any job starting reloads any preview,
not only the one serving it. That costs one reload on an unrelated watcher, which
is the cheaper of the two errors.

The parent process cannot see inside a cross-origin iframe, so there is no way to
ask whether its socket is alive. The job lifecycle is the closest observable
proxy, and it is exact for the case that prompted this.

tsgo clean, frontend builds, 851 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 02:22:02 +00:00
co-authored by Claude Opus 5
parent 6b21d4b8bd
commit 4ced2983a6
3 changed files with 62 additions and 1 deletions
@@ -1,7 +1,8 @@
import { useCallback } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useGlobal } from 'hooks/useGlobal';
import { useDashboardState } from 'state/useDashboardState';
import { panelStateKey, type WorkspaceIdentity } from '../../components/Workspace';
import { useBackgroundJobChanged } from '../../channels';
/**
* What a preview panel is pointing at, and a way to reload it.
@@ -57,6 +58,27 @@ export function usePreview(panelId: string | undefined, workspace: WorkspaceIden
const [reloadToken, setReloadToken] = useGlobal<number>(`preview-reload-${panelId ?? 'none'}`, 0);
const reload = useCallback(() => setReloadToken((n) => n + 1), [setReloadToken]);
/**
* Reload when a background job starts or ends — NOT when a file changes.
*
* A dev server restarting kills the HMR socket inside the iframe. The page carries on rendering and
* looks perfectly alive, but stops receiving updates: an agent edits a file, nothing moves, and there
* is no way to tell whether the edit failed or the connection did. That is the state this exists to
* end, and it is only reachable by reloading.
*
* Reloading on file changes instead would throw away scroll position, form state and whatever route
* you had navigated to inside the app — on every edit. HMR exists precisely so that does not happen.
*/
const [jobSignal] = useBackgroundJobChanged();
const firstSignal = useRef(true);
useEffect(() => {
if (firstSignal.current) {
firstSignal.current = false;
return;
}
reload();
}, [jobSignal]);
return { url, setUrl, reloadToken, reload };
}
+27
View File
@@ -64,3 +64,30 @@ export const useActiveChatSession = defineChannel<string | null>('chat:active-se
* that read it no longer exists anywhere in the repo. Same reason for declaring rather than deleting.
*/
export const usePreviewRefresh = defineChannel<number>('preview:refresh', 0);
/**
* "A background job started or ended."
*
* Bumped on `task:started` and on a task's completion — a few times a session, not once per edit.
*
* It exists for the app preview, and the distinction is the whole point. A dev server restarting kills
* the HMR socket inside the iframe, so the page keeps rendering, keeps looking live, and silently stops
* receiving updates: the agent edits a file, nothing happens, and nobody can tell whether the change
* failed or the connection did. Reloading on THIS reconnects it. Reloading on `preview:refresh` — every
* turn that touched a file — would throw away scroll, form state and route on every edit, which is the
* thing HMR exists to avoid.
*
* It over-triggers: any job starting reloads the preview, not only the one serving it. That costs one
* reload on an unrelated watcher and is preferable to the alternative, which is a preview that is stale
* and indistinguishable from a working one.
*/
const useBackgroundJobChangedChannel = defineChannel<number>('background-job:changed', 0);
/** Same stable-bump shape as `useFilesRefresh`, and for the same reason — see the note there. */
export const useBackgroundJobChanged = () => {
const [signal, setSignal] = useBackgroundJobChangedChannel();
const setRef = useRef(setSignal);
setRef.current = setSignal;
const bump = useCallback(() => setRef.current(++nonce), []);
return [signal, bump] as const;
};
@@ -4,6 +4,7 @@ import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings';
import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types';
import { spliceRunningTasks } from '../apps/Chat/running-tasks';
import { useBackgroundJobChanged } from '../channels';
import { randomId } from 'helpers/random-id';
const SAVE_DEBOUNCE_MS = 1000;
@@ -90,6 +91,11 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
// Resilient-transport cursor: the max durable-event `seq` seen. On (re)connect we send it as a
// resume-cursor so the server replays anything we missed (e.g. a background task:notification that
// landed while briefly disconnected). sendRef breaks the hook ↔ onOpen circular dependency.
// Bumped when a background job starts or ends — the app preview reloads on it, because a dev server
// restarting leaves the iframe holding a dead HMR socket. Read through a ref-stable setter, so it can
// sit in the message handler without re-creating it.
const [, bumpJobChanged] = useBackgroundJobChanged();
const cursorRef = useRef(0);
const sendRef = useRef<(d: Record<string, unknown>) => void>(() => {});
@@ -433,6 +439,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
...prev,
{ role: 'task', taskId: msg.taskId, description: msg.description, taskType: msg.taskType },
]);
// A job coming up may be a dev server coming back, which means any preview pointed at it is
// holding a dead HMR socket. See `background-job:changed`.
bumpJobChanged();
break;
case 'compact:start':
@@ -484,6 +493,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
};
return next;
});
// And a job going down may be the dev server going down — a preview pointed at it should show
// that rather than a page that merely looks alive.
bumpJobChanged();
break;
}
}