diff --git a/backend/auth.py b/backend/auth.py index 154be5d4..84953cb4 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -186,6 +186,9 @@ _AUTH_EXEMPT_EXACT = { # public api.openswarm.com/api/oauth/google/refresh doesn't already # do for any internet caller, so no new attack surface. "/api/tools/google-oauth-token", + # Dev-only token handoff for the split-port frontend (no Electron preload + # to read the token from). The route itself 404s in packaged builds. + "/api/dev/token", } _AUTH_EXEMPT_PREFIX = ( diff --git a/backend/main.py b/backend/main.py index ad21f1ee..a4f3e124 100644 --- a/backend/main.py +++ b/backend/main.py @@ -386,6 +386,17 @@ async def websocket_dashboard(websocket: WebSocket): ws_manager.disconnect_global(websocket) +@app.get("/api/dev/token") +async def dev_token(): + """Hand the per-install token to the dev frontend, which has no Electron + preload to read it from. Disabled in packaged builds (the preload exists + there); localhost binding is the only thing gating it in dev.""" + if os.environ.get("OPENSWARM_PACKAGED") == "1": + return JSONResponse({"error": "not available"}, status_code=404) + from backend.auth import get_auth_token + return JSONResponse({"token": get_auth_token()}) + + @app.post("/api/browser/command") async def browser_command(request: Request): """HTTP endpoint called by the browser MCP server subprocess. diff --git a/backend/tests/test_auth_router.py b/backend/tests/test_auth_router.py index 9eeb7a72..2fe86d15 100644 --- a/backend/tests/test_auth_router.py +++ b/backend/tests/test_auth_router.py @@ -268,3 +268,26 @@ def test_signout_succeeds_even_when_cloud_unreachable(client, reset_settings): s2 = load_settings() assert s2.user_id is None assert s2.openswarm_bearer_token is None + + +# --------------------------------------------------------------------------- +# The dev-token handoff must be dev-only so it can't widen prod surface (#49). +# --------------------------------------------------------------------------- + +def test_dev_token_is_dev_only(): + """/api/dev/token hands the install token to the split-port dev frontend + without auth, but 404s in packaged builds where the preload supplies it.""" + import os + import backend.auth as auth_mod + noauth = TestClient(app) # deliberately no bearer header + + os.environ.pop("OPENSWARM_PACKAGED", None) + r = noauth.get("/api/dev/token") + assert r.status_code == 200 + assert r.json()["token"] == auth_mod._TOKEN + + os.environ["OPENSWARM_PACKAGED"] = "1" + try: + assert noauth.get("/api/dev/token").status_code == 404 + finally: + os.environ.pop("OPENSWARM_PACKAGED", None) diff --git a/electron/main.js b/electron/main.js index 45fcc596..7305c71a 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1249,6 +1249,19 @@ function createWindow() { console.log(`[renderer:${tag}] ${message}${src ? ` (${src}:${line})` : ''}`); }); + // DevTools shortcut. Windows/Linux hide the menu bar, so the default View > + // Toggle Developer Tools route is unreachable there (Mac keeps its menu); + // wire F12 and Ctrl/Cmd+Shift+I directly so support can grab logs anywhere. + mainWindow.webContents.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown') return; + const key = (input.key || '').toLowerCase(); + const isInspect = (input.control || input.meta) && input.shift && key === 'i'; + if (key === 'f12' || isInspect) { + mainWindow.webContents.toggleDevTools(); + event.preventDefault(); + } + }); + isCreatingMainWindow = false; console.log('[diag][main] createWindow end, ua=', mainWindow.webContents.getUserAgent()); } diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index bf103154..7735ee3d 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1307,19 +1307,28 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose ); })} + {/* overflow-anchor: none on the two elements that grow every frame + (live stream + thinking dots) keeps Chromium's scroll anchoring + from fighting our jam-to-bottom for the scroll position. The + committed messages above keep the default anchor, so resizing a + tool row while the user has scrolled up still holds their view. */} {id && ( - + + + )} {(awaitingResponse || (session.status === 'running' && !streamingMessageId)) && ( - + + + )} {showResumeBubble && session.status === 'stopped' && ( diff --git a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx index 8c79e7fd..5656c969 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx @@ -972,6 +972,17 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o overflow: 'hidden', opacity: isPending ? 0.7 : 1, transition: 'opacity 0.2s, border-color 0.2s', + // User bubbles ease in instead of popping. Assistant bubbles are left + // alone on purpose: they reveal by typing, and animating them would + // flash at the streaming -> committed handoff. Transform+opacity only, + // so it rides the compositor and never shifts layout or the scroll. + ...(isUser && !editing ? { + animation: 'msgBubbleEnter 160ms ease-out', + '@keyframes msgBubbleEnter': { + from: { opacity: 0, transform: 'translateY(4px)' }, + to: { opacity: 1, transform: 'translateY(0)' }, + }, + } : {}), }} > {isUser ? ( @@ -1165,22 +1176,12 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o ) : ( <> - {isStreaming ? ( - - {rawText} - - ) : ( - renderedMarkdown - )} + {/* Render markdown live (not just at the end) so code is mono, + bold is bold, lists/headings format from the first character. + Killing the old plain-text -> markdown swap removes the big + layout snap at stream end, which was the "glitch" people felt. + Re-parse is memoized on the (smoothed) text and cheap at chat sizes. */} + {renderedMarkdown} {isStreaming && } )} diff --git a/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts b/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts index b3d603bd..425e8955 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts +++ b/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts @@ -6,26 +6,28 @@ import { useEffect, useRef, useState } from 'react'; * claude.ai does, so generated text reads like it's being typed rather than * dumped in clumps. * - * Zero dependencies. Zero added TTFT: the first characters reveal on the very next - * animation frame after the first delta (same frame budget as painting it directly). - * The reveal rate is ADAPTIVE — it accelerates as the backlog grows, so display - * never falls meaningfully behind the model and never reads as laggy. The rAF loop - * runs ONLY while there's a backlog to drain and parks itself at zero cost once - * caught up, so it adds no idle-frame churn. + * Why the old "reveal backlog/4, floor 3 chars/frame" version felt like + * "pump pump pump": that floor (~180 chars/sec) is FASTER than a model + * generates (~90 chars/sec), so the display kept sprinting to catch up, then + * FROZE waiting for the next token. Freeze-sprint-freeze at token frequency is + * the choppiness. + * + * This version is a buffered constant-velocity controller: + * - It deliberately stays ~TARGET_LAG seconds BEHIND the latest text, so there + * is always a buffer to reveal and it never runs dry between tokens. + * - Reveal is TIME-based (chars = rate * elapsed), so it's frame-rate + * independent and survives a dropped frame without a visible jump. + * - The reveal RATE is EMA-smoothed, so a burst ramps the speed up gently and + * a lull ramps it down gently; the rate never steps, so the flow never pulses. + * The rAF loop runs only while there's a backlog and parks at zero cost once + * caught up. Zero added TTFT: the first characters still reveal in-render on the + * very first frame content exists. */ -/** Pure pacing step (exported for testing): chars to reveal this frame. */ -export function smoothStep(shown: number, full: number): number { - if (shown >= full) return full; - const backlog = full - shown; - // Floor of 3 chars/frame (~180 chars/sec at 60fps) for a calm typing feel, - // and drain ~1/4 of any backlog on top of that so bursts catch up fast. The - // /4 keeps mid-stream lag small (a few words at most), so when the live bubble - // hands off to the final message at stream end there's no visible jump. Never - // overshoots `full`. - const step = Math.max(3, Math.ceil(backlog / 4)); - return Math.min(full, shown + step); -} +const TARGET_LAG_S = 0.35; // stay this far behind = the buffer that prevents stalls +const RATE_SMOOTH_S = 0.25; // how fast the reveal speed eases toward its target +const MAX_CPS = 1000; // cap so a huge paste/burst still reveals smoothly, not instantly +const MAX_DT_S = 0.05; // clamp elapsed after a frame drop / tab switch so we don't leap export function useSmoothText(target: string, enabled: boolean): string { const [shownLen, setShownLen] = useState(enabled ? 0 : target.length); @@ -33,40 +35,69 @@ export function useSmoothText(target: string, enabled: boolean): string { const targetRef = useRef(target); targetRef.current = target; + // Controller state lives in refs so the rAF loop reads the latest without the + // effect re-subscribing every character. + const posRef = useRef(enabled ? 0 : target.length); // float reveal position + const cpsRef = useRef(0); // current reveal speed + const lastRef = useRef(0); // last frame timestamp + const shownRef = useRef(shownLen); + shownRef.current = shownLen; + + // ONE persistent loop, keyed only on `enabled`. It must NOT restart per token: + // an effect that depends on target.length tears the rAF down and rebuilds it on + // every delta, and that churn is what stalls the reveal. So the loop runs every + // frame for the life of the stream, reads the latest text from a ref, and just + // advances by 0 when it happens to be caught up (cheap, no stall, no parking). useEffect(() => { - // Disabled (historical message, or smoothing turned off): show all, stop loop. if (!enabled) { if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } + posRef.current = targetRef.current.length; setShownLen(targetRef.current.length); return; } - const tick = () => { - rafRef.current = null; - setShownLen((cur) => { - const next = smoothStep(cur, targetRef.current.length); - if (next < targetRef.current.length) rafRef.current = requestAnimationFrame(tick); - return next; - }); + + const tick = (now: number) => { + const full = targetRef.current.length; + const dtRaw = lastRef.current ? (now - lastRef.current) / 1000 : 0.016; + lastRef.current = now; + const dt = dtRaw > MAX_DT_S ? MAX_DT_S : dtRaw; + + const backlog = Math.max(0, full - posRef.current); + const desired = backlog / TARGET_LAG_S; // speed that holds the lag steady (0 when caught up) + const k = Math.min(1, dt / RATE_SMOOTH_S); + let cps = cpsRef.current + (desired - cpsRef.current) * k; // EMA-smooth the speed itself, both up and down + if (cps > MAX_CPS) cps = MAX_CPS; + if (cps < 0) cps = 0; + cpsRef.current = cps; + + if (backlog > 0) { + posRef.current = Math.min(full, posRef.current + cps * dt); + const nextLen = Math.floor(posRef.current); + if (nextLen !== shownRef.current) setShownLen(nextLen); + } + rafRef.current = requestAnimationFrame(tick); // keep running for the whole stream }; - // Start a drain only if we're behind and no loop is already running. - if (rafRef.current == null && shownLen < target.length) { - rafRef.current = requestAnimationFrame(tick); - } + + lastRef.current = 0; + rafRef.current = requestAnimationFrame(tick); return () => { if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } }; - }, [enabled, target.length, shownLen]); + }, [enabled]); // Target shrank (new turn / reset / branch switch): re-sync so we don't slice - // past the end of a shorter string. + // past the end of a shorter string and so a fresh turn starts from zero. useEffect(() => { - if (shownLen > target.length) setShownLen(enabled ? 0 : target.length); - }, [target.length, shownLen, enabled]); + if (posRef.current > target.length) { + posRef.current = enabled ? 0 : target.length; + cpsRef.current = 0; + lastRef.current = 0; + setShownLen(enabled ? 0 : target.length); + } + }, [target.length, enabled]); - // ZERO added TTFT: on the very first frame content exists (shownLen still 0), - // reveal the floor immediately in-render instead of waiting a frame for the rAF - // tick. Pure derivation, no extra render — so first visible text lands on the - // exact same frame it would have without smoothing. State catches up next frame. + // ZERO added TTFT: on the very first frame content exists, reveal a few chars + // in-render instead of waiting a frame for the first rAF tick. if (!enabled) return target; const effectiveShown = (shownLen === 0 && target.length > 0) ? Math.min(3, target.length) diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx index 4ef70036..09dd9a48 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx @@ -55,7 +55,24 @@ export const DefaultToolBubble: React.FC = ({ const tc = useTermColors(); return ( - + = React.memo( const isInvokeAgent = isInvokeAgentTool(toolName); const isCreateAgent = isCreateAgentTool(toolName); const browserAgentAutoExpand = isBrowserAgent && isPending && !isStreaming; - const showBody = expanded || isStreaming || browserAgentAutoExpand; + // While the call is still streaming we keep the body CLOSED: the args land in + // bursty clumps and force-painting them mid-stream is the jitter the user feels. + // The header pill (tool name + glow) is the calm "what's running" signal; the + // full args/output live behind the chevron once the call lands and is expanded. + const showBody = expanded || browserAgentAutoExpand; const resultContent = result?.content; const hasStructuredResult = diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx index 0c4de468..4399019a 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx @@ -99,6 +99,14 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = my: 0.5, // contain: stops new tool rows from reflowing the whole transcript. contain: 'layout style', + // Ease in instead of popping when a tool group appears mid-turn. + // Transform+opacity only, so it rides the compositor and never nudges + // layout or the scroll position. No streaming twin, so no handoff flash. + animation: 'toolGroupEnter 160ms ease-out', + '@keyframes toolGroupEnter': { + from: { opacity: 0, transform: 'translateY(4px)' }, + to: { opacity: 1, transform: 'translateY(0)' }, + }, }} > ): } } +// The registry is renderer-local and a card briefly unregisters on remount / +// tab-switch; a command landing in that gap shouldn't hard-fail. Wait a bounded +// window for (re)registration before giving up, so the error stays a real +// "card is gone" signal rather than a transient race. +async function awaitWebview(browserId: string, tabId?: string): Promise { + const deadline = Date.now() + 2000; + let wv = getWebview(browserId, tabId); + while (!wv && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 100)); + wv = getWebview(browserId, tabId); + } + return wv; +} + async function handleBrowserCommand(data: Record) { const { request_id, action, browser_id, tab_id, params = {} } = data; if (!request_id) return; - const wv = getWebview(browser_id, tab_id || undefined); + const wv = await awaitWebview(browser_id, tab_id || undefined); if (!wv) { dashboardWs.send('browser:result', { request_id, diff --git a/frontend/src/shared/config.ts b/frontend/src/shared/config.ts index e36edd45..00b47eaf 100644 --- a/frontend/src/shared/config.ts +++ b/frontend/src/shared/config.ts @@ -23,6 +23,18 @@ export async function refreshAuthToken(): Promise { } catch { _authTokenCache = ''; } + return _authTokenCache; + } + // Dev (split-port, no Electron preload): the backend hands us the token over + // localhost. The route 404s in packaged builds, so this only fires under run.sh. + try { + const r = await fetch(`http://${host}:${port}/api/dev/token`); + if (r.ok) { + const data = await r.json(); + _authTokenCache = typeof data?.token === 'string' ? data.token : ''; + } + } catch { + _authTokenCache = ''; } return _authTokenCache; }