Files
openswarm/frontend/src/shared/state/dashboardLayoutSlice.ts
T

629 lines
19 KiB
TypeScript

import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { launchAndSendFirstMessage } from './agentsSlice';
import { API_BASE } from '@/shared/config';
const DASHBOARDS_API = `${API_BASE}/dashboards`;
export const DEFAULT_CARD_W = 480;
export const DEFAULT_CARD_H = 280;
export const DEFAULT_VIEW_CARD_W = 1280;
export const DEFAULT_VIEW_CARD_H = 800;
export const DEFAULT_BROWSER_CARD_W = 1280;
export const DEFAULT_BROWSER_CARD_H = 800;
export const EXPANDED_CARD_MIN_H = 620;
const GRID_GAP = 24;
const GRID_ORIGIN = { x: 40, y: 100 };
const GRID_COLS_FALLBACK = 4;
export interface CardPosition {
session_id: string;
x: number;
y: number;
width: number;
height: number;
}
export interface ViewCardPosition {
output_id: string;
x: number;
y: number;
width: number;
height: number;
}
export interface BrowserTab {
id: string;
url: string;
title: string;
favicon?: string;
}
export interface BrowserCardPosition {
browser_id: string;
url: string;
tabs: BrowserTab[];
activeTabId: string;
x: number;
y: number;
width: number;
height: number;
}
export interface DashboardLayoutState {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
glowingBrowserCards: Record<string, string>;
persistedExpandedSessionIds: string[];
loading: boolean;
initialized: boolean;
}
const initialState: DashboardLayoutState = {
cards: {},
viewCards: {},
browserCards: {},
glowingBrowserCards: {},
persistedExpandedSessionIds: [],
loading: false,
initialized: false,
};
interface LayoutPayload {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
expandedSessionIds: string[];
}
function generateTabId(): string {
return `tab-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
}
export const fetchLayout = createAsyncThunk(
'dashboardLayout/fetch',
async (dashboardId: string) => {
const res = await fetch(`${DASHBOARDS_API}/${dashboardId}`);
const data = await res.json();
const layout = data.layout ?? {};
const browserCards = (layout.browser_cards ?? {}) as Record<string, any>;
for (const card of Object.values(browserCards)) {
if (!card.tabs || card.tabs.length === 0) {
const tabId = generateTabId();
card.tabs = [{ id: tabId, url: card.url || 'https://www.google.com', title: '' }];
card.activeTabId = tabId;
}
if (!card.url && card.tabs.length > 0) {
const active = card.tabs.find((t: any) => t.id === card.activeTabId) || card.tabs[0];
card.url = active.url;
}
}
return {
cards: (layout.cards ?? {}) as Record<string, CardPosition>,
viewCards: (layout.view_cards ?? {}) as Record<string, ViewCardPosition>,
browserCards: browserCards as Record<string, BrowserCardPosition>,
expandedSessionIds: (layout.expanded_session_ids ?? []) as string[],
} satisfies LayoutPayload;
},
);
interface SaveLayoutPayload extends LayoutPayload {
dashboardId: string;
}
export const saveLayout = createAsyncThunk(
'dashboardLayout/save',
async (payload: SaveLayoutPayload) => {
await fetch(`${DASHBOARDS_API}/${payload.dashboardId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
layout: {
cards: payload.cards,
view_cards: payload.viewCards,
browser_cards: payload.browserCards,
expanded_session_ids: payload.expandedSessionIds,
},
}),
});
return payload;
},
);
interface Rect {
x: number;
y: number;
w: number;
h: number;
}
function rectsOverlap(a: Rect, b: Rect): boolean {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
function collectOccupiedRects(
state: DashboardLayoutState,
expandedSessionIds?: string[],
): Rect[] {
const expanded = new Set(expandedSessionIds);
const rects: Rect[] = [];
for (const c of Object.values(state.cards)) {
const h = expanded.has(c.session_id) ? Math.max(EXPANDED_CARD_MIN_H, c.height) : c.height;
rects.push({ x: c.x, y: c.y, w: c.width, h });
}
for (const c of Object.values(state.viewCards)) {
rects.push({ x: c.x, y: c.y, w: c.width, h: c.height });
}
for (const c of Object.values(state.browserCards)) {
rects.push({ x: c.x, y: c.y, w: c.width, h: c.height });
}
return rects;
}
export function findOpenGridCell(
occupiedRects: Rect[],
newW: number,
newH: number,
): { x: number; y: number } {
const cellW = DEFAULT_CARD_W + GRID_GAP;
const cellH = DEFAULT_CARD_H + GRID_GAP;
const maxCols = Math.max(
1,
Math.floor((window.innerWidth - GRID_ORIGIN.x) / cellW) || GRID_COLS_FALLBACK,
);
for (let row = 0; ; row++) {
for (let col = 0; col < maxCols; col++) {
const x = GRID_ORIGIN.x + col * cellW;
const y = GRID_ORIGIN.y + row * cellH;
const candidate: Rect = { x, y, w: newW, h: newH };
if (!occupiedRects.some((r) => rectsOverlap(candidate, r))) {
return { x, y };
}
}
}
}
const dashboardLayoutSlice = createSlice({
name: 'dashboardLayout',
initialState,
reducers: {
setCardPosition(
state,
action: PayloadAction<{ sessionId: string; x: number; y: number }>
) {
const { sessionId, x, y } = action.payload;
const card = state.cards[sessionId];
if (card) {
card.x = x;
card.y = y;
}
},
setCardSize(
state,
action: PayloadAction<{ sessionId: string; width: number; height: number }>
) {
const { sessionId, width, height } = action.payload;
const card = state.cards[sessionId];
if (card) {
card.width = Math.max(480, width);
card.height = Math.max(180, height);
}
},
removeCard(state, action: PayloadAction<string>) {
delete state.cards[action.payload];
},
reconcileSessions(
state,
action: PayloadAction<{ sessionIds: string[]; expandedSessionIds: string[] }>,
) {
const { sessionIds, expandedSessionIds } = action.payload;
const liveIds = new Set(sessionIds);
for (const id of Object.keys(state.cards)) {
if (!liveIds.has(id)) {
delete state.cards[id];
}
}
const hasDraftCard = Object.keys(state.cards).some((id) => id.startsWith('draft-'));
const newIds = sessionIds.filter((id) => !state.cards[id]);
for (const id of newIds) {
if (hasDraftCard && !id.startsWith('draft-')) continue;
const rects = collectOccupiedRects(state, expandedSessionIds);
const pos = findOpenGridCell(rects, DEFAULT_CARD_W, DEFAULT_CARD_H);
state.cards[id] = {
session_id: id,
x: pos.x,
y: pos.y,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
};
}
},
tidyLayout(
state,
action: PayloadAction<{ expandedSessionIds: string[] }>,
) {
const expanded = new Set(action.payload.expandedSessionIds);
const agentCards = Object.values(state.cards);
const viewCards = Object.values(state.viewCards);
const bCards = Object.values(state.browserCards);
const total = agentCards.length + viewCards.length + bCards.length;
if (total === 0) return;
const allItems = [
...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y, storedH: c.height })),
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y, storedH: c.height })),
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y, storedH: c.height })),
];
allItems.sort((a, b) => a.y - b.y || a.x - b.x);
const placedRects: Rect[] = [];
for (const item of allItems) {
let w: number, h: number;
if (item.kind === 'agent') {
w = DEFAULT_CARD_W;
h = expanded.has(item.id) ? Math.max(EXPANDED_CARD_MIN_H, item.storedH) : DEFAULT_CARD_H;
} else if (item.kind === 'view') {
w = DEFAULT_VIEW_CARD_W; h = DEFAULT_VIEW_CARD_H;
} else {
w = DEFAULT_BROWSER_CARD_W; h = DEFAULT_BROWSER_CARD_H;
}
const pos = findOpenGridCell(placedRects, w, h);
placedRects.push({ x: pos.x, y: pos.y, w, h });
if (item.kind === 'agent') {
const card = state.cards[item.id];
if (card) { card.x = pos.x; card.y = pos.y; card.width = w; card.height = h; }
} else if (item.kind === 'view') {
const card = state.viewCards[item.id];
if (card) { card.x = pos.x; card.y = pos.y; card.width = w; card.height = h; }
} else {
const card = state.browserCards[item.id];
if (card) { card.x = pos.x; card.y = pos.y; card.width = w; card.height = h; }
}
}
},
addViewCard(state, action: PayloadAction<{ outputId: string; expandedSessionIds?: string[] }>) {
const { outputId, expandedSessionIds } = action.payload;
if (state.viewCards[outputId]) return;
const rects = collectOccupiedRects(state, expandedSessionIds);
const pos = findOpenGridCell(rects, DEFAULT_VIEW_CARD_W, DEFAULT_VIEW_CARD_H);
state.viewCards[outputId] = {
output_id: outputId,
x: pos.x,
y: pos.y,
width: DEFAULT_VIEW_CARD_W,
height: DEFAULT_VIEW_CARD_H,
};
},
setViewCardPosition(
state,
action: PayloadAction<{ outputId: string; x: number; y: number }>
) {
const { outputId, x, y } = action.payload;
const card = state.viewCards[outputId];
if (card) { card.x = x; card.y = y; }
},
setViewCardSize(
state,
action: PayloadAction<{ outputId: string; width: number; height: number }>
) {
const { outputId, width, height } = action.payload;
const card = state.viewCards[outputId];
if (card) {
card.width = Math.max(320, width);
card.height = Math.max(200, height);
}
},
removeViewCard(state, action: PayloadAction<string>) {
delete state.viewCards[action.payload];
},
addBrowserCard(state, action: PayloadAction<{ url: string; expandedSessionIds?: string[] }>) {
const id = `browser-${Date.now().toString(36)}`;
const tabId = generateTabId();
const rects = collectOccupiedRects(state, action.payload.expandedSessionIds);
const pos = findOpenGridCell(rects, DEFAULT_BROWSER_CARD_W, DEFAULT_BROWSER_CARD_H);
state.browserCards[id] = {
browser_id: id,
url: action.payload.url,
tabs: [{ id: tabId, url: action.payload.url, title: '' }],
activeTabId: tabId,
x: pos.x,
y: pos.y,
width: DEFAULT_BROWSER_CARD_W,
height: DEFAULT_BROWSER_CARD_H,
};
},
addBrowserCardFromBackend(state, action: PayloadAction<BrowserCardPosition>) {
const card = action.payload;
if (state.browserCards[card.browser_id]) return;
state.browserCards[card.browser_id] = {
...card,
width: card.width || DEFAULT_BROWSER_CARD_W,
height: card.height || DEFAULT_BROWSER_CARD_H,
};
},
setBrowserCardPosition(
state,
action: PayloadAction<{ browserId: string; x: number; y: number }>
) {
const { browserId, x, y } = action.payload;
const card = state.browserCards[browserId];
if (card) { card.x = x; card.y = y; }
},
setBrowserCardSize(
state,
action: PayloadAction<{ browserId: string; width: number; height: number }>
) {
const { browserId, width, height } = action.payload;
const card = state.browserCards[browserId];
if (card) {
card.width = Math.max(400, width);
card.height = Math.max(300, height);
}
},
removeBrowserCard(state, action: PayloadAction<string>) {
delete state.browserCards[action.payload];
},
updateBrowserCardUrl(
state,
action: PayloadAction<{ browserId: string; url: string }>
) {
const card = state.browserCards[action.payload.browserId];
if (card) {
card.url = action.payload.url;
const tab = card.tabs.find((t) => t.id === card.activeTabId);
if (tab) tab.url = action.payload.url;
}
},
addBrowserTab(
state,
action: PayloadAction<{ browserId: string; url: string; makeActive?: boolean }>
) {
const card = state.browserCards[action.payload.browserId];
if (!card) return;
const tabId = generateTabId();
card.tabs.push({ id: tabId, url: action.payload.url, title: '' });
if (action.payload.makeActive !== false) {
card.activeTabId = tabId;
card.url = action.payload.url;
}
},
removeBrowserTab(
state,
action: PayloadAction<{ browserId: string; tabId: string }>
) {
const card = state.browserCards[action.payload.browserId];
if (!card) return;
const idx = card.tabs.findIndex((t) => t.id === action.payload.tabId);
if (idx === -1) return;
card.tabs.splice(idx, 1);
if (card.tabs.length === 0) {
delete state.browserCards[action.payload.browserId];
return;
}
if (card.activeTabId === action.payload.tabId) {
const newActive = card.tabs[Math.min(idx, card.tabs.length - 1)];
card.activeTabId = newActive.id;
card.url = newActive.url;
}
},
setActiveBrowserTab(
state,
action: PayloadAction<{ browserId: string; tabId: string }>
) {
const card = state.browserCards[action.payload.browserId];
if (!card) return;
const tab = card.tabs.find((t) => t.id === action.payload.tabId);
if (tab) {
card.activeTabId = tab.id;
card.url = tab.url;
}
},
updateBrowserTabUrl(
state,
action: PayloadAction<{ browserId: string; tabId: string; url: string }>
) {
const card = state.browserCards[action.payload.browserId];
if (!card) return;
const tab = card.tabs.find((t) => t.id === action.payload.tabId);
if (tab) {
tab.url = action.payload.url;
if (action.payload.tabId === card.activeTabId) {
card.url = action.payload.url;
}
}
},
updateBrowserTabTitle(
state,
action: PayloadAction<{ browserId: string; tabId: string; title: string }>
) {
const card = state.browserCards[action.payload.browserId];
if (!card) return;
const tab = card.tabs.find((t) => t.id === action.payload.tabId);
if (tab) tab.title = action.payload.title;
},
updateBrowserTabFavicon(
state,
action: PayloadAction<{ browserId: string; tabId: string; favicon: string }>
) {
const card = state.browserCards[action.payload.browserId];
if (!card) return;
const tab = card.tabs.find((t) => t.id === action.payload.tabId);
if (tab) tab.favicon = action.payload.favicon;
},
reorderBrowserTab(
state,
action: PayloadAction<{ browserId: string; tabId: string; toIndex: number }>
) {
const card = state.browserCards[action.payload.browserId];
if (!card) return;
const fromIdx = card.tabs.findIndex((t) => t.id === action.payload.tabId);
if (fromIdx === -1) return;
const [tab] = card.tabs.splice(fromIdx, 1);
card.tabs.splice(Math.max(0, Math.min(action.payload.toIndex, card.tabs.length)), 0, tab);
},
moveCards(
state,
action: PayloadAction<{
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' }>;
dx: number;
dy: number;
}>,
) {
const { items, dx, dy } = action.payload;
for (const item of items) {
if (item.type === 'agent') {
const card = state.cards[item.id];
if (card) {
card.x += dx;
card.y += dy;
}
} else if (item.type === 'view') {
const card = state.viewCards[item.id];
if (card) {
card.x += dx;
card.y += dy;
}
} else {
const card = state.browserCards[item.id];
if (card) {
card.x += dx;
card.y += dy;
}
}
}
},
replaceDraftId(
state,
action: PayloadAction<{ oldId: string; newId: string }>
) {
const { oldId, newId } = action.payload;
const card = state.cards[oldId];
if (card) {
delete state.cards[oldId];
state.cards[newId] = { ...card, session_id: newId };
}
},
setGlowingBrowserCards(
state,
action: PayloadAction<{ browserIds: string[]; sessionId: string }>
) {
const { browserIds, sessionId } = action.payload;
for (const id of browserIds) {
state.glowingBrowserCards[id] = sessionId;
}
},
clearGlowingBrowserCards(state, action: PayloadAction<string>) {
const sessionId = action.payload;
for (const [browserId, sid] of Object.entries(state.glowingBrowserCards)) {
if (sid === sessionId) delete state.glowingBrowserCards[browserId];
}
},
clearAllGlowingBrowserCards(state) {
state.glowingBrowserCards = {};
},
resetLayout(state) {
state.cards = {};
state.viewCards = {};
state.browserCards = {};
state.glowingBrowserCards = {};
state.persistedExpandedSessionIds = [];
state.initialized = false;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchLayout.pending, (state) => {
state.loading = true;
})
.addCase(fetchLayout.fulfilled, (state, action) => {
state.loading = false;
state.initialized = true;
state.cards = action.payload.cards;
state.viewCards = action.payload.viewCards;
state.browserCards = action.payload.browserCards;
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
})
.addCase(fetchLayout.rejected, (state) => {
state.loading = false;
state.initialized = true;
})
.addCase(launchAndSendFirstMessage.fulfilled, (state, action) => {
const { draftId, session } = action.payload;
const card = state.cards[draftId];
if (card) {
delete state.cards[draftId];
state.cards[session.id] = { ...card, session_id: session.id };
}
});
},
});
export const {
setCardPosition,
setCardSize,
removeCard,
reconcileSessions,
replaceDraftId,
tidyLayout,
addViewCard,
setViewCardPosition,
setViewCardSize,
removeViewCard,
addBrowserCard,
addBrowserCardFromBackend,
setBrowserCardPosition,
setBrowserCardSize,
removeBrowserCard,
updateBrowserCardUrl,
addBrowserTab,
removeBrowserTab,
setActiveBrowserTab,
updateBrowserTabUrl,
updateBrowserTabTitle,
updateBrowserTabFavicon,
reorderBrowserTab,
moveCards,
setGlowingBrowserCards,
clearGlowingBrowserCards,
clearAllGlowingBrowserCards,
resetLayout,
} = dashboardLayoutSlice.actions;
export default dashboardLayoutSlice.reducer;