mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 05:07:40 +02:00
[eric] onboarding 1.0.31 + Settings draft persistence + LM Studio fix
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
// 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.
|
||||
|
||||
interface Migration {
|
||||
/** Stable localStorage key. Never reused. */
|
||||
key: string;
|
||||
/** Human-readable description for telemetry / logs. */
|
||||
description: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
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',
|
||||
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.
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function runStartupMigrations(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
for (const m of MIGRATIONS) {
|
||||
try {
|
||||
if (window.localStorage.getItem(m.key) === 'done') continue;
|
||||
m.run();
|
||||
window.localStorage.setItem(m.key, 'done');
|
||||
} catch {
|
||||
// Don't block other migrations on one failing.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,74 @@ export function findOpenGridCell(
|
||||
}
|
||||
}
|
||||
|
||||
// Like findOpenGridCell but biased to stay near a proposed (x,y) anchor.
|
||||
// Used when the backend hands us a card with a position that's already
|
||||
// occupied (sub-agent or sub-browser spawning on top of its parent or a
|
||||
// sibling). Spirals outward from the anchor on a grid, snapping to
|
||||
// cell-aligned positions so the result still looks intentional, not
|
||||
// dropped from orbit. Caps the spiral search at ~1000 cells to avoid
|
||||
// pathological work in adversarial layouts — falls back to
|
||||
// findOpenGridCell after that.
|
||||
//
|
||||
// Cost: O(rects × cells_scanned). Spawn events are rare (not per-frame),
|
||||
// so this only runs when a new card appears. Typical scan resolves in
|
||||
// <10 cells, well below the cap. No perf impact on steady-state UI.
|
||||
export function findOpenSpotNear(
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
occupiedRects: Rect[],
|
||||
newW: number,
|
||||
newH: number,
|
||||
): { x: number; y: number } {
|
||||
const cellW = DEFAULT_CARD_W + GRID_GAP;
|
||||
const cellH = DEFAULT_CARD_H + GRID_GAP;
|
||||
// Snap the anchor to the nearest grid cell so all cards align cleanly.
|
||||
const baseCol = Math.round((anchorX - GRID_ORIGIN.x) / cellW);
|
||||
const baseRow = Math.round((anchorY - GRID_ORIGIN.y) / cellH);
|
||||
|
||||
const cellFree = (col: number, row: number): boolean => {
|
||||
const x = GRID_ORIGIN.x + col * cellW;
|
||||
const y = GRID_ORIGIN.y + row * cellH;
|
||||
const candidate: Rect = { x, y, w: newW, h: newH };
|
||||
return !occupiedRects.some((r) => rectsOverlap(candidate, r));
|
||||
};
|
||||
|
||||
// Try the anchor itself first.
|
||||
if (cellFree(baseCol, baseRow)) {
|
||||
return {
|
||||
x: GRID_ORIGIN.x + baseCol * cellW,
|
||||
y: GRID_ORIGIN.y + baseRow * cellH,
|
||||
};
|
||||
}
|
||||
|
||||
// Spiral search: expand rings around the anchor. Each ring r covers
|
||||
// the perimeter of a (2r+1)×(2r+1) square. First free cell wins,
|
||||
// preferring right/down (read order) within each ring for stability.
|
||||
const MAX_RING = 32;
|
||||
for (let r = 1; r <= MAX_RING; r++) {
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
// Only perimeter of this ring (interior was scanned in r-1).
|
||||
if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
|
||||
const col = baseCol + dx;
|
||||
const row = baseRow + dy;
|
||||
// Don't place above the grid origin.
|
||||
if (col < 0 || row < 0) continue;
|
||||
if (cellFree(col, row)) {
|
||||
return {
|
||||
x: GRID_ORIGIN.x + col * cellW,
|
||||
y: GRID_ORIGIN.y + row * cellH,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pathological — full canvas occupied near anchor. Fall back to the
|
||||
// global first-empty scan so we never return an overlap.
|
||||
return findOpenGridCell(occupiedRects, newW, newH);
|
||||
}
|
||||
|
||||
const dashboardLayoutSlice = createSlice({
|
||||
name: 'dashboardLayout',
|
||||
initialState,
|
||||
@@ -261,10 +329,34 @@ const dashboardLayoutSlice = createSlice({
|
||||
|
||||
placeCard(
|
||||
state,
|
||||
action: PayloadAction<{ sessionId: string; x: number; y: number; width: number; height: number }>
|
||||
action: PayloadAction<{
|
||||
sessionId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
// Optional: which existing sessions are currently expanded
|
||||
// (showing their full chat history). Without this, the collision
|
||||
// check uses each card's STORED height — which is the collapsed
|
||||
// value — even when the card is currently rendering at the
|
||||
// expanded ~620px. Result: new sub-agent cards spawn into the
|
||||
// collapsed footprint but overlap the visually expanded one.
|
||||
// Caller (Dashboard.tsx) passes the current expanded set so
|
||||
// the collision math matches what the user actually sees.
|
||||
expandedSessionIds?: string[];
|
||||
}>
|
||||
) {
|
||||
const { sessionId, x, y, width, height } = action.payload;
|
||||
state.cards[sessionId] = { session_id: sessionId, x, y, width, height, zOrder: state.nextZOrder++ };
|
||||
const { sessionId, x, y, width, height, expandedSessionIds } = action.payload;
|
||||
const rects = collectOccupiedRects(state, expandedSessionIds);
|
||||
const pos = findOpenSpotNear(x, y, rects, width, height);
|
||||
state.cards[sessionId] = {
|
||||
session_id: sessionId,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width,
|
||||
height,
|
||||
zOrder: state.nextZOrder++,
|
||||
};
|
||||
},
|
||||
|
||||
bringToFront(
|
||||
@@ -453,10 +545,23 @@ const dashboardLayoutSlice = createSlice({
|
||||
addBrowserCardFromBackend(state, action: PayloadAction<BrowserCardPosition>) {
|
||||
const card = action.payload;
|
||||
if (state.browserCards[card.browser_id]) return;
|
||||
const w = card.width || DEFAULT_BROWSER_CARD_W;
|
||||
const h = card.height || DEFAULT_BROWSER_CARD_H;
|
||||
// Collision-resolve the backend-proposed position. Backend agents
|
||||
// often spawn sub-browsers at the parent's coordinates or at a
|
||||
// default (0,0) — without this guard, the new card lands on top
|
||||
// of an existing one and the user sees a single card with
|
||||
// multiple titles fighting for the z-index. Bias toward the
|
||||
// proposed position so the spawn still LOOKS related to wherever
|
||||
// the agent intended.
|
||||
const rects = collectOccupiedRects(state);
|
||||
const pos = findOpenSpotNear(card.x, card.y, rects, w, h);
|
||||
state.browserCards[card.browser_id] = {
|
||||
...card,
|
||||
width: card.width || DEFAULT_BROWSER_CARD_W,
|
||||
height: card.height || DEFAULT_BROWSER_CARD_H,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: w,
|
||||
height: h,
|
||||
zOrder: card.zOrder || state.nextZOrder++,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -145,7 +145,10 @@ export interface AutoRunResult {
|
||||
|
||||
export const autoRunOutput = createAsyncThunk(
|
||||
'outputs/autoRun',
|
||||
async (body: { prompt: string; input_schema: Record<string, any>; backend_code?: string | null; context_paths?: Array<{ path: string; type: string }>; forced_tools?: string[]; model?: string }) => {
|
||||
// backend_code intentionally NOT in the request shape. The server endpoint
|
||||
// ignores it now (it was an unsandboxed-RCE primitive); callers that want
|
||||
// backend execution should chain executeOutput against a persisted Output.
|
||||
async (body: { prompt: string; input_schema: Record<string, any>; context_paths?: Array<{ path: string; type: string }>; forced_tools?: string[]; model?: string }) => {
|
||||
const res = await fetch(`${OUTPUTS_API}/auto-run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -100,6 +100,16 @@ interface SettingsState {
|
||||
modalOpen: boolean;
|
||||
/** When non-null, Settings opens to this tab instead of 'general'. */
|
||||
initialTab: string | null;
|
||||
/**
|
||||
* In-flight form edits, preserved across modal close/reopen so the user
|
||||
* can step away from Settings (browse the dashboard, open a doc, etc.)
|
||||
* and come back to find their typing intact. `null` means the form is in
|
||||
* sync with `data` — no unsaved edits. Cleared automatically on a
|
||||
* successful save, or explicitly via clearDraft.
|
||||
*/
|
||||
draft: AppSettings | null;
|
||||
/** Tab the user was on when they closed the modal with unsaved edits. */
|
||||
draftTab: string | null;
|
||||
}
|
||||
|
||||
const initialState: SettingsState = {
|
||||
@@ -124,6 +134,8 @@ const initialState: SettingsState = {
|
||||
loaded: false,
|
||||
modalOpen: false,
|
||||
initialTab: null,
|
||||
draft: null,
|
||||
draftTab: null,
|
||||
};
|
||||
|
||||
export const fetchSettings = createAsyncThunk('settings/fetch', async () => {
|
||||
@@ -242,6 +254,21 @@ const settingsSlice = createSlice({
|
||||
state.modalOpen = false;
|
||||
state.initialTab = null;
|
||||
},
|
||||
/**
|
||||
* Persist the user's in-flight form edits + active tab so they survive
|
||||
* modal close. Settings.tsx calls this on every form mutation (React's
|
||||
* batching keeps it cheap). When the form matches saved data, callers
|
||||
* pass null/clearDraft to drop the marker — `hasChanges` then reads
|
||||
* false correctly.
|
||||
*/
|
||||
setDraft(state, action: PayloadAction<{ form: AppSettings; tab: string }>) {
|
||||
state.draft = action.payload.form;
|
||||
state.draftTab = action.payload.tab;
|
||||
},
|
||||
clearDraft(state) {
|
||||
state.draft = null;
|
||||
state.draftTab = null;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
@@ -270,12 +297,18 @@ const settingsSlice = createSlice({
|
||||
})
|
||||
.addCase(updateSettings.fulfilled, (state, action) => {
|
||||
state.data = action.payload;
|
||||
// Save consumes the draft — clear it so the next modal-open
|
||||
// doesn't restore stale edits over freshly-saved values.
|
||||
state.draft = null;
|
||||
state.draftTab = null;
|
||||
})
|
||||
.addCase(resetSystemPrompt.fulfilled, (state, action) => {
|
||||
state.data = action.payload;
|
||||
state.draft = null;
|
||||
state.draftTab = null;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { openSettingsModal, closeSettingsModal } = settingsSlice.actions;
|
||||
export const { openSettingsModal, closeSettingsModal, setDraft, clearDraft } = settingsSlice.actions;
|
||||
export default settingsSlice.reducer;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { store } from '../state/store';
|
||||
import { unstable_batchedUpdates } from 'react-dom';
|
||||
import {
|
||||
updateSession,
|
||||
updateSessionName,
|
||||
@@ -166,6 +167,43 @@ class WebSocketManager {
|
||||
// speed (~200 cps comfort threshold), still hides bursty upstream
|
||||
// cadence, just feels less frantic. Tuned for legibility at speed.
|
||||
private static TARGET_CHARS_PER_PAINT = 10;
|
||||
// Frame-aligned message coalescer. Buffers incoming WS messages from
|
||||
// all WebSocketManager instances and flushes them in ONE batched
|
||||
// React render per animation frame. Without this, N concurrent agents
|
||||
// each cause their own renders on every WS message — dozens of full
|
||||
// app re-renders per second, fanning out to every useSelector. With
|
||||
// it: max one render per frame regardless of message volume.
|
||||
private static _messageQueue: Array<{ mgr: WebSocketManager; msg: WSEvent }> = [];
|
||||
private static _flushScheduled = false;
|
||||
|
||||
private static _enqueueMessage(mgr: WebSocketManager, msg: WSEvent) {
|
||||
WebSocketManager._messageQueue.push({ mgr, msg });
|
||||
if (WebSocketManager._flushScheduled) return;
|
||||
WebSocketManager._flushScheduled = true;
|
||||
requestAnimationFrame(WebSocketManager._flushMessages);
|
||||
}
|
||||
|
||||
private static _flushMessages = () => {
|
||||
WebSocketManager._flushScheduled = false;
|
||||
if (WebSocketManager._messageQueue.length === 0) return;
|
||||
const batch = WebSocketManager._messageQueue;
|
||||
WebSocketManager._messageQueue = [];
|
||||
// unstable_batchedUpdates collapses all dispatches inside the
|
||||
// callback into a single React render. Available in React 17;
|
||||
// React 18's automatic batching covers this too, but explicit
|
||||
// wrap remains correct in both and protects against future
|
||||
// batching-context changes.
|
||||
unstable_batchedUpdates(() => {
|
||||
for (const { mgr, msg } of batch) {
|
||||
try {
|
||||
mgr.handleMessage(msg);
|
||||
} catch (e) {
|
||||
console.warn('[ws] message handler threw', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// When a backlog accumulates, allow up to this many chars/paint to
|
||||
// drain it. ~1.6× the target keeps catch-up imperceptible — the
|
||||
// eye can't tell 10 from 16 in a fluid stream. Caps the worst-case
|
||||
@@ -359,7 +397,16 @@ class WebSocketManager {
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg: WSEvent = JSON.parse(event.data);
|
||||
this.handleMessage(msg);
|
||||
// Buffer incoming messages and flush them per animation frame
|
||||
// in a single React batch. With N concurrent agents/browsers
|
||||
// streaming, each WS instance used to trigger its own React
|
||||
// render — dozens per frame, fanning out to every useSelector
|
||||
// subscriber, starving the main thread. Coalescing flips that
|
||||
// to ONE batched render per frame regardless of how many
|
||||
// messages arrived. Stream-chunk dispatches are already paced
|
||||
// by the interpolator, so this is purely additive throttling
|
||||
// for non-stream events (status, tool_call, completion, etc).
|
||||
WebSocketManager._enqueueMessage(this, msg);
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user