From 953fddc99e14c2d1e7842eaf3a5d0d3e5bdfcbb3 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 7 Jul 2026 01:31:35 -0700 Subject: [PATCH] [eric] apps: auto hard-reload the app card when a turn installed deps (soft reload can't see new packages) --- .../manager/streaming/post_tool_hook.py | 8 +++++ backend/tests/test_tool_result_hook.py | 32 +++++++++++++++++++ .../Dashboard/cards/DashboardViewCard.tsx | 22 ++++++++++++- frontend/src/shared/state/agentsSlice.ts | 12 +++++++ frontend/src/shared/ws/WebSocketManager.ts | 8 +++++ 5 files changed, 81 insertions(+), 1 deletion(-) diff --git a/backend/apps/agents/manager/streaming/post_tool_hook.py b/backend/apps/agents/manager/streaming/post_tool_hook.py index a6c40934..10f182f9 100644 --- a/backend/apps/agents/manager/streaming/post_tool_hook.py +++ b/backend/apps/agents/manager/streaming/post_tool_hook.py @@ -103,6 +103,14 @@ async def post_tool_hook(ctx: HookContext, input_data: dict, tool_use_id, contex outputs_runtime_manager.reset_render_state_for_workspace(session.id) except Exception: pass + if installed_pkg: + # Tell the app card this turn changed deps so its turn-finish reload restarts Vite; a soft webview reload can't pick up newly installed packages. + try: + await ws_manager.send_to_session(session.id, "agent:app_deps_changed", { + "session_id": session.id, + }) + except Exception: + pass elif wrote_files: if file_path: try: diff --git a/backend/tests/test_tool_result_hook.py b/backend/tests/test_tool_result_hook.py index ad260ea8..e230f390 100644 --- a/backend/tests/test_tool_result_hook.py +++ b/backend/tests/test_tool_result_hook.py @@ -65,3 +65,35 @@ async def test_agent_tool_spawns_subsession_into_live_registry(): assert child.parent_session_id == parent_id assert child.active_mcps == [] # context-isolation invariant: no inherited activations assert "sub-agent did the work" in str(child.messages[-1].content) + + +@pytest.mark.asyncio +async def test_view_builder_dep_install_broadcasts_app_deps_changed(): + """An npm install in a view-builder session must tell the app card this turn + changed deps (agent:app_deps_changed), so its turn-finish reload restarts Vite + instead of soft-reloading a preview that can't see the new packages.""" + registry: dict = {} + ctx = p_ctx(registry) + ctx.session.mode = "view-builder" + with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()) as send: + await tool_result_hook.post_tool_hook( + ctx, {"tool_name": "Bash", "tool_response": "added 3 packages", + "tool_input": {"command": "npm install recharts"}}, "tu1", None + ) + events = [c.args[1] for c in send.await_args_list] + assert "agent:app_deps_changed" in events + + +@pytest.mark.asyncio +async def test_view_builder_plain_write_does_not_flag_deps_changed(): + """A plain file edit must NOT escalate the reload; only dep changes do.""" + registry: dict = {} + ctx = p_ctx(registry) + ctx.session.mode = "view-builder" + with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()) as send: + await tool_result_hook.post_tool_hook( + ctx, {"tool_name": "Write", "tool_response": "ok", + "tool_input": {"file_path": "/ws/frontend/src/App.tsx", "content": "x"}}, "tu1", None + ) + events = [c.args[1] for c in send.await_args_list] + assert "agent:app_deps_changed" not in events diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 131e0456..e31453a5 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -179,10 +179,30 @@ const DashboardViewCard: React.FC = ({ const [finishing, setFinishing] = useState(false); const wasBuildingRef = useRef(false); const finishTimerRef = useRef(null); + // Whether this turn changed deps (needs a Vite restart, not just a soft reload). Held in a ref so the reload effect stays keyed on the status transition alone. + const depsChanged = useAppSelector( + (s) => (output.session_id ? !!s.agents.sessions[output.session_id]?.app_deps_changed : false), + ); + const depsChangedRef = useRef(false); + useEffect(() => { depsChangedRef.current = depsChanged; }, [depsChanged]); useEffect(() => { const building = linkedStatus === 'running' || linkedStatus === 'waiting_approval'; if (wasBuildingRef.current && !building) { - previewRef.current?.reload(); + const wsId = output.workspace_id; + if (depsChangedRef.current && wsId) { + // Deps changed this turn: a soft reload can't pick up new packages, so restart the Vite runtime first, then reload. + void (async () => { + try { + const tok = getAuthToken(); + const headers: Record = { 'Content-Type': 'application/json' }; + if (tok) headers.Authorization = `Bearer ${tok}`; + await fetch(`${API_BASE}/outputs/workspace/${wsId}/runtime/restart?instance=${instance}`, { method: 'POST', headers }); + } catch { /* failures surface via the runtime log WS */ } + previewRef.current?.reload(); + })(); + } else { + previewRef.current?.reload(); + } setFinishing(true); if (finishTimerRef.current) clearTimeout(finishTimerRef.current); finishTimerRef.current = window.setTimeout(() => setFinishing(false), 1200); diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 43c257bc..6f9d6cdd 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -110,6 +110,8 @@ export interface AgentSession { context_overflow?: { reason: string; message: string; at: string } | null; rate_limited?: { retry_after_s: number | null; at: string } | null; context_recovered?: { at: string } | null; + // Set when a view-builder turn installed/changed deps, so the app card does a HARD reload (Vite restart) at turn-finish instead of the soft one. Reset when the next turn starts. + app_deps_changed?: boolean; mcp_suggestions?: Array<{ id: string; title: string; description: string; reason?: string }>; mcp_suggestions_is_vague?: boolean; compacted_through_msg_id?: string | null; @@ -721,6 +723,10 @@ const agentsSlice = createSlice({ if (terminal.includes(session.status as any) && action.payload.status === 'running') { return; } + // A fresh turn clears last turn's deps-changed flag so the app card only hard-reloads for the turn that actually changed deps. + if (action.payload.status === 'running' && session.status !== 'running') { + session.app_deps_changed = false; + } session.status = action.payload.status; } if (action.payload.status === 'running' && !state.trackedNotificationIds.includes(action.payload.sessionId)) { @@ -960,6 +966,11 @@ const agentsSlice = createSlice({ if (session) session.rate_limited = null; }, + setAppDepsChanged(state, action: PayloadAction<{ sessionId: string }>) { + const session = state.sessions[action.payload.sessionId]; + if (session) session.app_deps_changed = true; + }, + setContextRecovered(state, action: PayloadAction<{ sessionId: string }>) { const session = state.sessions[action.payload.sessionId]; if (session) session.context_recovered = { at: new Date().toISOString() }; @@ -1453,6 +1464,7 @@ export const { clearRateLimited, setContextRecovered, clearContextRecovered, + setAppDepsChanged, clearContextOverflow, setMcpSuggestions, clearMcpSuggestions, diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index edc169c7..9771608f 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -14,6 +14,7 @@ import { setContextOverflow, setRateLimited, setContextRecovered, + setAppDepsChanged, setMcpSuggestions, addBranch, setActiveBranch, @@ -562,6 +563,13 @@ class WebSocketManager { } break; + case 'agent:app_deps_changed': + // A view-builder agent installed/changed deps this turn; the app card escalates its turn-finish reload from soft to a Vite restart so new deps actually load. + if (session_id) { + store.dispatch(setAppDepsChanged({ sessionId: session_id })); + } + break; + case 'agent:context_status': // Auto-compaction collapsed older turns into a summary. Mirror compacted_through_msg_id locally so the renderer can drop a visible "N earlier turns summarized" chip into the transcript. Other reasons (cleared, etc.) flow through this same event but don't currently need a chip, ignore them for now. if (session_id && data.reason === 'compacted') {