[eric] onboarding 1.0.31 + Settings draft persistence + LM Studio fix

This commit is contained in:
ciregenz
2026-05-10 17:57:01 -07:00
parent 443327d4bd
commit 54be1e8ba1
39 changed files with 1202 additions and 231 deletions
+64
View File
@@ -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++,
};
},
+4 -1
View File
@@ -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' },
+34 -1
View File
@@ -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;
+48 -1
View File
@@ -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
}