defluff (frontend + backend): strip em-dashes + shorten docstrings + drop dead UI files (cosmetic only, no schedule code)

This commit is contained in:
ciregenz
2026-05-20 05:36:17 -07:00
parent 5b0c6e1df3
commit f59bf0db9b
118 changed files with 853 additions and 4202 deletions
+12 -47
View File
@@ -137,9 +137,7 @@ async function handleType(wv: BrowserWebview, params: Record<string, any>): Prom
return result;
}
// Map common JS KeyboardEvent.key values to Electron's accelerator keyCodes.
// Electron's sendInputEvent expects: 'Up', 'Down', 'Left', 'Right', 'Enter',
// 'Escape', 'Tab', 'Backspace', 'Delete', 'Space', or single char letters.
// Electron sendInputEvent expects names like 'Up', 'Enter', 'Space', not 'ArrowUp'/' '/'Esc'.
const KEY_NAME_MAP: Record<string, string> = {
ArrowUp: 'Up',
ArrowDown: 'Down',
@@ -155,30 +153,15 @@ async function handlePressKey(wv: BrowserWebview, params: Record<string, any>):
const rawKey = (params.key as string) || '';
if (!rawKey) return { error: 'key parameter is required' };
const keyCode = KEY_NAME_MAP[rawKey] || rawKey;
// Focus the page first so the key event has a sensible target.
await wv.executeJavaScript('document.body && document.body.focus && document.body.focus(); true');
// Native OS-level key events — these have event.isTrusted === true so site
// keyboard handlers (Tinder, Slack, Notion, etc.) actually respect them.
// 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 });
return { text: `Pressed ${rawKey}` };
}
// ---------------------------------------------------------------------------
// CDP accessibility-tree element indexing
// ---------------------------------------------------------------------------
// list_interactives uses Chrome DevTools Protocol's Accessibility.getFullAXTree
// to get the *computed* accessibility tree, not the raw DOM. This sees roles,
// names, and labels even on hostile sites (Tinder, Instagram) where the raw
// HTML is just unlabeled <div>s with click handlers — because Chromium computes
// accessible names for screen readers from icons, surrounding text, etc.
//
// Each interactive element is assigned a numeric index. The index → backendNodeId
// map is cached server-side per webContents and used by click_index. This is
// orders of magnitude more reliable than CSS-selector-based clicking on sites
// that don't expose semantic markup.
// CDP Accessibility.getFullAXTree sees computed roles/names even on hostile sites with unlabeled DOMs.
const INTERACTIVE_ROLES = new Set([
'button', 'link', 'textbox', 'combobox', 'checkbox', 'menuitem',
'tab', 'switch', 'searchbox', 'slider', 'listbox', 'option',
@@ -211,7 +194,7 @@ async function sendCdp(wv: BrowserWebview, method: string, params?: Record<strin
const bridge = (window as any).openswarm?.sendCdpCommand as
| ((id: number, m: string, p?: any) => Promise<CdpResult>)
| undefined;
if (!bridge) throw new Error('CDP bridge not available restart the app');
if (!bridge) throw new Error('CDP bridge not available, restart the app');
const resp = await bridge(wcId, method, params);
if (!resp || !resp.ok) {
throw new Error(resp?.error || `CDP ${method} failed`);
@@ -237,7 +220,6 @@ async function handleListInteractives(wv: BrowserWebview): Promise<Record<string
if (!INTERACTIVE_ROLES.has(role)) continue;
const name = extractAxValue(node.name);
if (!name && role !== 'textbox' && role !== 'searchbox' && role !== 'combobox') {
// Skip nameless elements unless they're inputs (which can be empty)
continue;
}
const backendNodeId = node.backendDOMNodeId;
@@ -246,8 +228,7 @@ async function handleListInteractives(wv: BrowserWebview): Promise<Record<string
index++;
}
// Cache the index map in main-process storage so click_index can resolve it
// even across separate WebSocket commands.
// Cache in main-process so click_index can resolve across separate WS commands.
const indexMap: Record<number, number> = {};
for (const el of interactives) {
indexMap[el.index] = el.backendNodeId;
@@ -256,10 +237,9 @@ async function handleListInteractives(wv: BrowserWebview): Promise<Record<string
const cacheBridge = (window as any).openswarm?.cdpCacheSet;
if (cacheBridge) await cacheBridge(wv.getWebContentsId(), indexMap);
} catch {
// Cache is best-effort; click_index will fall back to re-listing.
// best-effort; click_index falls back to re-listing.
}
// Build the model-friendly text representation: [1]<button "Like">
const lines = interactives.map(
(el) => `[${el.index}]<${el.role} "${el.name}">`,
);
@@ -280,7 +260,6 @@ async function handleClickIndex(wv: BrowserWebview, params: Record<string, any>)
return { error: 'index parameter is required and must be a positive integer' };
}
// Look up the cached index → backendNodeId mapping.
let backendNodeId: number | undefined;
try {
const cacheBridge = (window as any).openswarm?.cdpCacheGet;
@@ -300,9 +279,7 @@ async function handleClickIndex(wv: BrowserWebview, params: Record<string, any>)
};
}
// Cheap revalidation: resolve the backend node ID to a runtime object.
// If the page has mutated and the node is gone, this fails fast with a
// clear error message instead of clicking the wrong element.
// Revalidate: fails fast if the page mutated and the node is gone (vs. clicking the wrong element).
try {
await sendCdp(wv, 'DOM.resolveNode', { backendNodeId });
} catch (err: any) {
@@ -311,9 +288,7 @@ async function handleClickIndex(wv: BrowserWebview, params: Record<string, any>)
};
}
// Get the element's bounding box for clicking via Input.dispatchMouseEvent
// (more reliable than Element.click() on hostile sites — bypasses any
// synthetic-event filtering since these are real OS-level mouse events).
// Input.dispatchMouseEvent (OS-level) bypasses synthetic-event filtering on hostile sites.
let boxModel;
try {
boxModel = await sendCdp(wv, 'DOM.getBoxModel', { backendNodeId });
@@ -327,7 +302,7 @@ async function handleClickIndex(wv: BrowserWebview, params: Record<string, any>)
if (!Array.isArray(content) || content.length < 8) {
return { error: `Index ${idx} has no valid bounding rect.` };
}
// content is [x1,y1, x2,y2, x3,y3, x4,y4] compute center
// content is [x1,y1, x2,y2, x3,y3, x4,y4]; compute center
const x = (content[0] + content[4]) / 2;
const y = (content[1] + content[5]) / 2;
@@ -355,16 +330,7 @@ async function handleClickIndex(wv: BrowserWebview, params: Record<string, any>)
};
}
// ---------------------------------------------------------------------------
// Batched actions
// ---------------------------------------------------------------------------
// handleBatch executes a list of sub-actions sequentially on the same webview,
// capturing the URL before/after each one and aborting the rest of the batch
// if the URL changes mid-batch (page navigated → indices and selectors are
// stale). This lets the model emit "[click_index 7, wait 500, type 'eric',
// press_key Enter]" in a single tool call instead of round-tripping for each
// action.
// Sequential sub-actions; aborts mid-batch if URL changes (indices/selectors go stale on navigation).
const MAX_BATCH_ACTIONS = 5;
type SubActionType =
@@ -403,7 +369,7 @@ async function handleBatch(wv: BrowserWebview, params: Record<string, any>): Pro
if (!subType || !(subType in BATCH_DISPATCH)) {
results.push({ index: i, type: subType, error: `Unknown sub-action type: ${subType}` });
// Continue with the rest — per-action failures don't abort the batch.
// per-action failures don't abort the batch
continue;
}
@@ -416,8 +382,7 @@ async function handleBatch(wv: BrowserWebview, params: Record<string, any>): Pro
}
results.push({ index: i, type: subType, ...subResult });
// If the URL changed, abort the rest — selectors and indices are stale
// and any subsequent actions would be operating on a half-loaded page.
// URL changed: selectors and indices are stale on the half-loaded page; abort.
const urlAfter = wv.getURL();
if (urlAfter !== urlBefore && i < actions.length - 1) {
aborted_at = i + 1;
+2 -13
View File
@@ -1,13 +1,4 @@
// Plain-JS shared ref (NOT React state) for "is the user currently
// interacting with the canvas" (pan/drag/wheel/zoom). Read on hot paths
// like AgentCard's ResizeObserver to suppress expensive work during the
// gesture. Setting/clearing the ref does NOT trigger any React re-renders.
//
// Why this pattern instead of Redux or context: ResizeObserver callbacks
// fire dozens of times per second during streaming. We want them to bail
// in O(1) without a subscription that itself has overhead. A module-level
// mutable holder + a one-shot "interaction ended" event meets both.
// Module-level ref (not React state) so ResizeObservers can bail O(1) without subscription overhead.
let _isPanning = false;
const listeners: Set<() => void> = new Set();
@@ -20,9 +11,7 @@ export function setCanvasInteractionActive(active: boolean) {
if (_isPanning === active) return;
const wasActive = _isPanning;
_isPanning = active;
// Fire the end-of-interaction notification so listeners can flush work
// that was suppressed during the gesture (re-measure heights, dispatch
// pending state updates, etc.).
// End-of-interaction: flush work suppressed during the gesture (re-measure, dispatch, etc.).
if (wasActive && !active) {
for (const fn of listeners) {
try { fn(); } catch (e) { console.warn('[canvas-interaction] listener threw', e); }
+6 -40
View File
@@ -3,18 +3,10 @@ const host = window.location.hostname || 'localhost';
export const API_BASE = `http://${host}:${port}/api`;
export const WS_BASE = `ws://${host}:${port}`;
// Must match openswarm-cloud's PUBLIC_BASE_URL (fly.toml) and the redirect
// URI registered on the Google OAuth client. The historical `.ai` value
// resolved to NXDOMAIN — fine while no frontend caller used it directly,
// but the v1.0.29 sign-in gate is the first frontend caller that
// constructs URLs from this constant, so the typo had to go.
// Must match openswarm-cloud's PUBLIC_BASE_URL (fly.toml) and the Google OAuth redirect URI.
export const OPENSWARM_DEFAULT_PROXY_URL = 'https://api.openswarm.com';
// Per-install auth token. Fetched from Electron's main process via the
// preload contextBridge. We cache it after first resolution so every
// API/WS call is synchronous. On Electron hot-reload the token rotates;
// call `refreshAuthToken()` from a 4401 WS handler to pick up a new
// one without a full page reload.
// Per-install token from Electron preload; cached after first resolve. Call refreshAuthToken() on 4401.
let _authTokenCache: string = '';
let _authTokenPromise: Promise<string> | null = null;
@@ -35,33 +27,15 @@ export async function refreshAuthToken(): Promise<string> {
return _authTokenCache;
}
// Resolve-once helper: the first call kicks off the IPC request; any
// concurrent calls reuse the same promise. Frontend bootstrap awaits
// this before the first API call so the token is ready.
/** Resolve auth token once; concurrent callers share the same promise. */
export function ensureAuthToken(): Promise<string> {
if (_authTokenPromise) return _authTokenPromise;
_authTokenPromise = refreshAuthToken();
return _authTokenPromise;
}
// Install a global fetch interceptor so every fetch(API_BASE + ...)
// call site gets the Authorization header without touching each site.
// Covers the analytics, settings, agents, dashboards, etc. fetches.
// Only applies to requests that target our own API_BASE — pass-through
// for every other URL (3rd-party APIs, asset CDNs, etc.).
//
// Layered on top of the auth-injection: a tiny in-flight dedupe + 1s
// success cache for GETs. The onboarding flow + dashboard load fire the
// same `GET /api/agents/sessions/<id>` / `GET /api/skills/list` /
// `GET /api/skills/workspace/<id>` two-to-five times in quick
// succession when components mount near-simultaneously — without
// dedupe we paid a full roundtrip every time. With this in place the
// second-through-Nth call inside a 1 s window either piggybacks on
// the in-flight promise OR reads a freshly-cached Response. Cache is
// keyed by `METHOD URL`, scoped to GET only (mutations always fall
// through), and a Response.clone() per consumer keeps each caller's
// body stream independent. Non-2xx responses are NOT cached so a
// transient 5xx can't poison the next click.
// Global fetch interceptor: attaches bearer for our API + dedupes/caches GETs in a 1s window.
// Cache is keyed `METHOD URL`, GET-only (mutations pass through); non-2xx never cached.
const _inflightFetches = new Map<string, Promise<Response>>();
const _cachedFetches = new Map<string, { resp: Response; expiresAt: number }>();
const _GET_CACHE_TTL_MS = 1000;
@@ -74,11 +48,9 @@ function _installAuthFetchInterceptor() {
window.fetch = async function patchedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
try {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
// Only attach token for our own API. Everything else flows through.
const isOurApi = url.startsWith(API_BASE) || url.startsWith(`http://${host}:${port}/`);
if (!isOurApi) return originalFetch(input, init);
// Don't override an explicit Authorization the caller already set.
const existingHeaders = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
const callerSetAuth = existingHeaders.has('Authorization') || existingHeaders.has('authorization');
@@ -96,9 +68,7 @@ function _installAuthFetchInterceptor() {
?? (input instanceof Request ? input.method : 'GET')
).toUpperCase();
// Only GET is safe to dedupe + cache. POST/PUT/PATCH/DELETE have
// side effects — collapsing two intentional calls (e.g. user
// double-clicked Send) would be wrong, so we always pass through.
// Only GET is safe to dedupe/cache; mutations could collapse intentional double-clicks.
if (method !== 'GET') {
return originalFetch(input, finalInit);
}
@@ -140,9 +110,5 @@ function _installAuthFetchInterceptor() {
};
}
// Call immediately on module load — config.ts is imported by the main
// entry point, so this runs before any component-level fetch.
_installAuthFetchInterceptor();
// Kick off token resolution in the background so it's warm by the
// time the first request goes out.
ensureAuthToken();
@@ -1,17 +1,6 @@
import React, { createContext, useContext } from 'react';
/**
* React context that signals whether the Dashboard is currently the active
* route (i.e. visible to the user) vs hidden in the background.
*
* Defaults to `true` so any standalone usage of dashboard children outside
* the DashboardHost wrapper just behaves normally.
*
* Heavy/expensive Dashboard children read this via `useDashboardActive()`
* and short-circuit their work when the dashboard is hidden — that's how
* we keep CPU usage near-zero while the user is on /actions or /settings
* with the Dashboard mounted but invisible.
*/
/** True when Dashboard is the visible route; heavy children short-circuit when false. */
const DashboardActiveContext = createContext<boolean>(true);
export const DashboardActiveProvider = DashboardActiveContext.Provider;
+5 -25
View File
@@ -6,30 +6,17 @@ import { fetchTools } from '@/shared/state/toolsSlice';
import { API_BASE } from '@/shared/config';
import { report } from '@/shared/serviceClient';
// Listens for openswarm://auth?token=...&plan=...&expires=... URLs coming
// from the Electron main process via window.openswarm.onAuthUrl. Parses the
// payload and dispatches activateSubscription so the backend validates and
// persists the bearer.
//
// Safe no-op in web/browser contexts where window.openswarm isn't defined.
/** Subscribe to openswarm:// auth/oauth deep-links from Electron main; no-op in browser. */
export function useDeepLink(): void {
const dispatch = useAppDispatch();
useEffect(() => {
const api = (window as any).openswarm as OpenSwarmAPI | undefined;
// Both listeners are optional — useDeepLink no-ops in browser/web context
// where window.openswarm is undefined.
if (!api) return;
const unsubscribe = api.onAuthUrl?.((rawUrl: string) => {
try {
// openswarm://auth?token=... (host = "auth", search carries fields).
// Two flavors land here, distinguished by the `signin` flag:
// - signin=true → free-tier sign-in (Google OAuth / magic link)
// - (default) → Stripe checkout subscription activation
// Note: the bearer-handoff page in lib/authMint.ts (cloud) POSTs
// directly to localhost so this deep-link path is currently a
// backstop for older flows. Both branches here remain wired up.
// openswarm://auth?token=... ; signin=true => free sign-in, else Stripe activation.
const url = new URL(rawUrl);
if (url.host !== 'auth' && url.pathname !== '//auth' && url.pathname !== '/auth') {
console.warn('[deep-link] Unknown openswarm:// host:', url.host);
@@ -47,9 +34,7 @@ export function useDeepLink(): void {
const expires = url.searchParams.get('expires');
if (isSignin) {
// v1.0.29 only supports Google sign-in. signinMethodRaw is read
// for forward compatibility / analytics if other methods are
// added later.
// 1.0.29 only ships Google sign-in; read for forward compat.
void signinMethodRaw;
report('signin', 'deep_link_received', { method: 'google' });
@@ -82,8 +67,7 @@ export function useDeepLink(): void {
.unwrap()
.then((res) => {
report('subscription', 'activated', { plan: res.plan });
// Re-fetch the model list so the Claude models (via OpenSwarm
// Pro proxy) show up in the chat picker right away.
// Refresh models so Pro-proxy Claude models appear in the picker immediately.
dispatch(fetchModels());
})
.catch((err) => {
@@ -97,15 +81,12 @@ export function useDeepLink(): void {
}
});
// OAuth claim deep-link listener. The Electron main process routes
// openswarm://oauth/{provider}/complete to its own IPC channel so we
// can claim tokens immediately rather than routing through Settings.
let unsubscribeOauth: (() => void) | undefined;
if (api?.onOauthClaim) {
unsubscribeOauth = api.onOauthClaim(async (rawUrl: string) => {
try {
// openswarm://oauth/{provider}/complete?session_id=...&tool_id=...
const url = new URL(rawUrl);
// Expected: openswarm://oauth/{provider}/complete?session_id=...&tool_id=...
if (url.host !== 'oauth' || !url.pathname.endsWith('/complete')) {
console.warn('[deep-link] Unexpected oauth-claim URL:', rawUrl);
return;
@@ -131,7 +112,6 @@ export function useDeepLink(): void {
return;
}
report('oauth', 'claim_succeeded');
// Refresh tools so the UI reflects the newly-connected tool.
dispatch(fetchTools());
} catch (e) {
console.error('[deep-link] OAuth claim threw:', e);
@@ -1,10 +1,4 @@
// Mounts a single global listener that records each user interaction
// timestamp into Redux. One installer per app — call from Main.tsx after
// the store is provided.
//
// Debounces at 1-second granularity so we don't spam Redux on every
// keystroke. Coarse enough for "idle dim after N minutes" UX; fine enough
// that the timestamp on session close is accurate to the second.
// Records user-interaction timestamps into Redux, 1s-debounced. Mount once from Main.tsx.
import { useEffect } from 'react';
import { useAppDispatch } from '@/shared/hooks';
@@ -4,23 +4,7 @@ import { useLocation } from 'react-router-dom';
const STORAGE_KEY = 'openswarm_last_dashboard_id';
const WINDOW_KEY = '__openswarm_last_dashboard_id';
/**
* Tracks the last visited dashboard id in a "sticky" way: once a dashboard
* has been visited, the id stays set even when the user navigates to other
* routes. This is the foundation for keeping the Dashboard component mounted
* across non-dashboard route navigation (hide-don't-unmount pattern).
*
* The Dashboard component reads its dashboardId from this hook (via a prop
* passed by AppShell) instead of from `useParams()`, so the id never goes
* undefined when the URL changes to /actions etc. This prevents the
* dashboardId useEffect from re-firing on every incidental route change,
* which would cause `resetLayout` + `fetchLayout` and visibly reload the
* browser cards.
*
* Returns a tuple of `[lastDashboardId, setLastDashboardId]`. The setter
* is exposed so explicit dashboard close/delete handlers can clear it
* (which causes the Dashboard to fully unmount and tear down its webviews).
*/
/** Sticky last-visited dashboard id so Dashboard stays mounted across non-dashboard nav. */
export function useLastDashboardId(): [string | null, (id: string | null) => void] {
const location = useLocation();
const [lastId, setLastIdState] = useState<string | null>(() => {
@@ -31,8 +15,7 @@ export function useLastDashboardId(): [string | null, (id: string | null) => voi
}
});
// Watch the URL — when it matches /dashboard/:id, update the sticky id.
// Critically: do NOT clear the sticky id when the URL stops matching.
// Watch URL; update sticky id on /dashboard/:id. Do NOT clear when URL stops matching.
useEffect(() => {
const match = location.pathname.match(/^\/dashboard\/([^/]+)/);
if (match && match[1] && match[1] !== lastId) {
+2 -21
View File
@@ -5,7 +5,6 @@ const QUERY = '(prefers-reduced-motion: reduce)';
function subscribe(callback: () => void): () => void {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(QUERY);
// Modern + legacy event names both supported.
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
}
@@ -19,30 +18,12 @@ function getServerSnapshot(): boolean {
return false;
}
/**
* True when the OS-level "Reduce motion" preference is on.
* Mac: System Settings → Accessibility → Display → Reduce Motion.
* Windows: Settings → Ease of Access → Display → Show animations.
*
* Reactive — flips immediately if the user toggles the OS setting
* mid-session (rare but supported).
*/
/** True when the OS "Reduce motion" preference is on; reactive to OS toggles. */
export function useReducedMotion(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
/**
* Convenience: returns 0 when reduced-motion is on, otherwise the supplied
* duration. Use inline at animation sites:
*
* const dur = useMotionDuration(DURATION_MS.quick);
* <Fade timeout={dur}>...</Fade>
*
* For animations that convey causality (modal open, drawer slide), prefer a
* tiny non-zero floor so the user still perceives the transition:
*
* const dur = useMotionDuration(DURATION_MS.standard, { floor: 40 });
*/
/** Returns 0 (or `opts.floor`) when reduced-motion is on, else `ms`. */
export function useMotionDuration(ms: number, opts: { floor?: number } = {}): number {
const reduced = useReducedMotion();
if (!reduced) return ms;
+2 -13
View File
@@ -1,12 +1,4 @@
// Route-change tracker.
//
// Reports a `nav.route_changed` event on every React Router location
// change so the cloud can aggregate visits per route. Reuses the
// existing report() surface — no new outbound paths added. The desktop
// just sends the path; the cloud counts.
//
// Mount inside a Router (must be a child of HashRouter / BrowserRouter)
// so useLocation() resolves.
// Reports nav.route_changed on each React Router location change. Mount inside a Router.
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
@@ -14,8 +6,7 @@ import { report } from '@/shared/serviceClient';
export function useRouteTracker(): void {
const location = useLocation();
// Skip the very first render the App opens at "/" and we don't want
// to report a phantom navigation that didn't happen.
// Skip first render so the App's "/" open doesn't fire a phantom nav.
const skippedFirst = useRef(false);
const lastPath = useRef<string>('');
@@ -28,8 +19,6 @@ export function useRouteTracker(): void {
}
if (path === lastPath.current) return;
lastPath.current = path;
// The path is a route name (e.g. /dashboard, /settings) — never the
// full URL. No query strings, no hash fragments beyond the route id.
report('nav', 'route_changed', { path });
}, [location.hash, location.pathname]);
}
@@ -1,19 +1,4 @@
// Single source of truth for "what URL should the preview webview point at?"
//
// New-mode webapp_template workspaces have no root index.html — the live
// preview lives behind the workspace's own Vite dev server, whose port is
// announced by the backend's runtime:status WS frame. Old-mode flat
// workspaces still serve files through the legacy /api/outputs/.../serve/
// endpoints. This hook hides the difference: it attaches to the runtime
// (ref-counted server-side, so multiple subscribers share one process),
// listens for status, and exposes the live frontend_url + new-mode flag.
// Consumers compute their final URL with `pickPreviewUrl()` below.
//
// Used by both ViewEditor (editor tab) and DashboardViewCard (dashboard
// canvas). Earlier each component had its own copy of this effect and
// only the editor had the new-mode logic — that's why dashboard cards
// for webapp_template apps were rendering the literal "File not found
// in output" JSON. One hook now, both consumers stay in sync.
// Hides legacy /serve/ vs new-mode Vite-runtime split for preview URLs; ref-counted spawn.
import { useEffect, useRef, useState } from 'react';
import { API_BASE, getAuthToken } from '@/shared/config';
@@ -27,25 +12,14 @@ export interface RuntimeLogLine {
export interface RuntimePreviewState {
frontendUrl: string | null;
isNewMode: boolean;
// True for the first ~400ms after subscribing — gives the runtime WS a
// chance to send its initial runtime:status frame before consumers
// decide to render a "Starting preview…" placeholder. Without this
// gate, dashboard cards flashed the placeholder every remount even
// when Vite was already up, because frontendUrl resets to null on
// mount and arrives one tick later.
// True until the runtime:status frame lands; prevents placeholder flash on remount when Vite is up.
isHydrating: boolean;
}
export interface RuntimePreviewOptions {
// Workspace to attach to. null/undefined → no-op (no spawn, no WS).
workspaceId: string | null | undefined;
// Gate the spawn. Lets callers defer paying the runtime cost until
// the user actually wants the preview (ViewEditor only spawns once
// the user clicks Preview or Terminal). Dashboard cards default to
// true since the preview pane is always visible.
/** Gate the spawn so callers can defer paying runtime cost until preview is wanted. */
enabled?: boolean;
// Optional sink for log lines. Editor's terminal panel uses this;
// dashboard cards don't need it and can omit.
onLog?: (line: RuntimeLogLine) => void;
}
@@ -54,9 +28,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
const [frontendUrl, setFrontendUrl] = useState<string | null>(null);
const [isNewMode, setIsNewMode] = useState(false);
const [isHydrating, setIsHydrating] = useState(true);
// Pin the latest onLog so we don't tear down + respawn the runtime
// every time the callback identity changes. The effect only depends
// on workspaceId + enabled.
// Pin latest onLog so callback identity changes don't tear down/respawn the runtime.
const onLogRef = useRef(onLog);
onLogRef.current = onLog;
@@ -70,12 +42,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
setFrontendUrl(null);
setIsNewMode(false);
setIsHydrating(true);
// Drop the hydrating flag after the WS has had time to deliver its
// initial runtime:status frame. With the backend's 80ms poll
// interval, status almost always arrives in 20-100ms; 150ms is
// generous enough that warm starts never flash the booting
// placeholder, while not making genuinely-cold runtimes wait an
// extra half second before showing "Starting preview…".
// 150ms: warm starts deliver status in 20-100ms; long enough to skip placeholder flash, short enough to not stall cold starts.
const hydrationTimer = setTimeout(() => {
if (!cancelled) setIsHydrating(false);
}, 150);
@@ -91,7 +58,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
headers,
});
} catch (_) {
// Spawn errors surface via the log WS. Don't double-report.
// Spawn errors surface via the log WS; don't double-report.
}
if (cancelled) return;
try {
@@ -105,7 +72,6 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
const fu = msg.data?.frontend_url ?? null;
setFrontendUrl(fu || null);
setIsNewMode(!!msg.data?.is_new_mode);
// Status arrived; hand off to the real ready/booting gate.
setIsHydrating(false);
} else if (msg.event === 'runtime:log') {
const stream = msg.data?.stream || 'stdout';
@@ -118,8 +84,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
}
};
} catch (_) {
// WS construction failed (CSP, bad URL, etc). Caller stays in
// its "no preview yet" state — same shape as a slow Vite cold start.
// WS construction failed; caller stays in "no preview yet" state.
}
})();
@@ -130,9 +95,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
setFrontendUrl(null);
setIsNewMode(false);
setIsHydrating(true);
// detach is ref-counted on the backend — only the last subscriber
// actually tears down the runtime, the rest are no-ops. We fire
// and forget; errors here would be transient and don't affect UX.
// detach is ref-counted on the backend; fire-and-forget.
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/stop`, {
method: 'POST',
headers,
@@ -145,40 +108,25 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
export interface PickPreviewUrlOptions {
workspaceId: string | null | undefined;
// Legacy fallback URL for old-mode flat workspaces. Pass the URL the
// component used BEFORE the new-mode split (ViewEditor uses
// `${SERVE_BASE}/workspace/${ws}/serve/index.html`, dashboard cards
// use `${SERVE_BASE}/${output_id}/serve/index.html`). When the runtime
// says we're in new-mode AND Vite is up, we override with frontendUrl.
/** Pre-new-mode URL the component used (serve/index.html); overridden by frontendUrl when ready. */
legacyUrl: string | undefined;
frontendUrl: string | null;
isNewMode: boolean;
}
export interface PickPreviewUrlResult {
// Final URL the preview should load. `undefined` means "show placeholder
// instead" — happens when the workspace is new-mode but Vite hasn't
// bound yet (cold start, npm install in progress, runtime crashed).
/** undefined => render placeholder (new-mode and Vite not bound yet). */
url: string | undefined;
// True iff we're in new-mode and frontendUrl hasn't arrived. UI uses
// this to render a "Starting preview…" affordance instead of letting
// the webview attempt the legacy URL (which 404s in new-mode).
isBooting: boolean;
}
export function pickPreviewUrl(opts: PickPreviewUrlOptions): PickPreviewUrlResult {
const { legacyUrl, frontendUrl, isNewMode, workspaceId } = opts;
if (!workspaceId) {
// No workspace id at all (output never seeded one). Use whatever
// legacy URL the caller computed — typical for old flat outputs
// that were created before workspace_id became standard.
return { url: legacyUrl, isBooting: false };
}
if (isNewMode && !frontendUrl) {
return { url: undefined, isBooting: true };
}
// Prefer frontendUrl when present (works for both new-mode that's up
// AND any future caller that gives us a Vite URL). Fall back to the
// legacy serve URL for old-mode workspaces.
return { url: frontendUrl ?? legacyUrl, isBooting: false };
}
+1 -12
View File
@@ -1,15 +1,4 @@
// Window blur/focus tracking — analytics signal for "user switched to
// another app" (temp-churn measurement).
//
// Wires the IPC channel that electron/main.js fires on the BrowserWindow's
// blur/focus events into the existing `report()` analytics pipeline. Each
// blur emits `app focus_lost` with the elapsed-ms-since-last-focus, and
// each focus emits `app focus_gained` with elapsed-ms-since-last-blur.
//
// Together these answer: how often do users leave OpenSwarm mid-session,
// for how long, and at what cadence?
//
// No-op in browser/web context where window.openswarm is undefined.
// Reports app focus_lost/focus_gained from Electron blur/focus IPC; no-op in browser.
import { useEffect } from 'react';
import { report } from '@/shared/serviceClient';
+5 -31
View File
@@ -1,17 +1,8 @@
// One-shot launch-time migrations. Runs synchronously before React
// mounts so any state-reset takes effect before the first selector
// reads it.
//
// Each migration is gated by a localStorage flag so it only runs once
// per install. Adding a new migration:
// 1. Append a new entry to MIGRATIONS below with a unique `key`.
// 2. The `run` function should be idempotent in case the flag check
// races with a parallel reload.
// Runs synchronously before React mounts so state-resets land before the first selector read.
// Each migration is gated by a localStorage flag so it runs once per install; keep `run` idempotent.
interface Migration {
/** Stable localStorage key. Never reused. */
key: string;
/** Human-readable description for telemetry / logs. */
description: string;
run: () => void;
}
@@ -19,37 +10,20 @@ interface Migration {
const MIGRATIONS: Migration[] = [
{
key: 'openswarm.migrations.v131_force_relogin_and_reonboard',
description:
'1.0.31 — force every user to sign in again and walk the new ' +
'onboarding flow, regardless of prior state',
description: '1.0.31: force re-login and re-walk onboarding, regardless of prior state',
run: () => {
try {
// Clear the persisted auth token. SignInGate will see no token
// and show the sign-in screen on next render. Electron's main
// process still has a copy, but the renderer will refetch via
// IPC after the user re-authenticates.
window.localStorage.removeItem('openswarm.auth.token');
// Clear onboarding-v2 state so the tour starts fresh from
// step 1 even for users who completed it on a prior version.
// The slice's loadFromStorage() will return null on next
// mount and init() will fire with a clean slate.
window.localStorage.removeItem('openswarm.onboarding.v2');
// Also clear the legacy v1 onboarding flag so v1.0.29-era
// users who never opened v2 get the new flow too.
window.localStorage.removeItem('openswarm_onboarding_seen');
} catch {
// localStorage can throw in private mode / quota-exceeded
// non-fatal, user will just keep prior state.
// localStorage can throw in private mode / quota-exceeded; non-fatal.
}
},
},
];
/**
* Run any migrations that haven't fired on this install yet. Idempotent;
* safe to call on every launch. Errors in individual migrations don't
* block subsequent ones.
*/
/** Run migrations that haven't fired on this install yet. Idempotent. */
export function runStartupMigrations(): void {
if (typeof window === 'undefined') return;
for (const m of MIGRATIONS) {
+4 -17
View File
@@ -1,14 +1,4 @@
// Native (Electron / browser) notifications for agent completion.
//
// We only fire when the document is hidden — the user has switched away —
// since a notification while you're staring at the same window would just
// be noise. Granola/Linear/Raycast all converge on this rule.
//
// Permission is requested lazily on first attempted use; subsequent calls
// no-op gracefully when permission is denied. Click on a notification
// re-focuses the window and emits a custom event the renderer listens for
// to deep-link back to the right session.
// Native notifications for agent completion; fires only when document is hidden (user switched away).
const FIRED_RECENTLY = new Set<string>();
const COOLDOWN_MS = 30_000;
@@ -35,15 +25,13 @@ export interface AgentCompletionPayload {
export function notifyAgentCompletion(p: AgentCompletionPayload): void {
if (typeof document === 'undefined') return;
// Same-window skip noise. Hidden = tab switched, window minimised, or
// (in Electron) another BrowserWindow is in front.
// Same-window: skip noise (hidden = tab-switched, minimized, or another BrowserWindow in front).
if (!document.hidden) return;
if (typeof Notification === 'undefined') return;
const perm = ensurePermission();
if (perm !== 'granted') return;
// Per-session debounce — if a sub-agent flips completederrorcompleted
// in quick succession we still only fire one toast.
// Per-session debounce: collapse rapid completed/error/completed flips.
const key = `${p.sessionId}:${p.status}`;
if (FIRED_RECENTLY.has(key)) return;
FIRED_RECENTLY.add(key);
@@ -68,7 +56,6 @@ export function notifyAgentCompletion(p: AgentCompletionPayload): void {
n.close();
};
} catch {
// Notification API can throw if the page is sandboxed or in a
// headless harness — fail silently.
// Notification API can throw if sandboxed or headless; fail silently.
}
}
+1 -11
View File
@@ -1,14 +1,4 @@
/**
* Resolves raw URL-bar input into a navigable URL.
*
* Priority:
* 1. Already has a scheme (http://, https://, file://, etc.) → pass through
* 2. Starts with / or ~ → file path, prefix with file://
* 3. localhost (with optional port/path) → http://
* 4. IP address (with optional port/path) → http://
* 5. No spaces + contains a dot followed by a 2+ char TLD → domain, prefix https://
* 6. Everything else → Google search
*/
/** Resolve raw URL-bar input to a navigable URL (scheme passthrough, file paths, domains, Google fallback). */
export function resolveInput(input: string): string {
const trimmed = input.trim();
if (!trimmed) return trimmed;
+7 -34
View File
@@ -1,15 +1,8 @@
// Operational state sync (frontend half).
//
// Single function: sync(data). Ships whatever object the caller has.
// The cloud determines what it means. No event names, no labels,
// no analytics vocabulary. A dev sees "we sync app state."
// Operational state sync; ships opaque objects the cloud interprets.
import { API_BASE } from './config';
/** Generate an id per submit() call. Used so retries (network blip,
* page reload mid-flush, etc.) are deduplicated downstream rather than
* inserted as separate rows. Falls back to a Math.random() id on
* ancient browsers without crypto.randomUUID. */
/** Submission id per call so retries get deduped downstream. */
function _newSubmissionId(): string {
try {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
@@ -25,10 +18,7 @@ let _appStart = Date.now();
const _queue: Record<string, unknown>[] = [];
let _flushTimer: ReturnType<typeof setTimeout> | null = null;
// Bounded ring buffer of the most recent report() calls. Lets components
// (notably ErrorBoundary) attach a "what was the user doing right before
// this broke" context as a property on their own report — no extra
// outbound traffic, no extra events.
// Bounded ring buffer of recent report() calls; ErrorBoundary attaches as breadcrumb context.
const _RECENT_CAP = 20;
const _recentReports: Array<{ s: string; a: string; ts: number }> = [];
@@ -39,10 +29,7 @@ function _record(surface: string, action: string): void {
}
}
/**
* Snapshot the most recent N report() entries. Used by error-handling
* paths to include "trail of breadcrumbs" context with their own report.
*/
/** Snapshot recent report() entries for breadcrumb context in error paths. */
export function getRecentActions(limit = 10): Array<{ s: string; a: string; ms_ago: number }> {
const now = Date.now();
const slice = _recentReports.slice(-Math.max(1, Math.min(limit, _RECENT_CAP)));
@@ -52,12 +39,7 @@ export function getRecentActions(limit = 10): Array<{ s: string; a: string; ms_a
function _flush(): void {
if (_queue.length === 0) return;
const batch = _queue.splice(0);
// Real batching: ship the whole queue in ONE request instead of N. The
// backend's /service/submit accepts either a single object or an array,
// and treating the queue as N separate POSTs was the largest single
// source of network/main-thread overhead in the app — hundreds of
// POSTs per second under load. One POST per second under the same load
// now. Cuts cost ~Nx.
// Ship whole queue in one POST; /service/submit accepts a single object or array.
const body = JSON.stringify(batch.length === 1 ? batch[0] : batch);
fetch(`${API_BASE}/service/submit`, {
method: 'POST',
@@ -68,9 +50,7 @@ function _flush(): void {
export function sync(data: Record<string, unknown> = {}, opts: { immediate?: boolean } = {}): void {
_lastTs = Date.now();
// Stamp a submission id + client timestamp so the cloud can deduplicate
// retries and order events by the moment they happened, not by the
// moment they landed.
// Stamp submission id + client ts so the cloud dedupes retries and orders by happen-time.
const stamped: Record<string, unknown> = {
...data,
submission_id: typeof data.submission_id === 'string' ? data.submission_id : _newSubmissionId(),
@@ -90,14 +70,7 @@ export function sync(data: Record<string, unknown> = {}, opts: { immediate?: boo
}
}
/**
* Compact ship-an-event helper. Produces the same wire shape as `sync()`
* — `{ s: surface, a: action, p: props }` — but reads as a "report a UI
* surface event" verb in caller code rather than a free-form state dump.
*
* The cloud reads (surface, action) tuples from the opaque payload and
* decides what they mean. The desktop never names what it's reporting.
*/
/** Ship-an-event helper: same wire shape as sync(), reads as a UI surface verb in callers. */
export function report(
surface: string,
action: string,
+25 -101
View File
@@ -15,27 +15,15 @@ export interface AgentMessage {
forced_tools?: string[];
images?: Array<{ data: string; media_type: string }>;
hidden?: boolean;
// Client-generated id used for optimistic-bubble dedupe. Set on the
// optimistic message we synthesize in `sendMessage.pending` and on the
// server echo (round-tripped via the POST body); the addMessage reducer
// uses it to find and replace the optimistic placeholder.
/** Round-tripped optimistic-bubble id; addMessage dedupes the echo against the placeholder. */
client_message_id?: string;
// Frontend-only lifecycle marker for optimistic messages. 'pending' until
// the server echo lands; 'failed' if the POST rejected. Confirmed messages
// (i.e. ones echoed back from the server) drop this field entirely.
/** Frontend-only optimistic lifecycle; dropped on server-echoed messages. */
optimistic_status?: 'pending' | 'failed';
// Server-stamped duration (ms) and approximate token count for the
// message's content. Today only thinking messages set these — the
// persisted ThinkingBubble reads them so "Thought for Ns · M tokens"
// survives reload instead of decaying to "Thoughts".
/** Server-stamped duration/token counts; today only thinking messages set these. */
elapsed_ms?: number;
tokens?: number;
// Server-stamped input-side token count for the turn (fresh
// input + cache_creation + cache_read). Populated on thinking
// messages so the pill can show "Thought for Ns · M in / K out"
// — which is the only honest answer to "how big was this turn".
/** Input-side token count for the turn (fresh + cache_creation + cache_read). */
input_tokens?: number;
// tool count drives the "3 tools used" segment on the thinking pill.
tool_count?: number;
}
@@ -57,8 +45,7 @@ export interface MessageBranch {
created_at: string;
}
// StreamingMessage type moved to streamingSlice. Import from there if you
// need the shape directly.
// StreamingMessage moved to streamingSlice; re-exported for back-compat.
export type { StreamingMessage } from './streamingSlice';
export interface ToolGroupMeta {
@@ -89,10 +76,7 @@ export interface AgentSession {
pending_approvals: ApprovalRequest[];
branches: Record<string, MessageBranch>;
active_branch_id: string;
// streamingMessage lives in `state.streaming.bySession[id]` now;
// read it via the selectors in streamingSlice. Kept off this type so
// that any reader still trying to access it gets a compile error and
// is migrated to the new location.
// streamingMessage lives in state.streaming.bySession[id]; see streamingSlice.
target_directory?: string | null;
tool_group_meta: Record<string, ToolGroupMeta>;
dashboard_id?: string;
@@ -107,17 +91,9 @@ export interface AgentSession {
mcp_suggestions?: Array<{ id: string; title: string; description: string; reason?: string }>;
mcp_suggestions_is_vague?: boolean;
compacted_through_msg_id?: string | null;
// Transient frontend-only WS connection state. Independent of
// `status` (which describes the agent run itself). When the WS
// drops we set this to 'reconnecting' so the UI can render a
// subtle indicator without faking a terminal status. Cleared back
// to 'live' on resume_ack. Never persisted to the backend.
/** Frontend-only WS state, decoupled from session.status so reconnects don't fake terminal states. */
connection_state?: 'live' | 'reconnecting';
// Aux-LLM-generated verb-phrase describing what the model is doing
// on the current turn ("Auditing the pull request", "Drafting your
// email"). Set by agent:turn_label, scoped to the turn that produced
// it via turn_id. ThinkingBubble swaps in this label as soon as it
// arrives, then back to the heuristic when the turn ends.
/** Aux-LLM verb-phrase for the current turn; ThinkingBubble swaps in then back when turn ends. */
turn_label?: { label: string; turn_id: string } | null;
}
@@ -161,12 +137,7 @@ interface AgentsState {
loading: boolean;
historySearch: HistorySearchState;
trackedNotificationIds: string[];
// Maps the temporary frontend draft id minted by createDraftSession to the
// real backend session id that replaces it once launchAndSendFirstMessage
// fulfills. Lets components that bound to the draft id (App Builder /
// ViewEditor in particular) find their new session without falling back
// to the global `activeSessionId` — which would silently leak whatever
// agent the user last interacted with from the dashboard.
// Draft session id => real backend id; bound components find their session without leaking activeSessionId.
draftLaunchMap: Record<string, string>;
}
@@ -224,11 +195,7 @@ function _genOptimisticId(): string {
export const sendMessage = createAsyncThunk(
'agents/sendMessage',
async ({ sessionId, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, hidden, selectedBrowserIds }: SendMessagePayload, { dispatch }) => {
// Generate an optimistic id up-front and dispatch the synchronous
// bubble *before* awaiting the network. The reducer below
// (sendMessage.pending) handles the same path, but doing it here
// gives us access to the id we'll round-trip to the server for
// dedupe on echo.
// Mint client id and dispatch optimistic bubble before awaiting the network; id round-trips for echo dedupe.
const clientMessageId = _genOptimisticId();
dispatch(addOptimisticMessage({
sessionId,
@@ -311,12 +278,7 @@ export const fetchSession = createAsyncThunk(
async (sessionId: string, { rejectWithValue }) => {
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}`);
if (!res.ok) {
// 404 is the common case: AgentChat is rehydrating from a URL hash
// that points at a session the user deleted (or that never made it
// to disk after a crash). Surface a structured rejection so the
// .rejected reducer can purge the stale id from `state.sessions`
// instead of leaving it as a phantom entry that the next mount
// will re-fetch right back into a 404.
// 404: rehydrating a deleted/crashed session; structured reject lets .rejected purge state.
return rejectWithValue({ sessionId, status: res.status });
}
const session = await res.json();
@@ -650,14 +612,12 @@ const agentsSlice = createSlice({
}
}
const existing = state.sessions[action.payload.id];
// Don't let a stale "running" message overwrite a terminal status
// Don't let stale "running" overwrite terminal status.
const terminal = ['stopped', 'error'] as const;
if (existing && terminal.includes(existing.status as any) && action.payload.status === 'running') {
return;
}
// Preserve local pending_approvals if the server payload has none but
// the frontend has some (avoids race where backend clears approvals
// before the frontend processes the removal).
// Preserve local pending_approvals when server payload has none (race on removal).
const mergedApprovals = existing?.pending_approvals?.length && !action.payload.pending_approvals?.length
? existing.pending_approvals
: action.payload.pending_approvals ?? [];
@@ -692,9 +652,7 @@ const agentsSlice = createSlice({
state,
action: PayloadAction<{ sessionId: string; state: 'live' | 'reconnecting' }>
) {
// Transient WS-layer indicator. Decoupled from session.status
// so a network blip never masquerades as a run terminating —
// status keeps reflecting the agent's actual lifecycle.
// Transient WS state, decoupled from session.status so blips don't mask the agent lifecycle.
const session = state.sessions[action.payload.sessionId];
if (session) {
session.connection_state = action.payload.state;
@@ -705,18 +663,13 @@ const agentsSlice = createSlice({
const session = state.sessions[action.payload.sessionId];
if (!session) return;
const incoming = action.payload.message;
// Optimistic-bubble dedupe: if this echo carries a client_message_id
// and we have an optimistic placeholder with the same id, replace it
// with the server version (preserving server's id, dropping the
// optimistic_status marker so the bubble renders as confirmed).
// Optimistic-bubble dedupe by client_message_id.
if (incoming.client_message_id) {
const optIdx = session.messages.findIndex(
(m) => m.client_message_id === incoming.client_message_id && m.optimistic_status === 'pending',
);
if (optIdx >= 0) {
session.messages[optIdx] = { ...incoming, optimistic_status: undefined };
// streamingMessage cleanup is handled by streamingSlice's
// extraReducers listening to this action.
return;
}
}
@@ -726,13 +679,9 @@ const agentsSlice = createSlice({
} else {
session.messages.push(incoming);
}
// streamingMessage cleanup is handled by streamingSlice's extraReducers.
},
// Synchronous "you sent a message" bubble dispatched from the
// sendMessage thunk before the network round-trip. The placeholder
// carries a client_message_id which the server echo (agent:message)
// will round-trip back; addMessage dedupes against it.
// Synchronous "you sent a message" placeholder; client_message_id round-trips for echo dedupe.
addOptimisticMessage(
state,
action: PayloadAction<{
@@ -749,8 +698,7 @@ const agentsSlice = createSlice({
const { sessionId, clientMessageId, prompt, contextPaths, forcedTools, attachedSkills, images, hidden } = action.payload;
const session = state.sessions[sessionId];
if (!session) return;
// Hidden messages (e.g. continuation prompts the model fires
// internally) shouldn't render an optimistic bubble.
// Hidden messages (e.g. internal continuation prompts) skip the optimistic bubble.
if (hidden) return;
session.messages.push({
id: clientMessageId,
@@ -780,10 +728,7 @@ const agentsSlice = createSlice({
if (msg) msg.optimistic_status = 'failed';
},
// Backend emits agent:context_status with reason="compacted" when the
// auto-compaction routine collapses older turns into a summary. We
// mirror compacted_through_msg_id locally so the renderer can drop a
// chip in the transcript right after that message.
// Mirror compacted_through_msg_id from agent:context_status so the renderer can drop a chip.
recordCompaction(
state,
action: PayloadAction<{ sessionId: string; throughMsgId: string | null }>,
@@ -793,8 +738,7 @@ const agentsSlice = createSlice({
session.compacted_through_msg_id = action.payload.throughMsgId;
},
// Aux-LLM-generated turn label. The pill renderer prefers this over
// the static "Thinking…" verb when present.
// Aux-LLM turn label; pill renderer prefers this over the static "Thinking..." verb.
setTurnLabel(
state,
action: PayloadAction<{ sessionId: string; turnId: string; label: string }>,
@@ -810,12 +754,7 @@ const agentsSlice = createSlice({
session.turn_label = null;
},
// streamStart / streamDelta / streamEnd live in streamingSlice now.
// Mutating per-character on `session.streamingMessage` previously
// changed the top-level `sessions` dict reference 30Hz × N agents,
// forcing Dashboard (subscribed to sessions) to re-render at the same
// rate. Keeping the streaming text in a separate slice keeps the
// sessions dict stable during streaming.
// streamStart/Delta/End live in streamingSlice; keeps sessions dict stable during streaming.
addApprovalRequest(
state,
@@ -1048,9 +987,7 @@ const agentsSlice = createSlice({
const fetchedIds = new Set(action.payload.map((s) => s.id));
const activeStatuses = new Set(['running', 'waiting_approval']);
// Remove stale sessions that belong to this dashboard fetch but
// are no longer returned by the server — keep sessions from other
// dashboards, drafts, tracked notifications, and active sessions.
// Strip stale fetched sessions; keep other dashboards, drafts, tracked, and active sessions.
for (const [id, existing] of Object.entries(state.sessions)) {
if (fetchedIds.has(id)) continue;
if (existing.status === 'draft') continue;
@@ -1144,10 +1081,7 @@ const agentsSlice = createSlice({
if (session) {
session.status = 'stopped';
session.pending_approvals = [];
// streamingMessage cleanup is handled by streamingSlice via
// clearStreamingForSession. We dispatch it explicitly here
// because stopAgent.fulfilled isn't one of the action types
// we listen for in streamingSlice's extraReducers.
// streamingMessage cleanup is via clearStreamingForSession (not in streamingSlice's extraReducers).
}
})
.addCase(handleApproval.fulfilled, (state, action) => {
@@ -1158,8 +1092,7 @@ const agentsSlice = createSlice({
}
})
.addCase(handleApproval.rejected, (_state, action) => {
// Approval stays in state so the user can retry.
// The request was never delivered to the backend.
// Approval stays in state so the user can retry; request never reached the backend.
console.error('Approval request failed:', action.error.message);
})
.addCase(switchBranch.fulfilled, (state, action) => {
@@ -1243,11 +1176,7 @@ const agentsSlice = createSlice({
if (!state.expandedSessionIds.includes(session.id)) {
state.expandedSessionIds.push(session.id);
}
// Keep this session pinned across the next fetchSessions strip.
// Without this, an in-flight fetchSessions that returned before the
// resume races with the resume reducer and removes the just-resumed
// session (since closed/stopped sessions don't survive the strip
// unless they're in trackedNotificationIds, drafts, or active).
// Pin across the next fetchSessions strip so an in-flight fetch can't drop the just-resumed session.
if (!state.trackedNotificationIds.includes(session.id)) {
state.trackedNotificationIds.push(session.id);
}
@@ -1268,12 +1197,7 @@ const agentsSlice = createSlice({
};
})
.addCase(fetchSession.rejected, (state, action) => {
// Stale-id cleanup: if the backend returned 404, the session no
// longer exists — strip it from state so AgentChat can short-
// circuit to a "session not found" view instead of looping the
// same dead fetch on every remount. Also clears activeSessionId
// if it was pointing at the dead id, so the dashboard doesn't
// keep highlighting a ghost.
// Stale-id cleanup on 404/410: strip so AgentChat short-circuits instead of looping the dead fetch.
const payload = action.payload as { sessionId?: string; status?: number } | undefined;
const sessionId = payload?.sessionId;
if (!sessionId) return;
+1 -3
View File
@@ -117,9 +117,7 @@ const dashboardsSlice = createSlice({
.addCase(createDashboard.fulfilled, (state, action) => {
state.items[action.payload.id] = action.payload;
})
// Optimistic: update name immediately on dispatch so the sidebar
// entry / picker label swaps with no perceptible lag. Server confirms
// on .fulfilled (rare correction); .rejected rolls back to previousName.
// Optimistic rename: swap label on dispatch; .rejected rolls back to previousName.
.addCase(renameDashboard.pending, (state, action) => {
const { id, name } = action.meta.arg;
if (state.items[id]) {
+4 -14
View File
@@ -1,23 +1,13 @@
// Tracks the timestamp of the most recent user interaction in the app
// (keystrokes, clicks, scrolls). Drives:
// - Idle UI dimming
// - "Are you still there?" snooze prompts
// - Session sync — last interaction timestamp piggybacks on the dump
// submitted to the backend at session close
//
// Intentionally lightweight; this is a single Redux number plus a "last
// surface" string for context.
// Last-interaction timestamp; drives idle dimming, snooze prompts, session sync close.
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
interface InteractionState {
/** Wall-clock ms (Date.now()) of the most recent user interaction. */
/** Date.now() of the most recent user interaction. */
lastInteractionAt: number;
/** App start, useful for "time spent in app" metrics & idle calculations. */
/** App start; for time-spent metrics and idle calcs. */
appStartedAt: number;
/** A coarse label for what surface the user last interacted with — useful
* for the "are you still there?" prompt (so we can resume them in
* context). */
/** Coarse surface label for snooze-prompt context. */
lastSurface: string | null;
}
+3 -4
View File
@@ -9,12 +9,12 @@ export interface ModelOption {
version?: string;
context_window: number;
reasoning?: boolean;
// Optional picker-UX fields from list_models.
input_cost_per_1m?: number;
output_cost_per_1m?: number;
is_free?: boolean;
max_completion_tokens?: number | null;
tiers?: [number, number, number]; // (intelligence, speed, cost), 1-5.
/** (intelligence, speed, cost), 1-5. */
tiers?: [number, number, number];
billing_kind?: 'paid' | 'subscription' | 'free' | 'api_key';
}
@@ -32,7 +32,6 @@ export const fetchModels = createAsyncThunk('models/fetchModels', async () => {
const res = await fetch(`${AGENTS_API}/models`);
if (!res.ok) throw new Error('Failed to fetch models');
const data = await res.json();
// API returns { models: { provider: [...] } }
const models = data.models || data;
return models as Record<string, ModelOption[]>;
});
@@ -48,7 +47,7 @@ const modelsSlice = createSlice({
state.loaded = true;
})
.addCase(fetchModels.rejected, (state) => {
// Mark as loaded even on failure so we fall back to hardcoded options
// Mark loaded even on failure so callers fall back to hardcoded options.
state.loaded = true;
});
},
+3 -8
View File
@@ -15,8 +15,7 @@ export interface Output {
files: Record<string, string>;
permission: string;
thumbnail?: string | null;
// Linkage so reopening App Builder reattaches to the in-progress session
// and reuses the on-disk workspace folder instead of seeding a fresh one.
/** Linkage so reopening App Builder reattaches to the in-progress session and workspace. */
session_id?: string | null;
workspace_id?: string | null;
created_at: string;
@@ -60,10 +59,7 @@ export interface OutputExecuteResult {
stdout: string | null;
stderr: string | null;
error: string | null;
// Present when the backend AST validator flagged risky imports/calls and
// the caller didn't pass force=true. UI shows these alongside `code_preview`
// in a "review and Run Anyway" dialog; resubmitting with force:true bypasses
// the gate. Absent (undefined) on the happy path.
/** Set when AST validator flagged risky code without force=true; resubmit with force to bypass. */
warnings?: string[] | null;
code_preview?: string | null;
}
@@ -121,8 +117,7 @@ export const deleteOutput = createAsyncThunk('outputs/delete', async (id: string
export const executeOutput = createAsyncThunk(
'outputs/execute',
// `force` opts past the AST warnings gate — only set after the user has
// seen the code preview in the run dialog and clicked Run Anyway.
// `force` opts past the AST warnings gate (Run Anyway in the dialog).
async (body: { output_id: string; input_data: Record<string, any>; force?: boolean }) => {
const res = await fetch(`${OUTPUTS_API}/execute`, {
method: 'POST',
+1 -3
View File
@@ -10,9 +10,7 @@ export interface Skill {
content: string;
file_path: string;
command: string;
// Set true for skills OpenSwarm ships with the platform (currently
// app_builder_skill). UI hides the delete button; backend DELETE
// returns 409. Content is still editable.
/** Platform-shipped skill; UI hides delete, backend DELETE returns 409. Content still editable. */
built_in?: boolean;
}
+7 -31
View File
@@ -1,29 +1,14 @@
import { createSlice, PayloadAction, createAction } from '@reduxjs/toolkit';
// Action type strings for cross-slice listening: we react to agentsSlice
// events (addMessage, editMessage, etc.) by clearing the streaming entry,
// matching the old in-place behavior. Using createAction with the same
// name lets streamingSlice's extraReducers catch the dispatch even though
// the action itself is owned by agentsSlice.
// Cross-slice listeners on agents/* actions; createAction with the same name catches them.
const addMessageAction = createAction<{ sessionId: string; message: { id: string } }>('agents/addMessage');
const editMessageFulfilled = createAction<{ sessionId: string }>('agents/editMessage/fulfilled');
const clearSessionMessagesAction = createAction<string>('agents/clearSessionMessages');
const closeSessionFromWsAction = createAction<{ id: string }>('agents/closeSessionFromWs');
const removeSessionAction = createAction<string>('agents/removeSession');
// stopAgent thunk's fulfilled action carries the sessionId as payload.
// Listening here lets us drop the streaming entry the moment the user
// stops a running agent, matching the previous in-place behavior.
const stopAgentFulfilledAction = createAction<string>('agents/stopAgent/fulfilled');
// Streaming-message state lives in its own slice (separate from agents/
// sessions) so that the high-frequency mutation of streamingMessage.content
// on every painted character doesn't bubble up through the sessions dict
// reference. Previously each painted character changed `state.agents.sessions`
// via Immer, causing every component subscribed to `state.agents.sessions`
// (Dashboard.tsx in particular: 30 useEffects, many selectors) to
// re-render at 30Hz × N streaming agents. Moving this out keeps the
// sessions dict stable during streaming; only structural events (start,
// end, status change, new message) mutate it now.
// Separate slice so per-char streaming mutations don't bubble through the sessions dict ref.
export interface StreamingMessage {
id: string;
@@ -33,9 +18,7 @@ export interface StreamingMessage {
}
interface StreamingState {
// Keyed by sessionId. Map semantics: an entry exists iff that session
// currently has an in-flight streaming message; it's removed on
// stream_end. Use selectStreamingMessage(sessionId) to read.
/** Keyed by sessionId; entry exists iff a stream is in flight, removed on stream_end. */
bySession: Record<string, StreamingMessage>;
}
@@ -76,25 +59,20 @@ const streamingSlice = createSlice({
delete state.bySession[action.payload.sessionId];
}
},
// Used when a session is fully closed/removed so we don't leak a
// stuck streaming entry for a session that no longer exists.
/** Clear when a session is fully closed/removed, so stuck streaming entries don't leak. */
clearStreamingForSession(state, action: PayloadAction<string>) {
delete state.bySession[action.payload];
},
},
extraReducers: (builder) => {
// When a final message lands for a session, clear the streaming
// entry if it matches (the streaming bubble in the UI was acting as
// a placeholder; now the real message takes over). Matches the
// original behavior that lived in agentsSlice.addMessage.
// Final message lands: clear matching streaming placeholder; real bubble takes over.
builder.addCase(addMessageAction, (state, action) => {
const entry = state.bySession[action.payload.sessionId];
if (entry && entry.id === action.payload.message.id) {
delete state.bySession[action.payload.sessionId];
}
});
// Edit / clear / close / remove all wipe any in-flight streaming
// bubble regardless of id match: the session's been mutated.
// Edit/clear/close/remove wipes any in-flight streaming bubble; the session changed.
builder.addCase(editMessageFulfilled, (state, action) => {
delete state.bySession[action.payload.sessionId];
});
@@ -116,9 +94,7 @@ const streamingSlice = createSlice({
export const { streamStart, streamDelta, streamEnd, clearStreamingForSession } = streamingSlice.actions;
export default streamingSlice.reducer;
// Reader hook. Each call subscribes only to that one session's streaming
// entry, so unrelated agents' deltas don't trigger re-renders. Returns
// null when no stream is active for the session.
/** Subscribes only to one session's stream entry; null when no stream is active. */
import { useAppSelector } from '@/shared/hooks';
export function useStreamingMessage(sessionId: string | null | undefined) {
return useAppSelector((s) => sessionId ? s.streaming.bySession[sessionId] ?? null : null);
@@ -21,27 +21,14 @@ export interface SubscriptionsState {
status: SubscriptionStatus | null;
}
// Minimal slice-shape — used by selectors so the slice doesn't import
// from store.ts (would create a circular dependency with the configured
// store, even type-only).
// Minimal slice shape for selectors; avoids circular type import from store.ts.
type WithSubscriptions = { subscriptions: SubscriptionsState };
const initialState: SubscriptionsState = {
status: null,
};
// Mirrors `GET /agents/subscriptions/status` into Redux so the onboarding
// gate (and any other consumer) can react to OAuth-driven subscription
// connections — the actual tokens live in 9Router-managed storage, not in
// settings.data, so this slice is the only frontend signal that an
// "external subscription" has been hooked up.
//
// `preserveTransient` keeps a previously-seen `running: true` state when a
// refresh comes back with `running: false`. The backend's `is_running()`
// probe has a short sync timeout that can be exceeded while 9Router is
// streaming inference, producing false negatives that would otherwise
// flip the Settings cards into a "Starting subscription service..."
// spinner mid-session.
/** Mirror /agents/subscriptions/status into Redux; preserveTransient debounces is_running() false negatives. */
export const fetchSubscriptionStatus = createAsyncThunk(
'subscriptions/fetchStatus',
async (opts: { preserveTransient?: boolean } | undefined, { getState }) => {
@@ -74,9 +61,7 @@ const subscriptionsSlice = createSlice({
export const { setSubscriptionStatus } = subscriptionsSlice.actions;
// Pulls the connections array out of the polymorphic `providers` shape
// (`{ connections: [...] }` for the modern response, bare array for the
// legacy one). Returns [] for the loading state.
/** Unwraps the polymorphic `providers` shape (modern object vs legacy array). */
export function selectSubscriptionConnections(
state: WithSubscriptions,
): SubscriptionConnection[] {
@@ -1,4 +1,3 @@
// store/tempStateSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface TempState {
+13 -18
View File
@@ -1,35 +1,30 @@
// Single source of truth for animation timing + easing across the app.
// Mixing one-off durations / curves makes the chrome feel like several
// different products glued together; tokenizing makes everything land
// the same way.
//
// Pair with `useReducedMotion()` to respect OS-level "Reduce motion".
// Animation timing/easing tokens. Pair with useReducedMotion() for OS "Reduce motion".
export const DURATION_MS = {
/** 60ms hover state changes, subtle press feedback */
/** 60ms: hover, subtle press feedback. */
instant: 60,
/** 140ms rows fading in, popovers, tooltip open, status pill swaps */
/** 140ms: row fade, popover/tooltip open, status pill swap. */
quick: 140,
/** 220ms modal open, page transitions, banners */
/** 220ms: modal open, page transitions, banners. */
standard: 220,
/** 400ms drawer slide, big layout shifts */
/** 400ms: drawer slide, big layout shifts. */
slow: 400,
/** 1500ms skeleton pulse + ambient breathing indicators */
/** 1500ms: skeleton pulse + ambient breathing. */
ambient: 1500,
} as const;
export const EASE = {
/** Linear's signature curve. Snappy out, gentle settle. Good default for "thing appears". */
/** Linear-style snappy out, gentle settle; good default for things appearing. */
out: 'cubic-bezier(0.16, 1, 0.3, 1)',
/** MUI / Material default. Symmetric for things that move both directions. */
/** Material symmetric; for two-way motion. */
inOut: 'cubic-bezier(0.4, 0, 0.2, 1)',
/** Subtle bounce at the end. Use sparingly for delight moments. */
/** Subtle bounce; use sparingly. */
spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
/** Gentle breathing curve for ambient pulses. */
/** Gentle breathing curve. */
pulse: 'cubic-bezier(0.4, 0, 0.6, 1)',
} as const;
/** Framer-motion uses array-form easing. Same curves as EASE above. */
/** Array-form easing for framer-motion; same curves as EASE. */
export const FRAMER_EASE = {
out: [0.16, 1, 0.3, 1] as [number, number, number, number],
inOut: [0.4, 0, 0.2, 1] as [number, number, number, number],
@@ -37,7 +32,7 @@ export const FRAMER_EASE = {
pulse: [0.4, 0, 0.6, 1] as [number, number, number, number],
};
/** Module-scoped fadeIn keyframe. Imported once instead of redefined inline at each callsite. */
/** Shared fadeIn keyframe; import once. */
export const fadeInKeyframes = {
'@keyframes openswarmFadeIn': {
from: { opacity: 0 },
@@ -45,7 +40,7 @@ export const fadeInKeyframes = {
},
};
/** Skeleton + indicator pulse keyframe. Imported once. */
/** Shared skeleton/indicator pulse keyframe. */
export const pulseKeyframes = {
'@keyframes openswarmPulse': {
'0%, 100%': { opacity: 0.5 },
+3 -10
View File
@@ -8,10 +8,7 @@ interface SubscribeOptions {
wasSubscribed?: boolean;
}
// Kicks off a Stripe Checkout session for the given plan + interval and opens
// the returned URL in the user's default browser (or a new tab fallback).
// All subscribe CTAs across Settings, Onboarding, and the 429 error card go
// through this helper so the wire shape and error handling stay consistent.
/** Create a Stripe Checkout session and open the URL externally; used by all subscribe CTAs. */
export async function subscribeToPlan(
plan: OpenSwarmPlan,
billingInterval: BillingInterval,
@@ -26,14 +23,10 @@ export async function subscribeToPlan(
});
try {
// Cloud schema uses "yearly"; the desktop UI uses "annual".
// Normalize at the boundary so the rest of the client stays consistent.
// Cloud uses "yearly", UI uses "annual"; normalize at the boundary.
const wireInterval = billingInterval === 'annual' ? 'yearly' : billingInterval;
// Pull app_install_id from Electron's persisted install.json so the cloud
// can join Stripe checkout against install_tokens for affiliate payout
// attribution. Best-effort: missing IPC (renderer running outside the
// shell, e.g. in a dev browser) just means no attribution, not an error.
// app_install_id lets the cloud attribute Stripe checkout to install_tokens for affiliate payout.
let appInstallId: string | null = null;
try {
const api = (window as any).openswarm;
+3 -3
View File
@@ -10,11 +10,11 @@ export interface BrowserActivityState {
active: boolean;
action: BrowserAction | null;
detail: string | null;
/** The action that just completed stays set briefly for exit animations */
/** Action that just completed; stays briefly for exit animations. */
lastAction: BrowserAction | null;
/** Increments on each new action use as React key to restart CSS animations */
/** Increments per new action; use as React key to restart CSS animations. */
actionSeq: number;
/** Viewport-relative click coordinates (0-1 range) for positioning the click ripple */
/** Viewport-relative click coords (0-1) for positioning the click ripple. */
coords: { xPercent: number; yPercent: number } | null;
}