From 0c83bb8f785d7fe6ee5f82815358e59f5d95beb4 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 7 Aug 2026 17:37:30 -0700 Subject: [PATCH] [eric] canvas: an app window owns its wheel whether or not you clicked in first; workflow tests wait on progress, not a clock --- backend/apps/agents/schedule_mcp_server.py | 45 +++++++++++-------- .../hooks/interaction/useCanvasControls.ts | 16 ++++--- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py index bca169ef..e0cfa999 100644 --- a/backend/apps/agents/schedule_mcp_server.py +++ b/backend/apps/agents/schedule_mcp_server.py @@ -546,10 +546,13 @@ def handle_delete_step(args: dict) -> dict: # How long a synchronous test may hold the turn. Long enough for a real multi-step workflow, short # enough that a wedged test returns an honest "still running" instead of hanging the conversation. -# A real workflow test does several searches and page fetches per step. The first budget was 240s -# and a live three-step digest took 243, so it missed by three seconds and the agent had to make a -# second hop anyway, which is the exact re-pinging this tool exists to remove. -TEST_WAIT_S = 600 +# Wait on PROGRESS, not on a clock. A fixed budget answers the wrong question: a run doing real work +# should never be cut off (the first budget was 240s and a live digest took 243, missing by three +# seconds), while a run that is genuinely wedged should not be waited on for ten minutes either. So +# the deadline resets every time the transcript grows, and only silence ends the wait. +TEST_IDLE_S = 180.0 +# Absolute backstop for the pathological case where a run reports progress forever. +TEST_MAX_S = 3600.0 TEST_POLL_S = 3 @@ -565,33 +568,39 @@ def handle_test_workflow(args: dict) -> dict: # to "call ReadTestTranscript once it finishes", but a model has no way to know when that is, so # it ended its turn and the HUMAN had to keep re-pinging it. A test whose result the caller # cannot observe is not a tool, it is homework for the user. - deadline = time.time() + TEST_WAIT_S + started = time.time() + last_progress_at = started + last_len = -1 last_status = "running" - while time.time() < deadline: + partial = "" + while True: + now = time.time() + if now - last_progress_at >= TEST_IDLE_S or now - started >= TEST_MAX_S: + break time.sleep(TEST_POLL_S) t = _call("GET", f"/{wid}/test-transcript") if "_error" in t: continue last_status = t.get("status") or "running" + transcript = t.get("transcript") or "" + # Any growth in the transcript is the run telling us it is alive, so the clock starts over. + if len(transcript) != last_len: + last_len = len(transcript) + last_progress_at = time.time() + partial = transcript if last_status in ("running", "none"): continue - transcript = t.get("transcript") or "(empty transcript)" - return _ok(f"Test finished (status: {last_status}). Transcript:\n\n{transcript}") + return _ok(f"Test finished (status: {last_status}). Transcript:\n\n{transcript or '(empty transcript)'}") # Hand back whatever the run has actually produced. Returning only "call ReadTestTranscript" made # the model guess when to poll, which is the same dead end as not waiting at all; a partial # transcript is something it can reason about right now. - partial = "" - try: - t = _call("GET", f"/{wid}/test-transcript") - if "_error" not in t: - partial = (t.get("transcript") or "").strip() - except Exception: - partial = "" + waited = int(time.time() - started) head = ( - f"Test Agent (session {sid[:8]}) has not finished after {TEST_WAIT_S}s (status: {last_status}). " - "Everything it has produced so far follows; call ReadTestTranscript for the rest once it lands." + f"Test Agent (session {sid[:8]}) went quiet: no new output for {int(TEST_IDLE_S)}s " + f"(status: {last_status}, waited {waited}s total). Everything it produced follows; " + "call ReadTestTranscript if it lands later." ) - return _ok(f"{head}\n\n{partial}" if partial else head) + return _ok(f"{head}\n\n{partial.strip()}" if partial.strip() else head) def handle_read_test_transcript(args: dict) -> dict: diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 63743127..6602123d 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -9,6 +9,11 @@ import { applyBrowserZoom } from '@/shared/browserZoom'; import { syncTiledGeometry } from '../../canvas/tiledGeometry'; import { revealZoom, REVEAL_MIN_ZOOM } from '../../canvas/revealZoom'; +// Surfaces that are WINDOWS, not canvas cards: they behave like an OS window, so a wheel inside one +// belongs to it whether or not you clicked in first. Canvas cards (agent, browser, view) keep the +// Google Maps model instead, where a plain scroll over an unfocused card drives the canvas. +const APP_WINDOW_SELECT_TYPES = new Set(['settings-card', 'marketplace-card', 'workflows-hub-card']); + const MIN_ZOOM = 0.15; // The floor for AUTOMATIC reveals only. revealCards takes min(current, fit), which can only ever go // down, so every spawn that did not fit ratcheted the camera out and nothing ever brought it back: @@ -356,12 +361,13 @@ export function useCanvasControls( // old walk-up handed those to the canvas: reaching the end of Settings zoomed the world out. const windowEl = (e.target as HTMLElement | null)?.closest?.('[data-select-type]') as HTMLElement | null; if (windowEl && !(e.ctrlKey || e.metaKey)) { - // Only a card you have clicked INTO owns the wheel. Claiming every card unconditionally - // killed the Google Maps model: with cards under the pointer the canvas stopped responding - // to scroll at all. Singleton windows (Settings, Marketplace) carry no select-id and are - // always owners, since their inner panels are exactly the "hit target isn't the scroller" case. + // An app WINDOW always owns its wheel; a canvas CARD only owns it once you have clicked in. + // That split is the whole rule. Requiring click-focus for windows too meant hovering over + // Settings and scrolling leaked straight to the canvas, because nothing had focused it yet, + // and windows do carry a select-id so a "no id means a window" test silently never fired. const windowId = windowEl.getAttribute('data-select-id'); - if (!windowId || windowId === getScrollFocusedCard()) { + const isAppWindow = APP_WINDOW_SELECT_TYPES.has(windowEl.getAttribute('data-select-type') || ''); + if (isAppWindow || !windowId || windowId === getScrollFocusedCard()) { containedEl = windowEl; containedAt = Date.now(); return;