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:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user