-
- {mode === 'home' &&
}
- {mode === 'calendar' &&
}
- {mode === 'detail' && selectedId &&
}
- {mode === 'new' &&
}
- {mode === 'trash' &&
}
+
+ {/* TITLE BAR (drag handle) */}
+
+
+
+ Workflows
+
+
+ {selected && (
+ // The share dialog portals to the body but its events still bubble the React tree, so stop them here or dragging the card follows a click inside the modal.
+
e.stopPropagation()}
+ onClick={(e) => e.stopPropagation()}
+ style={{ display: 'flex' }}
+ >
+
+
+ )}
+
{ e.stopPropagation(); dispatch(closeWorkflowsApp()); }}
+ onPointerDown={(e) => e.stopPropagation()}
+ sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error, bgcolor: `${c.status.error}14` } }}
+ >
+
+
+
+
+
+
+ {mode === 'home' && }
+ {mode === 'calendar' && }
+ {mode === 'detail' && selectedId && }
+ {mode === 'new' && }
+ {mode === 'trash' && }
+
);
};
diff --git a/frontend/src/app/pages/Workflows/app/types.ts b/frontend/src/app/pages/Workflows/app/types.ts
index ca086556..6340614b 100644
--- a/frontend/src/app/pages/Workflows/app/types.ts
+++ b/frontend/src/app/pages/Workflows/app/types.ts
@@ -1,6 +1,16 @@
+import type { PointerEvent } from 'react';
+
export type AppMode = 'home' | 'calendar' | 'detail' | 'new' | 'trash';
export type CalView = 'week' | 'month';
+// The card owns drag geometry but the title bar renders inside the content (it needs nav state to know which workflow to share), so the card hands its drag handlers down.
+export interface CardHeader {
+ onPointerDown: (e: PointerEvent) => void;
+ onPointerMove: (e: PointerEvent) => void;
+ onPointerUp: (e: PointerEvent) => void;
+ dragging: boolean;
+}
+
// Navigation + ephemeral UI state for the Workflows app window. Data lives in Redux; this is only "where am I looking right now".
export interface AppNav {
mode: AppMode;
diff --git a/frontend/src/shared/browserCommandHandler.ts b/frontend/src/shared/browserCommandHandler.ts
index aa307ccd..ddfd6575 100644
--- a/frontend/src/shared/browserCommandHandler.ts
+++ b/frontend/src/shared/browserCommandHandler.ts
@@ -1,4 +1,4 @@
-import { getWebview, findWebviewByDomain, type BrowserWebview } from './browserRegistry';
+import { getWebview, findWebviewByDomain, hasDomReady, markDomReady, isPendingLoad, wakePendingLoad, clearPendingLoad, type BrowserWebview } from './browserRegistry';
import { store } from './state/store';
import { resumeBrowserCard } from './state/dashboardLayoutSlice';
import { dashboardWs } from './ws/WebSocketManager';
@@ -189,8 +189,40 @@ async function countSafeRoutes(wv: BrowserWebview): Promise
{
} catch { return 0; }
}
+// Electron queues executeJavaScript until the page "stops loading", and pages with straggler subresources (recaptcha/tracker iframes) can stay isLoading for minutes, starving EVERY command into its backend timeout (the wedged-webview tail). Once the document itself is ready, wv.stop() cancels only the stragglers and fires did-stop-loading, which flushes the queue; a genuinely-still-loading document (no dom-ready yet) is left alone.
+const STUCK_EVAL_GRACE_MS = 2500;
+const STUCK_EVAL_LIMIT_MS = 9000;
+
+async function evalInPage(wv: BrowserWebview, code: string): Promise {
+ const run = wv.executeJavaScript(code).then((v) => {
+ markDomReady(wv);
+ return { done: true as const, value: v };
+ });
+ const grace = new Promise<{ done: false }>((r) => setTimeout(() => r({ done: false }), STUCK_EVAL_GRACE_MS));
+ let first = await Promise.race([run, grace]);
+ if (!first.done) {
+ let stopped = false;
+ try {
+ if (wv.isLoading() && hasDomReady(wv)) {
+ wv.stop();
+ stopped = true;
+ }
+ } catch {
+ // torn-down webview; the limit below surfaces it
+ }
+ const limit = new Promise<{ done: false }>((r) => setTimeout(() => r({ done: false }), STUCK_EVAL_LIMIT_MS));
+ first = await Promise.race([run, limit]);
+ if (!first.done) {
+ throw new Error(stopped
+ ? 'page never finished loading even after cancelling stragglers'
+ : 'page is still loading; retry shortly');
+ }
+ }
+ return first.value;
+}
+
async function handleGetText(wv: BrowserWebview): Promise> {
- const text: string = await wv.executeJavaScript(
+ const text: string = await evalInPage(wv,
'document.body.innerText.substring(0, 15000)'
);
// Sampled HERE (on a read), not on navigate: by the time the agent reads the page, the SPA's XHR/fetch have fired, so routes are actually captured.
@@ -272,7 +304,7 @@ async function handleClick(wv: BrowserWebview, params: Record): Pro
clickY: window.innerHeight > 0 ? y / window.innerHeight : 0.5,
};
})()`;
- const result = await wv.executeJavaScript(code);
+ const result = await evalInPage(wv, code);
return result;
}
@@ -300,7 +332,7 @@ async function handleType(wv: BrowserWebview, params: Record): Prom
text: 'Typed into: ' + el.tagName.toLowerCase() + (el.id ? '#' + el.id : ''),
};
})()`;
- const result = await wv.executeJavaScript(code);
+ const result = await evalInPage(wv, code);
return result;
}
@@ -316,12 +348,63 @@ const KEY_NAME_MAP: Record = {
Del: 'Delete',
};
+interface CdpKeyDescriptor { key: string; code: string; vk: number; text?: string }
+
+const CDP_KEYS: Record = {
+ Enter: { key: 'Enter', code: 'Enter', vk: 13, text: '\r' },
+ Tab: { key: 'Tab', code: 'Tab', vk: 9 },
+ Escape: { key: 'Escape', code: 'Escape', vk: 27 },
+ Backspace: { key: 'Backspace', code: 'Backspace', vk: 8 },
+ Delete: { key: 'Delete', code: 'Delete', vk: 46 },
+ ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', vk: 38 },
+ ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', vk: 40 },
+ ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', vk: 37 },
+ ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', vk: 39 },
+ Home: { key: 'Home', code: 'Home', vk: 36 },
+ End: { key: 'End', code: 'End', vk: 35 },
+ PageUp: { key: 'PageUp', code: 'PageUp', vk: 33 },
+ PageDown: { key: 'PageDown', code: 'PageDown', vk: 34 },
+ ' ': { key: ' ', code: 'Space', vk: 32, text: ' ' },
+};
+
+// Loose names the model actually sends, folded onto the canonical DOM names above.
+const CDP_KEY_ALIASES: Record = {
+ Up: 'ArrowUp', Down: 'ArrowDown', Left: 'ArrowLeft', Right: 'ArrowRight',
+ Space: ' ', Spacebar: ' ', Esc: 'Escape', Del: 'Delete', Return: 'Enter',
+};
+
+function cdpKeyDescriptor(rawKey: string): CdpKeyDescriptor | null {
+ const canonical = CDP_KEY_ALIASES[rawKey] || rawKey;
+ const named = CDP_KEYS[canonical];
+ if (named) return named;
+ if (canonical.length === 1) {
+ const upper = canonical.toUpperCase();
+ const code = /[a-z]/i.test(canonical) ? `Key${upper}` : /[0-9]/.test(canonical) ? `Digit${canonical}` : '';
+ return { key: canonical, code, vk: upper.charCodeAt(0), text: canonical };
+ }
+ return null;
+}
+
async function handlePressKey(wv: BrowserWebview, params: Record): Promise> {
const rawKey = (params.key as string) || '';
if (!rawKey) return { error: 'key parameter is required' };
+ await evalInPage(wv, 'document.body && document.body.focus && document.body.focus(); true');
+ const desc = cdpKeyDescriptor(rawKey);
+ if (desc) {
+ try {
+ // CDP key events are trusted AND scoped to THIS webview no matter where the user's cursor sits; the sendInputEvent path delivered to whatever had focus, which is the "agent typed into my note" bug. keyDown-with-text inserts the char; bare named keys use rawKeyDown so no stray char lands.
+ const down: Record = { type: desc.text ? 'keyDown' : 'rawKeyDown', key: desc.key, windowsVirtualKeyCode: desc.vk, nativeVirtualKeyCode: desc.vk };
+ if (desc.code) down.code = desc.code;
+ if (desc.text) down.text = desc.text;
+ await sendCdp(wv, 'Input.dispatchKeyEvent', down);
+ const up: Record = { type: 'keyUp', key: desc.key, windowsVirtualKeyCode: desc.vk, nativeVirtualKeyCode: desc.vk };
+ if (desc.code) up.code = desc.code;
+ await sendCdp(wv, 'Input.dispatchKeyEvent', up);
+ return { text: `Pressed ${rawKey}` };
+ } catch { /* fall through to the legacy path so a CDP hiccup never makes a key dead */ }
+ }
+ // Legacy focus-dependent fallback (exotic keys or CDP unavailable): keeps every key that worked before working.
const keyCode = KEY_NAME_MAP[rawKey] || rawKey;
- await wv.executeJavaScript('document.body && document.body.focus && document.body.focus(); true');
- // Native OS-level key events have isTrusted=true, so hostile sites' keyboard handlers respect them.
wv.sendInputEvent({ type: 'keyDown', keyCode });
wv.sendInputEvent({ type: 'char', keyCode });
wv.sendInputEvent({ type: 'keyUp', keyCode });
@@ -348,7 +431,7 @@ async function handleClickPoint(wv: BrowserWebview, params: Record)
// host element's box. One cheap round-trip; falls back to the element box.
let vw = wv.clientWidth, vh = wv.clientHeight;
try {
- const d = await wv.executeJavaScript('({w: window.innerWidth, h: window.innerHeight})');
+ const d = await evalInPage(wv, '({w: window.innerWidth, h: window.innerHeight})');
if (d && d.w > 0 && d.h > 0) { vw = d.w; vh = d.h; }
} catch { /* use the element box as a fallback */ }
const x = (cx / 100) * vw;
@@ -1122,7 +1205,7 @@ async function handleScroll(wv: BrowserWebview, params: Record): Pr
};
})()`;
try {
- const result = await wv.executeJavaScript(code);
+ const result = await evalInPage(wv, code);
const status = result.atBottom ? ' (reached bottom)' : result.atTop ? ' (reached top)' : '';
return {
text: `Scrolled ${direction} by ${result.scrolled}px${status}. Position: ${result.scrollTop}/${result.scrollHeight - result.clientHeight}px`,
@@ -1150,7 +1233,7 @@ async function handleWait(wv: BrowserWebview, params: Record): Prom
const elapsed = Date.now() - start;
if (elapsed >= ms) break;
try {
- const probe = JSON.parse(await wv.executeJavaScript(probeJs));
+ const probe = JSON.parse(await evalInPage(wv, probeJs));
probeErrors = 0;
if (probe.elems !== lastElems) { lastElems = probe.elems; elemsChangedAt = Date.now(); }
const domStable = Date.now() - elemsChangedAt;
@@ -1238,7 +1321,7 @@ async function handleGetElements(wv: BrowserWebview, params: Record
return { elements: results, total: interactive.length, url: location.href, title: document.title };
})()`;
try {
- const result = await wv.executeJavaScript(code);
+ const result = await evalInPage(wv, code);
return { text: JSON.stringify(result, null, 2), url: wv.getURL() };
} catch (err: any) {
return { error: `Failed to get elements: ${err?.message || String(err)}` };
@@ -1263,7 +1346,7 @@ async function handleDetectWebMCP(wv: BrowserWebview): Promise
} catch (e) { return { error: String((e && e.message) || e) }; }
})()`;
try {
- const res = await wv.executeJavaScript(code);
+ const res = await evalInPage(wv, code);
if (res.error) return { error: `Replay failed: ${res.error}` };
return { text: `${method} ${absUrl} -> HTTP ${res.status}\n${res.body}`, status: res.status, url: wv.getURL() };
} catch (err: any) {
@@ -1340,7 +1423,7 @@ async function handleEvaluate(wv: BrowserWebview, params: Record):
const expression = params.expression as string;
if (!expression) return { error: 'expression parameter is required' };
try {
- const result = await wv.executeJavaScript(expression);
+ const result = await evalInPage(wv, expression);
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
// evaluate is the agent's main read path; sample routes here too (XHRs have fired by now) so the backend can surface the fast network tier once.
const routes_available = await countSafeRoutes(wv);
@@ -1351,7 +1434,7 @@ async function handleEvaluate(wv: BrowserWebview, params: Record):
}
// 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 {
+async function awaitWebview(browserId: string, tabId?: string, action?: string): Promise {
// A suspended (snapshot-swapped) card has no webview at all; wake it and wait out the remount + page reload before the command touches it.
const wasSuspended = !!store.getState().dashboardLayout.suspendedBrowserCards[browserId];
if (wasSuspended) store.dispatch(resumeBrowserCard(browserId));
@@ -1371,6 +1454,24 @@ async function awaitWebview(browserId: string, tabId?: string): Promise setTimeout(r, 150));
}
}
+ // A lazy background tab mounts at about:blank with its real page deferred; an agent command needs
+ // the real page, so wake it and wait out the load, same as a resumed suspended card. A navigate
+ // is about to load its own url, so just drop the deferred load instead of loading the old one first.
+ if (wv && isPendingLoad(wv)) {
+ if (action === 'navigate') {
+ clearPendingLoad(wv);
+ } else if (wakePendingLoad(wv)) {
+ const loadDeadline = Date.now() + 12000;
+ while (Date.now() < loadDeadline) {
+ try {
+ if (!wv.isLoading() && wv.getURL() !== 'about:blank') break;
+ } catch {
+ // mid-load hiccup; keep waiting
+ }
+ await new Promise((r) => setTimeout(r, 150));
+ }
+ }
+ }
return wv;
}
@@ -1418,6 +1519,18 @@ async function handlePerformAction(params: Record): Promise setTimeout(res, 150));
+ }
+ }
const steps = Array.isArray(params.steps) ? params.steps : [];
const results: Record[] = [];
for (const step of steps) {
@@ -1447,7 +1560,7 @@ async function runBrowserCommand(
dashboardWs.send('browser:result', { request_id, ...result });
return;
}
- const wv = await awaitWebview(browser_id, tab_id || undefined);
+ const wv = await awaitWebview(browser_id, tab_id || undefined, action);
if (!wv) {
dashboardWs.send('browser:result', {
request_id,
diff --git a/frontend/src/shared/browserRegistry.ts b/frontend/src/shared/browserRegistry.ts
index 1828da69..d4151a52 100644
--- a/frontend/src/shared/browserRegistry.ts
+++ b/frontend/src/shared/browserRegistry.ts
@@ -20,6 +20,7 @@ export interface BrowserWebview extends HTMLElement {
reload: () => void;
canGoBack: () => boolean;
canGoForward: () => boolean;
+ stop: () => void;
getURL: () => string;
getTitle: () => string;
isLoading: () => boolean;
@@ -44,8 +45,61 @@ function makeKey(browserId: string, tabId: string): string {
return `${browserId}:${tabId}`;
}
+// Electron suspends webContents.executeJavaScript until the page "stops loading", and pages with straggler iframes (LinkedIn's recaptcha/trackers) can stay isLoading for minutes; the guarded eval in browserCommandHandler needs to know the document itself is usable before it dares wv.stop().
+const domReadyDocs = new WeakSet();
+const loadTrackingArmed = new WeakSet();
+
+function armLoadStateTracking(wv: BrowserWebview): void {
+ if (loadTrackingArmed.has(wv)) return;
+ loadTrackingArmed.add(wv);
+ wv.addEventListener('dom-ready', () => domReadyDocs.add(wv));
+ // a real main-frame navigation starts a new document; in-page (SPA pushState) ones don't
+ wv.addEventListener('did-navigate', () => domReadyDocs.delete(wv));
+}
+
+export function hasDomReady(wv: BrowserWebview): boolean {
+ return domReadyDocs.has(wv);
+}
+
+export function markDomReady(wv: BrowserWebview): void {
+ domReadyDocs.add(wv);
+}
+
export function registerWebview(browserId: string, tabId: string, wv: BrowserWebview): void {
registry.set(makeKey(browserId, tabId), wv);
+ armLoadStateTracking(wv);
+}
+
+// Lazy-tab loading: a background tab mounts its (so it stays registered + resolvable
+// exactly like a live one) but defers loadURL until it's actually needed, so a many-tab card
+// doesn't load every page at once. The tab is never starved: it's woken when it becomes active
+// OR the moment an agent command resolves it.
+const pendingLoad = new WeakMap void>();
+const intendedUrl = new WeakMap();
+
+export function registerPendingLoad(wv: BrowserWebview, url: string, load: () => void): void {
+ pendingLoad.set(wv, load);
+ intendedUrl.set(wv, url);
+}
+
+export function isPendingLoad(wv: BrowserWebview): boolean {
+ return pendingLoad.has(wv);
+}
+
+// Fire a lazy tab's deferred load exactly once; returns true if it was pending (the caller then
+// waits out the page load, same as a resumed suspended card). No-op on an already-loaded tab.
+export function wakePendingLoad(wv: BrowserWebview): boolean {
+ const load = pendingLoad.get(wv);
+ if (!load) return false;
+ pendingLoad.delete(wv);
+ load();
+ return true;
+}
+
+// Drop a lazy tab's deferred load WITHOUT firing it: an agent navigate is about to load a
+// different url, so loading the old intended url first would be wasted work.
+export function clearPendingLoad(wv: BrowserWebview): void {
+ pendingLoad.delete(wv);
}
export function unregisterWebview(browserId: string, tabId: string): void {
@@ -84,13 +138,23 @@ export function findBrowserByWebContentsId(wcId: number): string | undefined {
// LIVE url (not a stale persisted card.url) so the action lands on the real tab.
export function findWebviewByDomain(domain: string): BrowserWebview | undefined {
const d = domain.toLowerCase().replace(/^\./, '');
- for (const wv of registry.values()) {
+ const matchesHost = (u: string): boolean => {
try {
- const host = new URL(wv.getURL()).hostname.toLowerCase();
- if (host === d || host.endsWith('.' + d)) return wv;
+ const host = new URL(u).hostname.toLowerCase();
+ return host === d || host.endsWith('.' + d);
} catch {
// about:blank or a torn-down webview has no parseable URL; skip it.
+ return false;
}
+ };
+ for (const wv of registry.values()) {
+ if (matchesHost(wv.getURL())) return wv;
+ }
+ // A lazy background tab sits at about:blank, so its LIVE url can't match; fall back to its
+ // INTENDED (deferred) url so the session-borrow shims still find + wake it. The caller wakes it.
+ for (const wv of registry.values()) {
+ const pend = intendedUrl.get(wv);
+ if (pend && pendingLoad.has(wv) && matchesHost(pend)) return wv;
}
return undefined;
}
diff --git a/frontend/src/shared/browserRegistryLazy.test.ts b/frontend/src/shared/browserRegistryLazy.test.ts
new file mode 100644
index 00000000..20068519
--- /dev/null
+++ b/frontend/src/shared/browserRegistryLazy.test.ts
@@ -0,0 +1,76 @@
+// Run: node --test frontend/src/shared/browserRegistryLazy.test.ts
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ registerWebview,
+ unregisterWebview,
+ registerPendingLoad,
+ isPendingLoad,
+ wakePendingLoad,
+ clearPendingLoad,
+ findWebviewByDomain,
+ type BrowserWebview,
+} from './browserRegistry.ts';
+
+// Minimal fake webview: the registry only calls addEventListener (load tracking) + getURL.
+function fakeWebview(url: string): BrowserWebview {
+ return {
+ getURL: () => url,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ } as unknown as BrowserWebview;
+}
+
+test('a lazy tab is resolvable by its INTENDED url while deferred, then wakes exactly once', () => {
+ const wv = fakeWebview('about:blank');
+ registerWebview('b1', 't1', wv);
+ let loaded = 0;
+ registerPendingLoad(wv, 'https://tiktok.com/@me', () => { loaded += 1; });
+
+ assert.equal(isPendingLoad(wv), true);
+ // about:blank live url can't match, but the intended-url fallback finds it for the session-borrow shims.
+ assert.equal(findWebviewByDomain('tiktok.com'), wv);
+
+ assert.equal(wakePendingLoad(wv), true);
+ assert.equal(loaded, 1);
+ // Second wake is a no-op (already loaded), so an agent re-touching the tab can't double-load it.
+ assert.equal(wakePendingLoad(wv), false);
+ assert.equal(loaded, 1);
+ assert.equal(isPendingLoad(wv), false);
+
+ unregisterWebview('b1', 't1');
+});
+
+test('clearPendingLoad drops the deferred load without firing it (navigate replaces the url)', () => {
+ const wv = fakeWebview('about:blank');
+ registerWebview('b2', 't2', wv);
+ let loaded = 0;
+ registerPendingLoad(wv, 'https://old.example.com', () => { loaded += 1; });
+
+ clearPendingLoad(wv);
+ assert.equal(isPendingLoad(wv), false);
+ assert.equal(wakePendingLoad(wv), false);
+ assert.equal(loaded, 0);
+
+ unregisterWebview('b2', 't2');
+});
+
+test('a live-url tab still matches by its real url (unchanged path)', () => {
+ const wv = fakeWebview('https://youtube.com/watch?v=x');
+ registerWebview('b3', 't3', wv);
+ assert.equal(findWebviewByDomain('youtube.com'), wv);
+ assert.equal(isPendingLoad(wv), false);
+ unregisterWebview('b3', 't3');
+});
+
+test('a live tab wins over a deferred tab for the same domain', () => {
+ const live = fakeWebview('https://reddit.com/r/x');
+ const lazy = fakeWebview('about:blank');
+ registerWebview('b4', 'live', live);
+ registerWebview('b4', 'lazy', lazy);
+ registerPendingLoad(lazy, 'https://reddit.com/r/y', () => {});
+ // The already-loaded tab is preferred; the deferred one is only a fallback.
+ assert.equal(findWebviewByDomain('reddit.com'), live);
+ unregisterWebview('b4', 'live');
+ unregisterWebview('b4', 'lazy');
+});
diff --git a/frontend/src/shared/cardContentScroll.ts b/frontend/src/shared/cardContentScroll.ts
new file mode 100644
index 00000000..4cda2236
--- /dev/null
+++ b/frontend/src/shared/cardContentScroll.ts
@@ -0,0 +1,67 @@
+import { getWebview } from './browserRegistry';
+import { getViewWebview } from './viewWebviewRegistry';
+import { getViewFrame } from './viewFrameRegistry';
+
+// One arrow press moves the content about a wheel notch, so a held key and a trackpad flick cover ground at a comparable rate.
+const ARROW_STEP_PX = 120;
+
+// Walks up from whatever sits at the middle of the view (a key press has no cursor to aim with) to the first ancestor that can still scroll horizontally the way dx points, nudges it, and reports whether anything actually moved. The boundary test is the same one the wheel path uses in useCanvasControls, so keys and trackpad hand the gesture back to the canvas at the same moment.
+// This runs in two worlds: stringified into a guest renderer, and called directly on a same-origin srcdoc iframe. Keep it self-contained - no imports, no closure references - or the stringified copy lands in the guest with dangling names.
+function scrollContentX(doc: Document, win: Window, dx: number): boolean {
+ const nudge = (node: Element | null): boolean => {
+ if (!node) return false;
+ const el = node as HTMLElement;
+ if (el.scrollWidth <= el.clientWidth) return false;
+ // The document's own scroller reports overflowX 'visible' yet still scrolls, so it skips the overflow test the way a real browser does.
+ const isViewport = el === doc.scrollingElement;
+ const overflowX = win.getComputedStyle(el).overflowX;
+ if (!isViewport && overflowX !== 'auto' && overflowX !== 'scroll') return false;
+ const atRight = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
+ const atLeft = el.scrollLeft <= 1;
+ if ((dx > 0 && atRight) || (dx < 0 && atLeft)) return false;
+ // Instant, not smooth: a page with scroll-behavior smooth would otherwise still be animating when the next key repeat arrives.
+ el.scrollBy({ left: dx, behavior: 'instant' });
+ return true;
+ };
+
+ let node: Element | null = doc.elementFromPoint(
+ Math.floor(win.innerWidth / 2),
+ Math.floor(win.innerHeight / 2),
+ );
+ while (node) {
+ if (nudge(node)) return true;
+ node = node.parentElement;
+ }
+ return nudge(doc.scrollingElement);
+}
+
+// Present on real Electron webviews; a browser card falls back to a plain iframe on locked-out Windows builds, which has none of this.
+interface GuestWebview {
+ executeJavaScript?: (code: string) => Promise;
+}
+
+/** Scrolls a card's own content sideways. True means the card absorbed the arrow, so the dashboard must not also navigate to a neighbor. */
+export async function scrollCardContentX(cardId: string, direction: 'left' | 'right'): Promise {
+ const dx = direction === 'right' ? ARROW_STEP_PX : -ARROW_STEP_PX;
+
+ const guest = (getWebview(cardId) ?? getViewWebview(cardId)) as GuestWebview | undefined;
+ if (guest?.executeJavaScript) {
+ // A guest is a separate renderer: the host can't read its scrollLeft, so the whole scroll-or-boundary decision has to be made over there and come back as a yes/no.
+ try {
+ const scrolled = await guest.executeJavaScript(`(${scrollContentX})(document, window, ${dx})`);
+ return scrolled === true;
+ } catch {
+ return false;
+ }
+ }
+
+ // Srcdoc app card: same-origin, so the host can walk the frame's DOM directly. A cross-origin frame throws on contentWindow access; treat that as "didn't scroll" and let the arrow navigate.
+ const frame = getViewFrame(cardId);
+ try {
+ const win = frame?.contentWindow;
+ if (!win) return false;
+ return scrollContentX(win.document, win, dx);
+ } catch {
+ return false;
+ }
+}
diff --git a/frontend/src/shared/cardScrollFocus.ts b/frontend/src/shared/cardScrollFocus.ts
new file mode 100644
index 00000000..21489fa8
--- /dev/null
+++ b/frontend/src/shared/cardScrollFocus.ts
@@ -0,0 +1,13 @@
+// The card you've clicked INTO, so plain scroll reads its content (chat transcript, scheduled-task
+// list) while scroll everywhere else zooms the canvas (Google Maps model). Imperative + read on the
+// wheel handler so no re-render; cleared when you click blank canvas. Browser/app cards aren't tracked
+// here: their guest page owns its own scroll/zoom (Maps, Figma), so plain wheel always stays in them.
+let scrollFocusedCardId: string | null = null;
+
+export function setScrollFocusedCard(id: string | null): void {
+ scrollFocusedCardId = id;
+}
+
+export function getScrollFocusedCard(): string | null {
+ return scrollFocusedCardId;
+}
diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts
index a3628a97..3c5da6cd 100644
--- a/frontend/src/shared/state/agentsSlice.ts
+++ b/frontend/src/shared/state/agentsSlice.ts
@@ -1,6 +1,7 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
import { normalizeSessionName } from './sessionDisplay';
+import { mergeSessionMessages } from './mergeSessionMessages';
const AGENTS_API = `${API_BASE}/agents`;
@@ -84,6 +85,12 @@ export interface AgentSession {
cost_usd: number;
tokens: { input: number; output: number };
messages: AgentMessage[];
+ /** Compact dashboard-list metadata; full messages are fetched when a chat opens. */
+ last_message_preview?: string;
+ first_user_message?: string;
+ message_count?: number;
+ /** WS seq high-water at snapshot time (GET /sessions only); seeds the resume cursor so connect skips replaying what REST just delivered. */
+ event_seq?: number;
pending_approvals: ApprovalRequest[];
branches: Record;
active_branch_id: string;
@@ -344,25 +351,47 @@ export const fetchSession = createAsyncThunk(
export const launchAndSendFirstMessage = createAsyncThunk(
'agents/launchAndSendFirstMessage',
- async ({ draftId, config, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds, selectedSettingIds }: LaunchAndSendPayload) => {
- const launchRes = await fetch(`${AGENTS_API}/launch`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(config),
- });
- const launchData = await launchRes.json();
- const session = launchData.session as AgentSession;
+ async ({ draftId, config, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds, selectedSettingIds }: LaunchAndSendPayload, { dispatch }) => {
+ // Optimistic bubble on the DRAFT before the three round-trips (launch/message/refetch): without it the first message of every fresh chat rendered nothing until the network came back. The fulfilled rekey swaps in the server session, which carries the real turn by then.
+ const clientMessageId = _genOptimisticId();
+ dispatch(addOptimisticMessage({
+ sessionId: draftId,
+ clientMessageId,
+ prompt,
+ contextPaths,
+ forcedTools,
+ attachedSkills: attachedSkills?.map((s) => ({ id: s.id, name: s.name })),
+ images: images?.map((img) => ({ data: img.data, media_type: img.media_type })),
+ hidden: false,
+ }));
+ try {
+ const launchRes = await fetch(`${AGENTS_API}/launch`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(config),
+ });
+ const launchData = await launchRes.json();
+ const session = launchData.session as AgentSession;
- await fetch(`${AGENTS_API}/sessions/${session.id}/message`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds, selected_app_output_ids: selectedAppIds, selected_setting_ids: selectedSettingIds }),
- });
+ // Only the launch response is load-bearing (it mints the session id); the message POST runs off the critical path so the rekey (and the chat's stream hookup) doesn't wait a round trip. The optimistic bubble already shows the message and flips to failed if this dies.
+ fetch(`${AGENTS_API}/sessions/${session.id}/message`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds, selected_app_output_ids: selectedAppIds, selected_setting_ids: selectedSettingIds, client_message_id: clientMessageId }),
+ }).then((res) => {
+ if (!res.ok) throw new Error(`first message failed: ${res.status}`);
+ }).catch(() => {
+ // The bubble lives on whichever session the rekey race left it in; one of these no-ops.
+ dispatch(markOptimisticFailed({ sessionId: session.id, clientMessageId }));
+ dispatch(markOptimisticFailed({ sessionId: draftId, clientMessageId }));
+ dispatch(updateSessionStatus({ sessionId: session.id, status: 'completed' }));
+ });
- const refreshRes = await fetch(`${AGENTS_API}/sessions/${session.id}`);
- const updatedSession = await refreshRes.json() as AgentSession;
-
- return { draftId, session: updatedSession };
+ return { draftId, session };
+ } catch (err) {
+ dispatch(markOptimisticFailed({ sessionId: draftId, clientMessageId }));
+ throw err;
+ }
}
);
@@ -505,7 +534,8 @@ export const deleteSession = createAsyncThunk(
export const fetchHistory = createAsyncThunk(
'agents/fetchHistory',
async ({ dashboardId }: { dashboardId?: string } = {}) => {
- const params = new URLSearchParams({ limit: '10000' });
+ // closed_only: an OPEN session landing in state.history made updateSession's resurrection gate swallow its terminal frames (card stuck running, final answer invisible). Search (searchHistory) keeps the full pool.
+ const params = new URLSearchParams({ limit: '10000', closed_only: '1' });
if (dashboardId) params.set('dashboard_id', dashboardId);
const res = await fetch(`${AGENTS_API}/history?${params}`);
const data = await res.json();
@@ -692,7 +722,8 @@ const agentsSlice = createSlice({
if (state.history[action.payload.id]) {
if (action.payload.status === 'running' || action.payload.mode === 'browser-agent') {
delete state.history[action.payload.id];
- } else {
+ } else if (!state.sessions[action.payload.id]) {
+ // Gate only truly-closed sessions (no live card): a late frame must not resurrect them. A LIVE session that leaked into history used to have its completed frame swallowed here, leaving the card stuck running.
return;
}
}
@@ -709,6 +740,9 @@ const agentsSlice = createSlice({
state.sessions[action.payload.id] = {
...action.payload,
name: normalizeSessionName(action.payload.name),
+ // Status frames replay stale on WS reconnect; the transcript and branch set only move forward here (fetchSession owns server-side deletes).
+ messages: mergeSessionMessages(existing?.messages, action.payload.messages, false),
+ branches: { ...existing?.branches, ...action.payload.branches },
pending_approvals: mergedApprovals,
tool_group_meta: { ...existing?.tool_group_meta, ...action.payload.tool_group_meta },
};
@@ -1193,8 +1227,21 @@ const agentsSlice = createSlice({
.addCase(launchAndSendFirstMessage.fulfilled, (state, action) => {
const { draftId, session } = action.payload;
const shouldExpand = action.meta.arg.expand !== false;
+ // The swap uses the LAUNCH response (no refetch round trip), so the user's message exists only as the draft's optimistic bubble; carry it (never the seeded greeting, which is cosmetic and must not reach the server session) plus anything the WS already landed under the server id.
+ const carried = [
+ ...(state.sessions[session.id]?.messages ?? []),
+ ...(state.sessions[draftId]?.messages ?? []).filter((m) => m.optimistic_status),
+ ];
delete state.sessions[draftId];
- state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name), tool_group_meta: session.tool_group_meta ?? {}, pending_approvals: session.pending_approvals ?? [] };
+ state.sessions[session.id] = {
+ ...session,
+ name: normalizeSessionName(session.name),
+ // The first message POST is in flight; its failure path flips this back (same optimism as sendMessage.pending).
+ status: 'running',
+ messages: mergeSessionMessages(carried, session.messages, false),
+ tool_group_meta: session.tool_group_meta ?? {},
+ pending_approvals: session.pending_approvals ?? [],
+ };
state.activeSessionId = session.id;
state.draftLaunchMap[draftId] = session.id;
state.expandedSessionIds = state.expandedSessionIds.map((id) => (id === draftId ? session.id : id));
@@ -1362,35 +1409,18 @@ const agentsSlice = createSlice({
const session = action.payload;
const existing = state.sessions[session.id];
// Preserve local messages the server snapshot doesn't carry yet. On remount mid-stream (leave the chat + come back) this fetch's snapshot predates the just-sent user turn, so a blind replace wiped the user's own bubble while the assistant stream (separate slice) kept going. The WS echo clears optimistic_status the instant it arrives, so the message is usually "confirmed but not yet server-persisted" rather than still 'pending' (that's why a pending-only filter missed it). Gate on the session being LIVE: on a running/streaming session, carry forward any local message the snapshot lacks; on a settled session the snapshot is authoritative (so a server-side delete isn't resurrected).
- const incomingMsgs = session.messages ?? [];
// Live by EITHER side's account: a send on a completed chat flips local status to running while the racing snapshot still says completed and lacks the new turn; trusting only the snapshot wiped the user bubble until the run finished.
const isLive = (s?: string) => s === 'running' || s === 'waiting_approval';
// A streaming session counts as live even if neither status says 'running' (streaming lives in streamingSlice). Without this, a mid-stream reopen dropped the just-sent user bubble until the turn finished.
const streamingActive = !!(session as AgentSession & { _streamingActive?: boolean })._streamingActive;
const liveStatus = streamingActive || isLive(session.status) || isLive(existing?.status);
- const incomingClientIds = new Set(
- incomingMsgs.map((m) => m.client_message_id).filter(Boolean),
- );
- const incomingIds = new Set(incomingMsgs.map((m) => m.id));
- // An optimistic message (no WS echo yet) is preserved even when both sides read settled: right after a send on a completed chat, NEITHER status has flipped to running, and the racing snapshot wiped the just-typed bubble for seconds. It can't be a deleted-message resurrection; the server has never confirmed it existed.
- const surviving = (existing?.messages ?? []).filter(
- (m) =>
- (liveStatus || m.optimistic_status) &&
- !incomingIds.has(m.id) &&
- !(m.client_message_id && incomingClientIds.has(m.client_message_id)),
- );
- // Place survivors by timestamp, not blindly at the end: when the snapshot already carries the agent's reply, appending the just-sent user bubble rendered the OUTPUT above the INPUT. Insert before the first incoming message that is newer.
- const mergedMessages = surviving.length ? [...incomingMsgs] : incomingMsgs;
- for (const m of surviving) {
- const at = mergedMessages.findIndex((x) => (x.timestamp || '') > (m.timestamp || ''));
- if (at === -1) mergedMessages.push(m);
- else mergedMessages.splice(at, 0, m);
- }
delete (session as AgentSession & { _streamingActive?: boolean })._streamingActive;
+ // Deletes only apply on a settled session: a snapshot racing a live turn is stale, not authoritative.
+ const stableMessages = mergeSessionMessages(existing?.messages, session.messages, !liveStatus);
state.sessions[session.id] = {
...session,
name: normalizeSessionName(session.name),
- messages: mergedMessages,
+ messages: stableMessages,
pending_approvals: session.pending_approvals ?? existing?.pending_approvals ?? [],
tool_group_meta: session.tool_group_meta ?? existing?.tool_group_meta ?? {},
// mcp_suggestions live in client state only (the backend never returns them in the session payload). Preserve them across refresh so the suggestion banner stays put until the user dismisses it or activates one.
@@ -1414,13 +1444,17 @@ const agentsSlice = createSlice({
})
.addCase(fetchBrowserAgentChildren.fulfilled, (state, action) => {
for (const session of action.payload) {
- if (!state.sessions[session.id]) {
+ const existing = state.sessions[session.id];
+ if (!existing) {
state.sessions[session.id] = {
...session,
name: normalizeSessionName(session.name),
tool_group_meta: session.tool_group_meta ?? {},
pending_approvals: session.pending_approvals ?? [],
};
+ } else if (existing.messages.length === 0 && session.messages.length > 0) {
+ // Hydrate a child the trimmed session-list poll left message-less; don't touch one mid-stream (already has messages).
+ existing.messages = session.messages;
}
}
})
diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts
index e6016fc1..fed118c8 100644
--- a/frontend/src/shared/state/dashboardLayoutSlice.ts
+++ b/frontend/src/shared/state/dashboardLayoutSlice.ts
@@ -392,8 +392,18 @@ export function findOpenSpotNear(
};
}
- // Spiral by ring perimeter; right/down preference for stability.
+ // Ring order approximates distance but returns the first-in-scan cell, which flings a card to a
+ // far corner when the near cells are blocked (a big browser + expanded chats). Instead pick the
+ // cell CLOSEST to the anchor by real distance: scan outward, and once a ring yields a free cell,
+ // scan ONE more ring (a ring-r corner ~r*1.41 can lose to a ring-(r+1) edge) then take the nearest.
const MAX_RING = 32;
+ const spotDist = (col: number, row: number): number => {
+ const x = GRID_ORIGIN.x + col * cellW;
+ const y = GRID_ORIGIN.y + row * cellH;
+ return Math.hypot(x - anchorX, y - anchorY);
+ };
+ let best: { col: number; row: number; d: number } | null = null;
+ let firstHitRing = -1;
for (let r = 1; r <= MAX_RING; r++) {
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
@@ -401,14 +411,20 @@ export function findOpenSpotNear(
const col = baseCol + dx;
const row = baseRow + dy;
if (col < 0 || row < 0) continue;
- if (cellFree(col, row)) {
- return {
- x: GRID_ORIGIN.x + col * cellW,
- y: GRID_ORIGIN.y + row * cellH,
- };
- }
+ if (!cellFree(col, row)) continue;
+ const d = spotDist(col, row);
+ if (!best || d < best.d) best = { col, row, d };
}
}
+ if (best && firstHitRing === -1) firstHitRing = r;
+ // Scan one ring past the first hit (a ring-r corner can lose to a ring-(r+1) edge), then commit.
+ if (firstHitRing !== -1 && r >= firstHitRing + 1) break;
+ }
+ if (best) {
+ return {
+ x: GRID_ORIGIN.x + best.col * cellW,
+ y: GRID_ORIGIN.y + best.row * cellH,
+ };
}
// Pathological, full canvas occupied near anchor. Fall back to the global first-empty scan so we never return an overlap.
@@ -511,8 +527,14 @@ export function computeSpawnPosition(
return placeBesideCard(state, anchor.beside, newW, newH, expandedSessionIds);
}
if (anchor.viewportCenter) {
- // Land dead-center, "in front of you", even if a card is already there. Overlap is intentional (new card sits on top via its higher zOrder); dodging to free space is exactly the "spawned off to the side" behavior we're removing.
- return { x: anchor.viewportCenter.x - newW / 2, y: anchor.viewportCenter.y - newH / 2 };
+ // Closest open gap to the viewport center: dead-center-with-overlap stacked spawns invisibly on top of each other (two center spawns in a row = the second fully covers the first). The spiral stays center-biased so it still reads as "in front of you".
+ return findOpenSpotNear(
+ anchor.viewportCenter.x - newW / 2,
+ anchor.viewportCenter.y - newH / 2,
+ collectOccupiedRects(state, expandedSessionIds),
+ newW,
+ newH,
+ );
}
return findOpenGridCell(collectOccupiedRects(state, expandedSessionIds), newW, newH);
}
diff --git a/frontend/src/shared/state/mergeSessionMessages.ts b/frontend/src/shared/state/mergeSessionMessages.ts
new file mode 100644
index 00000000..96d880c6
--- /dev/null
+++ b/frontend/src/shared/state/mergeSessionMessages.ts
@@ -0,0 +1,44 @@
+import type { AgentMessage } from './agentsSlice';
+
+/** Merge a server snapshot's message list over the store's, so a stale or partial snapshot can
+ * never wipe the transcript: WS status frames replay from seq 0 on every (re)connect (the
+ * launch-time zero-message frame included), and whichever socket lands last used to blind-replace
+ * newer local state, which is how first messages and edited histories vanished.
+ *
+ * allowDeletes: only the settled-session REST fetch may honor a server-side delete; WS frames and
+ * the draft rekey never drop a local message the snapshot lacks. Optimistic messages always survive. */
+export function mergeSessionMessages(
+ existing: AgentMessage[] | undefined,
+ incoming: AgentMessage[] | undefined,
+ allowDeletes: boolean,
+): AgentMessage[] {
+ const incomingMsgs = incoming ?? [];
+ const existingMsgs = existing ?? [];
+ const incomingIds = new Set(incomingMsgs.map((m) => m.id));
+ const incomingClientIds = new Set(
+ incomingMsgs.map((m) => m.client_message_id).filter(Boolean),
+ );
+ const surviving = existingMsgs.filter(
+ (m) =>
+ (!allowDeletes || m.optimistic_status) &&
+ !incomingIds.has(m.id) &&
+ !(m.client_message_id && incomingClientIds.has(m.client_message_id)),
+ );
+ // Place survivors by timestamp, not blindly at the end: when the snapshot already carries the agent's reply, appending the just-sent user bubble rendered the OUTPUT above the INPUT.
+ const merged = surviving.length ? [...incomingMsgs] : incomingMsgs;
+ for (const m of surviving) {
+ const at = merged.findIndex((x) => (x.timestamp || '') > (m.timestamp || ''));
+ if (at === -1) merged.push(m);
+ else merged.splice(at, 0, m);
+ }
+ // Keep the EXISTING object for any message the snapshot didn't change: fresh JSON clones of identical messages break every bubble's React.memo (a whole-transcript re-render hitch per frame).
+ const prevById = new Map(existingMsgs.map((m) => [m.id, m]));
+ const contentUnchanged = (a: AgentMessage, b: AgentMessage): boolean =>
+ typeof a.content === 'string' && typeof b.content === 'string'
+ ? a.content === b.content
+ : Array.isArray(a.content) && Array.isArray(b.content) && a.content.length === b.content.length;
+ return merged.map((m) => {
+ const prev = prevById.get(m.id);
+ return prev && prev.timestamp === m.timestamp && prev.role === m.role && contentUnchanged(prev, m) ? prev : m;
+ });
+}
diff --git a/frontend/src/shared/state/sessionDisplay.ts b/frontend/src/shared/state/sessionDisplay.ts
index 1ecc7db7..c68c5af3 100644
--- a/frontend/src/shared/state/sessionDisplay.ts
+++ b/frontend/src/shared/state/sessionDisplay.ts
@@ -36,8 +36,13 @@ export function displayChatTitle(session: AgentSession | null | undefined): stri
return session.name;
}
const firstUserMsg = session.messages?.find((m) => m.role === 'user');
- if (firstUserMsg && typeof firstUserMsg.content === 'string') {
- const truncated = truncateForTitle(firstUserMsg.content);
+ const firstUserContent = firstUserMsg && typeof firstUserMsg.content === 'string'
+ ? firstUserMsg.content
+ : session.messages.length === 0
+ ? session.first_user_message
+ : undefined;
+ if (firstUserContent) {
+ const truncated = truncateForTitle(firstUserContent);
if (truncated) return truncated;
}
return session.mode === 'view-builder' ? 'Untitled App' : SESSION_NAME_PLACEHOLDER;
diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts
index a2d09eea..445e0250 100644
--- a/frontend/src/shared/state/workflowsSlice.ts
+++ b/frontend/src/shared/state/workflowsSlice.ts
@@ -69,6 +69,8 @@ export interface Workflow {
deleted_at?: string | null;
system_prompt: string | null;
use_synced_prompt: boolean;
+ /** Agents may run this workflow via the InvokeWorkflow tool (opt-in per workflow on the Actions page). */
+ exposed_as_tool?: boolean;
steps: WorkflowStep[];
actions: ActionsConfig;
schedule: ScheduleConfig;
diff --git a/frontend/src/shared/viewFrameRegistry.ts b/frontend/src/shared/viewFrameRegistry.ts
new file mode 100644
index 00000000..8cf228cb
--- /dev/null
+++ b/frontend/src/shared/viewFrameRegistry.ts
@@ -0,0 +1,14 @@
+// Srcdoc app-card iframes keyed by card key. Mirror of viewWebviewRegistry for the outputs that render as an iframe instead of a (no serve URL): the dashboard's arrow-key handler needs a handle on the card's content to scroll it, and a srcdoc frame is same-origin, so no IPC is involved.
+const registry = new Map();
+
+export function registerViewFrame(cardKey: string, frame: HTMLIFrameElement): void {
+ registry.set(cardKey, frame);
+}
+
+export function unregisterViewFrame(cardKey: string): void {
+ registry.delete(cardKey);
+}
+
+export function getViewFrame(cardKey: string): HTMLIFrameElement | undefined {
+ return registry.get(cardKey);
+}
diff --git a/frontend/src/shared/viewWebviewRegistry.ts b/frontend/src/shared/viewWebviewRegistry.ts
index 2f6d0e23..866825e0 100644
--- a/frontend/src/shared/viewWebviewRegistry.ts
+++ b/frontend/src/shared/viewWebviewRegistry.ts
@@ -1,6 +1,8 @@
// Live app-card preview webviews keyed by output id. The delete path looks a card's up here to quiesce its GPU surface BEFORE React rips the element out; without it, deleting a couple of large app cards at once tears down several live SharedImage surfaces in one frame, which piles up "non-existent mailbox" errors and kills the GPU process (taking the whole app down with no dump). Mirror of browserRegistry, for the non-CDP preview webviews.
export interface ViewWebview extends HTMLElement {
loadURL: (url: string) => Promise;
+ // Optional: present on real Electron webviews, absent on any non-Electron stand-in, so callers must ?.() it.
+ executeJavaScript?: (code: string) => Promise;
}
const registry = new Map();
diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts
index e360ef50..320262f0 100644
--- a/frontend/src/shared/ws/WebSocketManager.ts
+++ b/frontend/src/shared/ws/WebSocketManager.ts
@@ -28,7 +28,7 @@ import {
clearTurnLabel,
} from '../state/agentsSlice';
import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice';
-import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, placeBrowserBesideChat, setBrowserCardPosition, setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice';
+import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, placeBrowserBesideChat, setBrowserCardPosition, setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeBrowserCard, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice';
import { upsertOutput } from '../state/outputsSlice';
import { fetchSettings } from '../state/settingsSlice';
import { displaySessionName } from '../state/sessionDisplay';
@@ -324,6 +324,20 @@ class WebSocketManager {
}
}
+ // Cross-socket dedupe: the backend fans every session frame out to BOTH the dashboard socket and the chat's own socket (same stamped seq), so an expanded chat parsed and reduced everything twice, and whichever copy landed second could be a replayed stale one. Time-windowed rather than a high-water mark so a deliberate later replay (gap recovery resets lastSeq to 0) is never starved.
+ if (typeof msg.seq === 'number' && session_id) {
+ const key = `${session_id}:${msg.seq}`;
+ const now = Date.now();
+ const seen = _recentFrameTimes.get(key);
+ if (seen !== undefined && now - seen < FRAME_DEDUPE_WINDOW_MS) return;
+ _recentFrameTimes.set(key, now);
+ if (_recentFrameTimes.size > 4000) {
+ for (const [k, t] of _recentFrameTimes) {
+ if (now - t >= FRAME_DEDUPE_WINDOW_MS) _recentFrameTimes.delete(k);
+ }
+ }
+ }
+
// ----- Connection-scoped frames (no business-logic side effects) -----
if (event === 'server:pong') {
@@ -351,6 +365,10 @@ class WebSocketManager {
// Reset lastSeq, the REST refetch is the new authoritative baseline; subsequent server events with seq numbers will re-establish the high-water mark. Also wipe the cross-mount persistent map so a remount during this gap window doesn't resurrect the stale value.
this.lastSeq = 0;
_sessionLastSeq.delete(session_id);
+ // The recovery replay re-delivers seqs possibly seen moments ago; drop them from the dedupe window so it's never starved.
+ for (const k of _recentFrameTimes.keys()) {
+ if (k.startsWith(`${session_id}:`)) _recentFrameTimes.delete(k);
+ }
}
return;
}
@@ -384,6 +402,17 @@ class WebSocketManager {
store.dispatch(trackAgentNotification(session_id));
}
+ // An AppAgent driving an app card announces itself only via this status event (no card_added like browsers), so light the app card here. Keyed by the parent chat like browser glows, so the same terminal fade below clears it.
+ const p_sess = data.session;
+ if (p_sess && p_sess.mode === 'browser-agent' && typeof p_sess.browser_id === 'string' && p_sess.browser_id.startsWith('app:')
+ && (p_sess.status === 'running' || p_sess.status === 'waiting_approval')) {
+ store.dispatch(setGlowingBrowserCards({
+ browserIds: [p_sess.browser_id],
+ sessionId: p_sess.parent_session_id || p_sess.id,
+ label: 'Use App',
+ }));
+ }
+
// Fade this session's browser glows on the terminal transition HERE, not only in AgentChat's effect: a collapsed chat is unmounted at finish, and a never-faded glow pins the browser's renderer (exempt from suspend + the webview cap) forever.
const newStatus = data.status ?? data.session?.status;
const wasWorking = prevStatus === 'running' || prevStatus === 'waiting_approval';
@@ -791,6 +820,13 @@ class WebSocketManager {
}
break;
+ case 'dashboard:browser_card_evict':
+ // A wedged card the backend is tearing down BEFORE it spawns a recovery card. Remove it now (no fade, no Keep pill) so its unmounts and stops starving the renderer while the fresh card mounts.
+ if (data.browser_id) {
+ store.dispatch(removeBrowserCard(data.browser_id));
+ }
+ break;
+
case 'dashboard:browser_card_added':
if (data.browser_card) {
// Tag with origin dashboard so the card renders only on the dashboard that spawned it, without this, a browser spawned by an agent on dashboard A leaks into whatever dashboard the user is currently viewing (the global browserCards dict + unfiltered render).
@@ -929,6 +965,17 @@ export const dashboardWs = new WebSocketManager(`${WS_BASE}/ws/dashboard`, { ski
// Per-session high-water mark for the resume protocol. Survives across AgentChat mounts/unmounts so reopening a chat doesn't re-trigger a full replay from the server's ring buffer. Why this exists: AgentChat uses `key={session.id}` on the embedded instance inside AgentCard, so every expand/collapse remounts the component, which constructs a fresh WebSocketManager. Without this persistent map, each fresh manager starts at last_seq=0 and asks the server for the entire buffered history. The server faithfully replays it, the client renders the typewriter animation again, and the user sees their completed chat "type itself out" on every reopen. Lifetime: tied to the JS module load, which means the page tab. Lost on full app reload (intentional, that should re-hydrate from REST). On backend restart the buffers are wiped anyway, so a stale lastSeq pointing past the buffer top falls into the "fresh client" path on the server (last_seq>0 but no buffer) which short-circuits to a no-op replay. Safe.
const _sessionLastSeq: Map = new Map();
+// (session_id:seq) -> arrival time; entries older than the window are prunable. Bounded by event rate x window, not session count.
+const FRAME_DEDUPE_WINDOW_MS = 5_000;
+const _recentFrameTimes: Map = new Map();
+
+/** Seed the resume cursor from a REST hydrate (GET /sessions returns event_seq), so the follow-up WS connect replays only what happened AFTER the snapshot instead of the whole ring buffer the client just received as JSON. Never lowers an existing high-water mark. */
+export function seedSessionSeq(sessionId: string, seq: number): void {
+ if (typeof seq !== 'number' || seq <= 0) return;
+ const cur = _sessionLastSeq.get(sessionId) ?? 0;
+ if (seq > cur) _sessionLastSeq.set(sessionId, seq);
+}
+
export function createSessionWs(sessionId: string): WebSocketManager {
return new WebSocketManager(`${WS_BASE}/ws/agents/${sessionId}`, { sessionId });
}
diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js
index 1e051e72..3556793b 100644
--- a/frontend/webpack.config.js
+++ b/frontend/webpack.config.js
@@ -79,13 +79,14 @@ module.exports = (env, argv) => {
devServer: {
static: { directory: path.join(__dirname, 'public') },
compress: true,
- port: 3000,
+ // Dev only: OPENSWARM_DEV_PORT / OPENSWARM_PORT let a second worktree run its own stack without colliding on 3000/8324 (electron reads the same var names).
+ port: Number(process.env.OPENSWARM_DEV_PORT) || 3000,
hot: true,
open: false,
historyApiFallback: true,
proxy: {
'/api': {
- target: 'http://localhost:8324',
+ target: `http://localhost:${process.env.OPENSWARM_PORT || 8324}`,
changeOrigin: true,
},
},
diff --git a/run.ps1 b/run.ps1
index b578ed5e..5ea0fb36 100644
--- a/run.ps1
+++ b/run.ps1
@@ -99,6 +99,12 @@ function Cleanup-All {
Write-Host "All services stopped." -ForegroundColor Green
}
+# Opt-in dev telemetry: OPENSWARM_ANALYTICS=1 reports to the prod edge tagged with a -dev channel so it stays filterable from real installs; off by default so a stray run never phones home.
+if ($env:OPENSWARM_ANALYTICS -eq '1') {
+ if (-not $env:OPENSWARM_ANALYTICS_URL) { $env:OPENSWARM_ANALYTICS_URL = 'https://analytics.openswarm.com' }
+ if (-not $env:OPENSWARM_APP_CHANNEL) { $env:OPENSWARM_APP_CHANNEL = 'dev' }
+}
+
try {
# --- Start backend (NoNewWindow so logs interleave into this terminal) ---
# No --reload on Windows: uvicorn's reload mode forces use_subprocess=True
diff --git a/run.sh b/run.sh
index 1059bc8e..987afa4e 100755
--- a/run.sh
+++ b/run.sh
@@ -22,6 +22,14 @@ FRONTEND_PID=""
ELECTRON_PID=""
SHUTTING_DOWN=false
+# Dev ports. Override BOTH to run a second worktree in parallel without colliding on 3000/8324
+# (e.g. OPENSWARM_PORT=8425 OPENSWARM_DEV_PORT=3005 bash run.sh). Electron, backend/run.sh, and
+# webpack all read these same names, so one export each wires the whole stack.
+BACKEND_PORT="${OPENSWARM_PORT:-8324}"
+FRONTEND_PORT="${OPENSWARM_DEV_PORT:-3000}"
+export OPENSWARM_PORT="$BACKEND_PORT"
+export OPENSWARM_DEV_PORT="$FRONTEND_PORT"
+
kill_tree() {
local pid=$1 sig=${2:-TERM}
local children
@@ -91,9 +99,9 @@ fi
# That makes the next `bash run.sh` fail with Errno 48 "Address already
# in use" and leaves the user thinking the dev loop is broken. Free the
# port up front instead of asking the user to debug.
-if lsof -ti :8324 >/dev/null 2>&1; then
- echo -e "${YELLOW}${BOLD}[preflight]${RESET} Port 8324 still bound from a prior run — killing stale process..."
- lsof -ti :8324 | xargs kill -9 2>/dev/null || true
+if lsof -ti :$BACKEND_PORT >/dev/null 2>&1; then
+ echo -e "${YELLOW}${BOLD}[preflight]${RESET} Port ${BACKEND_PORT} still bound from a prior run, killing stale process..."
+ lsof -ti :$BACKEND_PORT | xargs kill -9 2>/dev/null || true
sleep 0.3
fi
@@ -103,6 +111,13 @@ fi
# directly), so the env stays unset in production and uvicorn boots in
# its leaner non-reload mode.
export OPENSWARM_DEV=1
+
+# Opt-in dev telemetry. Dev normally never reports (the analytics client falls back to a dead local ingest); set OPENSWARM_ANALYTICS=1 (e.g. a hackathon cohort) to report to the prod edge, tagged with a -dev channel so those events stay filterable from real installs. Off by default so a stray `bash run.sh` never phones home.
+if [ "${OPENSWARM_ANALYTICS:-0}" = "1" ]; then
+ export OPENSWARM_ANALYTICS_URL="${OPENSWARM_ANALYTICS_URL:-https://analytics.openswarm.com}"
+ export OPENSWARM_APP_CHANNEL="${OPENSWARM_APP_CHANNEL:-dev}"
+fi
+
echo -e "${BLUE}${BOLD}[backend]${RESET} Starting backend server..."
bash "$PROJECT_ROOT/backend/run.sh" > >(
while IFS= read -r line; do
@@ -112,11 +127,11 @@ bash "$PROJECT_ROOT/backend/run.sh" > >(
BACKEND_PID=$!
# --- Wait for backend to become healthy ---
-echo -e "${YELLOW}${BOLD}Waiting for backend (http://localhost:8324) to be ready...${RESET}"
+echo -e "${YELLOW}${BOLD}Waiting for backend (http://localhost:${BACKEND_PORT}) to be ready...${RESET}"
MAX_WAIT=120
elapsed=0
while (( elapsed < MAX_WAIT )); do
- if curl -s -o /dev/null --connect-timeout 1 http://localhost:8324/ 2>/dev/null; then
+ if curl -s -o /dev/null --connect-timeout 1 http://localhost:${BACKEND_PORT}/ 2>/dev/null; then
echo -e "${GREEN}${BOLD}Backend is ready!${RESET}"
break
fi
@@ -143,11 +158,11 @@ bash "$PROJECT_ROOT/frontend/run.sh" > >(
FRONTEND_PID=$!
# --- Wait for frontend dev server to become available ---
-echo -e "${YELLOW}${BOLD}Waiting for frontend (http://localhost:3000) to be ready...${RESET}"
+echo -e "${YELLOW}${BOLD}Waiting for frontend (http://localhost:${FRONTEND_PORT}) to be ready...${RESET}"
FRONTEND_MAX_WAIT=60
frontend_elapsed=0
while (( frontend_elapsed < FRONTEND_MAX_WAIT )); do
- if curl -s -o /dev/null --connect-timeout 1 http://localhost:3000/ 2>/dev/null; then
+ if curl -s -o /dev/null --connect-timeout 1 http://localhost:${FRONTEND_PORT}/ 2>/dev/null; then
echo -e "${GREEN}${BOLD}Frontend is ready!${RESET}"
break
fi
@@ -192,8 +207,8 @@ ELECTRON_PID=$!
echo ""
echo -e "${BOLD}All services are running. Press Ctrl+C to stop.${RESET}"
-echo -e " Backend: ${BLUE}http://localhost:8324${RESET}"
-echo -e " Frontend: ${GREEN}http://localhost:3000${RESET}"
+echo -e " Backend: ${BLUE}http://localhost:${BACKEND_PORT}${RESET}"
+echo -e " Frontend: ${GREEN}http://localhost:${FRONTEND_PORT}${RESET}"
echo -e " Electron: ${MAGENTA}dev shell (pid $ELECTRON_PID)${RESET}"
echo ""