[eric] merge: streaming cadence + live markdown + auth/devtools/browser fixes from dev

This commit is contained in:
eric
2026-06-01 11:55:38 -07:00
12 changed files with 213 additions and 67 deletions
+3
View File
@@ -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 = (
+11
View File
@@ -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.
+23
View File
@@ -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)
+13
View File
@@ -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());
}
+19 -10
View File
@@ -1307,19 +1307,28 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
</Box>
);
})}
{/* 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 && (
<StreamingBubble
sessionId={id}
activeBranchId={session.active_branch_id || 'main'}
turnLabel={session.turn_label?.label}
onStreamGrew={stickToBottomIfNeeded}
/>
<Box sx={{ overflowAnchor: 'none' }}>
<StreamingBubble
sessionId={id}
activeBranchId={session.active_branch_id || 'main'}
turnLabel={session.turn_label?.label}
onStreamGrew={stickToBottomIfNeeded}
/>
</Box>
)}
{(awaitingResponse || (session.status === 'running' && !streamingMessageId)) && (
<ThinkingBubble
label={session.turn_label?.label}
seedKey={`${session.id}:${session.messages?.length ?? 0}`}
/>
<Box sx={{ overflowAnchor: 'none' }}>
<ThinkingBubble
label={session.turn_label?.label}
seedKey={`${session.id}:${session.messages?.length ?? 0}`}
/>
</Box>
)}
{showResumeBubble && session.status === 'stopped' && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
@@ -972,6 +972,17 @@ const MessageBubble: React.FC<Props> = 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<Props> = React.memo(({ message, editing = false, o
</Box>
) : (
<>
{isStreaming ? (
<Box
component="div"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontSize: 'inherit',
lineHeight: 'inherit',
color: 'inherit',
}}
>
{rawText}
</Box>
) : (
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 && <StreamingCursor />}
</>
)}
@@ -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<number>(enabled ? 0 : target.length); // float reveal position
const cpsRef = useRef<number>(0); // current reveal speed
const lastRef = useRef<number>(0); // last frame timestamp
const shownRef = useRef<number>(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)
@@ -55,7 +55,24 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
const tc = useTermColors();
return (
<Box {...selectAttrs} sx={{ maxWidth: mcpCompact ? '100%' : '85%', my: mcpCompact ? 0 : 0.5 }}>
<Box
{...selectAttrs}
sx={{
maxWidth: mcpCompact ? '100%' : '85%',
my: mcpCompact ? 0 : 0.5,
// Ease standalone tool calls in instead of popping. Skipped while
// streaming (the live pill has a committed twin, so animating both
// would flash) and when mcpCompact (rows inside a group already fade
// via toolRowFadeIn). Transform+opacity only, so layout/scroll are untouched.
...(!isStreaming && !mcpCompact ? {
animation: 'toolBubbleEnter 160ms ease-out',
'@keyframes toolBubbleEnter': {
from: { opacity: 0, transform: 'translateY(4px)' },
to: { opacity: 1, transform: 'translateY(0)' },
},
} : {}),
}}
>
<Box
sx={{
'--glow-rgb': accentRgb,
@@ -65,7 +65,11 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = 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 =
@@ -99,6 +99,14 @@ const ToolGroupBubble: React.FC<Props> = 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)' },
},
}}
>
<Box
+15 -1
View File
@@ -574,11 +574,25 @@ async function handleEvaluate(wv: BrowserWebview, params: Record<string, any>):
}
}
// 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<BrowserWebview | undefined> {
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<string, any>) {
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,
+12
View File
@@ -23,6 +23,18 @@ export async function refreshAuthToken(): Promise<string> {
} 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;
}