diff --git a/src/workspaces/officerdev/src/apps/Preview/use-preview.ts b/src/workspaces/officerdev/src/apps/Preview/use-preview.ts index b61082e0..ba08e023 100644 --- a/src/workspaces/officerdev/src/apps/Preview/use-preview.ts +++ b/src/workspaces/officerdev/src/apps/Preview/use-preview.ts @@ -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(`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 }; } diff --git a/src/workspaces/officerdev/src/channels.ts b/src/workspaces/officerdev/src/channels.ts index 4fe2c8d4..b519f29b 100644 --- a/src/workspaces/officerdev/src/channels.ts +++ b/src/workspaces/officerdev/src/channels.ts @@ -64,3 +64,30 @@ export const useActiveChatSession = defineChannel('chat:active-se * that read it no longer exists anywhere in the repo. Same reason for declaring rather than deleting. */ export const usePreviewRefresh = defineChannel('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('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; +}; diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index cb93c1a6..f2a97c21 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -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) => 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; } }