mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-30 13:34:50 +02:00
[Haik]: Functional Browser
This commit is contained in:
@@ -12,3 +12,4 @@ class AppSettings(BaseModel):
|
||||
theme: str = "dark"
|
||||
new_agent_shortcut: str = "Meta+l"
|
||||
anthropic_api_key: Optional[str] = None
|
||||
browser_homepage: str = "https://www.google.com"
|
||||
|
||||
+19
-6
@@ -10,6 +10,7 @@ let backendProcess = null;
|
||||
let backendPort = null;
|
||||
|
||||
const isPackaged = app.isPackaged;
|
||||
const isDev = process.env.ELECTRON_DEV === '1';
|
||||
|
||||
function getResourcePath(...segments) {
|
||||
if (isPackaged) {
|
||||
@@ -122,11 +123,16 @@ function createWindow() {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
},
|
||||
});
|
||||
|
||||
const frontendPath = getResourcePath('frontend', 'index.html');
|
||||
mainWindow.loadFile(frontendPath);
|
||||
if (isDev) {
|
||||
mainWindow.loadURL(`http://localhost:3000`);
|
||||
} else {
|
||||
const frontendPath = getResourcePath('frontend', 'index.html');
|
||||
mainWindow.loadFile(frontendPath);
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
@@ -180,9 +186,16 @@ function killBackend() {
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
try {
|
||||
await startBackend();
|
||||
if (isDev) {
|
||||
backendPort = parseInt(process.env.OPENSWARM_PORT || '8324', 10);
|
||||
console.log(`Dev mode: using existing backend on port ${backendPort}`);
|
||||
} else {
|
||||
await startBackend();
|
||||
}
|
||||
createWindow();
|
||||
setupAutoUpdater();
|
||||
if (!isDev) {
|
||||
setupAutoUpdater();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to start:', err);
|
||||
app.quit();
|
||||
@@ -190,12 +203,12 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
killBackend();
|
||||
if (!isDev) killBackend();
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on('will-quit', () => {
|
||||
killBackend();
|
||||
if (!isDev) killBackend();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "ELECTRON_DEV=1 electron .",
|
||||
"dist": "electron-builder --mac --publish never",
|
||||
"dist:all": "electron-builder --mac --win --linux"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import {
|
||||
setBrowserCardPosition,
|
||||
setBrowserCardSize,
|
||||
removeBrowserCard,
|
||||
updateBrowserCardUrl,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
||||
|
||||
const EDGE_THICKNESS = 6;
|
||||
const CORNER_SIZE = 14;
|
||||
const MIN_W = 400;
|
||||
const MIN_H = 300;
|
||||
|
||||
const CURSOR_MAP: Record<ResizeDir, string> = {
|
||||
n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize',
|
||||
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
|
||||
};
|
||||
|
||||
const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
|
||||
{ dir: 'n', sx: { top: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
|
||||
{ dir: 's', sx: { bottom: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
|
||||
{ dir: 'w', sx: { left: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
|
||||
{ dir: 'e', sx: { right: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
|
||||
{ dir: 'nw', sx: { top: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
|
||||
{ dir: 'ne', sx: { top: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
|
||||
{ dir: 'sw', sx: { bottom: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
|
||||
{ dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
|
||||
];
|
||||
|
||||
const isElectron = navigator.userAgent.includes('Electron');
|
||||
|
||||
interface WebviewElement extends HTMLElement {
|
||||
src: string;
|
||||
loadURL: (url: string) => Promise<void>;
|
||||
goBack: () => void;
|
||||
goForward: () => void;
|
||||
reload: () => void;
|
||||
canGoBack: () => boolean;
|
||||
canGoForward: () => boolean;
|
||||
getURL: () => string;
|
||||
getTitle: () => string;
|
||||
addEventListener: (event: string, listener: (...args: any[]) => void) => void;
|
||||
removeEventListener: (event: string, listener: (...args: any[]) => void) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
browserId: string;
|
||||
url: string;
|
||||
cardX: number;
|
||||
cardY: number;
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
zoom?: number;
|
||||
isSelected?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser', shiftKey: boolean) => void;
|
||||
onDragStart?: (id: string, type: 'agent' | 'view' | 'browser') => void;
|
||||
onDragMove?: (dx: number, dy: number) => void;
|
||||
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
|
||||
}
|
||||
|
||||
function ensureProtocol(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
if (/^[a-zA-Z0-9-]+\.[a-zA-Z]{2,}/.test(trimmed)) return `https://${trimmed}`;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const BrowserCard: React.FC<Props> = ({
|
||||
browserId, url, cardX, cardY, cardWidth, cardHeight, zoom = 1,
|
||||
isSelected = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const webviewRef = useRef<WebviewElement | null>(null);
|
||||
|
||||
const [currentUrl, setCurrentUrl] = useState(url);
|
||||
const [urlBarValue, setUrlBarValue] = useState(url);
|
||||
const [pageTitle, setPageTitle] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
const [canGoForward, setCanGoForward] = useState(false);
|
||||
|
||||
// ---- Webview event wiring ----
|
||||
useEffect(() => {
|
||||
if (!isElectron) return;
|
||||
const wv = webviewRef.current;
|
||||
if (!wv) return;
|
||||
|
||||
const onNavigate = () => {
|
||||
const newUrl = wv.getURL();
|
||||
setCurrentUrl(newUrl);
|
||||
setUrlBarValue(newUrl);
|
||||
setCanGoBack(wv.canGoBack());
|
||||
setCanGoForward(wv.canGoForward());
|
||||
dispatch(updateBrowserCardUrl({ browserId, url: newUrl }));
|
||||
};
|
||||
|
||||
const onTitleUpdate = () => {
|
||||
setPageTitle(wv.getTitle());
|
||||
};
|
||||
|
||||
const onLoadStart = () => setLoading(true);
|
||||
const onLoadStop = () => {
|
||||
setLoading(false);
|
||||
onNavigate();
|
||||
onTitleUpdate();
|
||||
};
|
||||
|
||||
const onNewWindow = (e: any) => {
|
||||
if (e.url) wv.loadURL(e.url);
|
||||
};
|
||||
|
||||
wv.addEventListener('did-navigate', onNavigate);
|
||||
wv.addEventListener('did-navigate-in-page', onNavigate);
|
||||
wv.addEventListener('page-title-updated', onTitleUpdate);
|
||||
wv.addEventListener('did-start-loading', onLoadStart);
|
||||
wv.addEventListener('did-stop-loading', onLoadStop);
|
||||
wv.addEventListener('new-window', onNewWindow);
|
||||
|
||||
return () => {
|
||||
wv.removeEventListener('did-navigate', onNavigate);
|
||||
wv.removeEventListener('did-navigate-in-page', onNavigate);
|
||||
wv.removeEventListener('page-title-updated', onTitleUpdate);
|
||||
wv.removeEventListener('did-start-loading', onLoadStart);
|
||||
wv.removeEventListener('did-stop-loading', onLoadStop);
|
||||
wv.removeEventListener('new-window', onNewWindow);
|
||||
};
|
||||
}, [browserId, dispatch]);
|
||||
|
||||
const navigate = useCallback((targetUrl: string) => {
|
||||
const finalUrl = ensureProtocol(targetUrl);
|
||||
setUrlBarValue(finalUrl);
|
||||
if (isElectron && webviewRef.current) {
|
||||
webviewRef.current.loadURL(finalUrl);
|
||||
}
|
||||
setCurrentUrl(finalUrl);
|
||||
dispatch(updateBrowserCardUrl({ browserId, url: finalUrl }));
|
||||
}, [browserId, dispatch]);
|
||||
|
||||
const handleUrlKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
navigate(urlBarValue);
|
||||
}
|
||||
}, [navigate, urlBarValue]);
|
||||
|
||||
const handleBack = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
webviewRef.current?.goBack();
|
||||
}, []);
|
||||
|
||||
const handleForward = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
webviewRef.current?.goForward();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
webviewRef.current?.reload();
|
||||
}, []);
|
||||
|
||||
const handleRemove = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dispatch(removeBrowserCard(browserId));
|
||||
}, [dispatch, browserId]);
|
||||
|
||||
// ---- Drag via header ----
|
||||
const DRAG_THRESHOLD = 3;
|
||||
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const didDrag = useRef(false);
|
||||
const justDraggedRef = useRef(false);
|
||||
|
||||
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY };
|
||||
didDrag.current = false;
|
||||
setIsDragging(true);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
onDragStart?.(browserId, 'browser');
|
||||
}, [cardX, cardY, onDragStart, browserId]);
|
||||
|
||||
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const rawDx = e.clientX - dragState.current.startX;
|
||||
const rawDy = e.clientY - dragState.current.startY;
|
||||
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
|
||||
didDrag.current = true;
|
||||
const dx = rawDx / zoom;
|
||||
const dy = rawDy / zoom;
|
||||
setLocalDragPos({
|
||||
x: dragState.current.origX + dx,
|
||||
y: dragState.current.origY + dy,
|
||||
});
|
||||
onDragMove?.(dx, dy);
|
||||
}, [zoom, onDragMove]);
|
||||
|
||||
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const dx = (e.clientX - dragState.current.startX) / zoom;
|
||||
const dy = (e.clientY - dragState.current.startY) / zoom;
|
||||
if (didDrag.current) {
|
||||
dispatch(setBrowserCardPosition({
|
||||
browserId,
|
||||
x: dragState.current.origX + dx,
|
||||
y: dragState.current.origY + dy,
|
||||
}));
|
||||
justDraggedRef.current = true;
|
||||
requestAnimationFrame(() => { justDraggedRef.current = false; });
|
||||
}
|
||||
onDragEnd?.(dx, dy, didDrag.current);
|
||||
dragState.current = null;
|
||||
didDrag.current = false;
|
||||
setLocalDragPos(null);
|
||||
setIsDragging(false);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [zoom, dispatch, browserId, onDragEnd]);
|
||||
|
||||
// ---- Resize ----
|
||||
const resizeRef = useRef<{
|
||||
dir: ResizeDir; startX: number; startY: number;
|
||||
origX: number; origY: number; origW: number; origH: number;
|
||||
} | null>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
|
||||
const handleResizeDown = useCallback(
|
||||
(dir: ResizeDir) => (e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resizeRef.current = {
|
||||
dir, startX: e.clientX, startY: e.clientY,
|
||||
origX: cardX, origY: cardY, origW: cardWidth, origH: cardHeight,
|
||||
};
|
||||
setIsResizing(true);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[cardX, cardY, cardWidth, cardHeight],
|
||||
);
|
||||
|
||||
const computeResize = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return null;
|
||||
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
|
||||
const dx = (e.clientX - startX) / zoom;
|
||||
const dy = (e.clientY - startY) / zoom;
|
||||
let newX = origX, newY = origY, newW = origW, newH = origH;
|
||||
if (dir.includes('e')) newW = origW + dx;
|
||||
if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; }
|
||||
if (dir.includes('s')) newH = origH + dy;
|
||||
if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; }
|
||||
if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; }
|
||||
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
|
||||
return { x: newX, y: newY, w: newW, h: newH };
|
||||
},
|
||||
[zoom],
|
||||
);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const result = computeResize(e);
|
||||
if (result) setLocalResize(result);
|
||||
},
|
||||
[computeResize],
|
||||
);
|
||||
|
||||
const handleResizeUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const result = computeResize(e);
|
||||
if (result) {
|
||||
dispatch(setBrowserCardPosition({ browserId, x: result.x, y: result.y }));
|
||||
dispatch(setBrowserCardSize({ browserId, width: result.w, height: result.h }));
|
||||
}
|
||||
resizeRef.current = null;
|
||||
setLocalResize(null);
|
||||
setIsResizing(false);
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [computeResize, dispatch, browserId]);
|
||||
|
||||
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
|
||||
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
|
||||
const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
|
||||
const displayY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy);
|
||||
const displayW = localResize?.w ?? cardWidth;
|
||||
const displayH = localResize?.h ?? cardHeight;
|
||||
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
|
||||
|
||||
const isSecure = currentUrl.startsWith('https://');
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-select-type="browser-card"
|
||||
data-select-id={browserId}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (justDraggedRef.current) return;
|
||||
onCardSelect?.(browserId, 'browser', e.shiftKey);
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: displayX,
|
||||
top: displayY,
|
||||
width: displayW,
|
||||
height: displayH,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
border: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`,
|
||||
bgcolor: c.bg.surface,
|
||||
boxShadow: isDragging || isResizing
|
||||
? c.shadow.lg
|
||||
: isSelected
|
||||
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
|
||||
: c.shadow.md,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: (isDragging || isResizing) ? 100 : 1,
|
||||
transition: noTransition ? 'none' : 'box-shadow 0.2s',
|
||||
'&:hover .resize-handle': { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
{/* Header / drag handle */}
|
||||
<Box
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
onPointerUp={handleDragPointerUp}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
bgcolor: c.bg.secondary,
|
||||
borderBottom: `1px solid ${c.border.subtle}`,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
flexShrink: 0,
|
||||
minHeight: 36,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<LanguageIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />
|
||||
<Typography
|
||||
sx={{
|
||||
flex: 1,
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
color: c.text.primary,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{pageTitle || 'Browser'}
|
||||
</Typography>
|
||||
|
||||
<Tooltip title="Back" placement="top">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleBack}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!canGoBack}
|
||||
sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<ArrowBackIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Forward" placement="top">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleForward}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!canGoForward}
|
||||
sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<ArrowForwardIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Reload" placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<RefreshIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close browser" placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ color: c.text.ghost, p: 0.4, '&:hover': { color: c.status.error } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* URL bar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
px: 1,
|
||||
py: 0.4,
|
||||
bgcolor: c.bg.page,
|
||||
borderBottom: `1px solid ${c.border.subtle}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{isSecure && (
|
||||
<LockIcon sx={{ fontSize: 13, color: c.status.success, flexShrink: 0 }} />
|
||||
)}
|
||||
<InputBase
|
||||
value={urlBarValue}
|
||||
onChange={(e) => setUrlBarValue(e.target.value)}
|
||||
onKeyDown={handleUrlKeyDown}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onFocus={(e) => (e.target as HTMLInputElement).select()}
|
||||
placeholder="Enter URL..."
|
||||
sx={{
|
||||
flex: 1,
|
||||
fontSize: '0.76rem',
|
||||
fontFamily: c.font.mono,
|
||||
color: c.text.secondary,
|
||||
py: 0,
|
||||
'& input': { py: '3px' },
|
||||
'& input::placeholder': { color: c.text.ghost, opacity: 1 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loading && (
|
||||
<LinearProgress
|
||||
sx={{
|
||||
height: 2,
|
||||
flexShrink: 0,
|
||||
bgcolor: 'transparent',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: c.accent.primary },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Browser body */}
|
||||
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
|
||||
{isElectron ? (
|
||||
<webview
|
||||
ref={webviewRef as any}
|
||||
src={currentUrl}
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<iframe
|
||||
src={currentUrl}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
title="Browser"
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bgcolor: `${c.status.warningBg}`,
|
||||
borderTop: `1px solid ${c.status.warning}`,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.status.warning }}>
|
||||
iframe mode — some sites may not load. Use the Electron build for full browser support.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Resize handles */}
|
||||
{HANDLE_DEFS.map(({ dir, sx }) => (
|
||||
<Box
|
||||
key={dir}
|
||||
className="resize-handle"
|
||||
onPointerDown={handleResizeDown(dir)}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeUp}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
cursor: CURSOR_MAP[dir],
|
||||
opacity: 0,
|
||||
zIndex: 10,
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrowserCard;
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
reconcileSessions,
|
||||
tidyLayout,
|
||||
addViewCard,
|
||||
addBrowserCard,
|
||||
moveCards,
|
||||
resetLayout,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
@@ -28,6 +29,7 @@ import { fetchOutputs } from '@/shared/state/outputsSlice';
|
||||
import { dashboardWs } from '@/shared/ws/WebSocketManager';
|
||||
import AgentCard from './AgentCard';
|
||||
import DashboardViewCard from './DashboardViewCard';
|
||||
import BrowserCard from './BrowserCard';
|
||||
import CanvasControls from './CanvasControls';
|
||||
import DashboardToolbar from './DashboardToolbar';
|
||||
import { useCanvasControls } from './useCanvasControls';
|
||||
@@ -66,9 +68,11 @@ const DashboardInner: React.FC = () => {
|
||||
const expandedSessionIds = useAppSelector((state) => state.agents.expandedSessionIds);
|
||||
const cards = useAppSelector((state) => state.dashboardLayout.cards);
|
||||
const viewCards = useAppSelector((state) => state.dashboardLayout.viewCards);
|
||||
const browserCards = useAppSelector((state) => state.dashboardLayout.browserCards);
|
||||
const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized);
|
||||
const zoomSensitivity = useAppSelector((state) => state.settings.data.zoom_sensitivity);
|
||||
const newAgentShortcut = useAppSelector((state) => state.settings.data.new_agent_shortcut);
|
||||
const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage);
|
||||
const outputs = useAppSelector((state) => state.outputs.items);
|
||||
const sessionList = Object.values(sessions);
|
||||
|
||||
@@ -77,6 +81,7 @@ const DashboardInner: React.FC = () => {
|
||||
{ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom, viewportRef: canvas.viewportRef },
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -183,6 +188,7 @@ const DashboardInner: React.FC = () => {
|
||||
|
||||
const cardsJson = JSON.stringify(cards);
|
||||
const viewCardsJson = JSON.stringify(viewCards);
|
||||
const browserCardsJson = JSON.stringify(browserCards);
|
||||
const skipInitialSave = useRef(true);
|
||||
useEffect(() => {
|
||||
if (!layoutInitialized || !dashboardId) return;
|
||||
@@ -190,8 +196,8 @@ const DashboardInner: React.FC = () => {
|
||||
skipInitialSave.current = false;
|
||||
return;
|
||||
}
|
||||
dispatch(saveLayout({ dashboardId, cards, viewCards }));
|
||||
}, [cardsJson, viewCardsJson, layoutInitialized, dashboardId]);
|
||||
dispatch(saveLayout({ dashboardId, cards, viewCards, browserCards }));
|
||||
}, [cardsJson, viewCardsJson, browserCardsJson, layoutInitialized, dashboardId]);
|
||||
|
||||
useEffect(() => {
|
||||
const parts = newAgentShortcut.toLowerCase().split('+');
|
||||
@@ -282,6 +288,10 @@ const DashboardInner: React.FC = () => {
|
||||
dispatch(addViewCard({ outputId }));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleAddBrowser = useCallback(() => {
|
||||
dispatch(addBrowserCard({ url: browserHomepage }));
|
||||
}, [dispatch, browserHomepage]);
|
||||
|
||||
const handleHistoryResume = useCallback((sessionId: string) => {
|
||||
dispatch(resumeSession({ sessionId })).then((action) => {
|
||||
if (resumeSession.fulfilled.match(action)) {
|
||||
@@ -294,10 +304,11 @@ const DashboardInner: React.FC = () => {
|
||||
dispatch(collapseAllSessions());
|
||||
dispatch(tidyLayout());
|
||||
|
||||
const { cards: tidied, viewCards: tidiedViews } = store.getState().dashboardLayout;
|
||||
const { cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers } = store.getState().dashboardLayout;
|
||||
const allRects = [
|
||||
...Object.values(tidied).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
...Object.values(tidiedViews).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
...Object.values(tidiedBrowsers).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
];
|
||||
canvas.actions.fitToCards(allRects);
|
||||
}, [dispatch, canvas.actions]);
|
||||
@@ -389,7 +400,7 @@ const DashboardInner: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{sessionList.length === 0 && Object.keys(viewCards).length === 0 ? (
|
||||
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
@@ -464,6 +475,24 @@ const DashboardInner: React.FC = () => {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{Object.values(browserCards).map((bc) => (
|
||||
<BrowserCard
|
||||
key={`browser-${bc.browser_id}`}
|
||||
browserId={bc.browser_id}
|
||||
url={bc.url}
|
||||
cardX={bc.x}
|
||||
cardY={bc.y}
|
||||
cardWidth={bc.width}
|
||||
cardHeight={bc.height}
|
||||
zoom={canvas.zoom}
|
||||
isSelected={selection.isSelected(bc.browser_id)}
|
||||
multiDragDelta={multiDragDelta}
|
||||
onCardSelect={handleCardSelect}
|
||||
onDragStart={handleCardDragStart}
|
||||
onDragMove={handleCardDragMove}
|
||||
onDragEnd={handleCardDragEnd}
|
||||
/>
|
||||
))}
|
||||
{/* Marquee selection rectangle */}
|
||||
{selection.marquee && (
|
||||
<div
|
||||
@@ -495,6 +524,7 @@ const DashboardInner: React.FC = () => {
|
||||
onSend={handleToolbarSend}
|
||||
onAddView={handleAddView}
|
||||
onHistoryResume={handleHistoryResume}
|
||||
onAddBrowser={handleAddBrowser}
|
||||
dashboardId={dashboardId}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -10,6 +10,7 @@ import AddIcon from '@mui/icons-material/Add';
|
||||
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
|
||||
import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined';
|
||||
import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { motion } from 'framer-motion';
|
||||
import ChatInput from '@/app/pages/AgentChat/ChatInput';
|
||||
@@ -36,6 +37,7 @@ interface Props {
|
||||
) => void;
|
||||
onAddView: (outputId: string) => void;
|
||||
onHistoryResume: (sessionId: string) => void;
|
||||
onAddBrowser: () => void;
|
||||
dashboardId?: string;
|
||||
}
|
||||
|
||||
@@ -79,7 +81,7 @@ function formatRelativeTime(dateStr: string | null): string {
|
||||
}
|
||||
|
||||
const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, dashboardId }, ref) => {
|
||||
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, dashboardId }, ref) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const elementSelection = useElementSelection();
|
||||
@@ -623,6 +625,39 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
</Box>
|
||||
</WarmTooltip>
|
||||
|
||||
<WarmTooltip
|
||||
tokens={c}
|
||||
placement="top"
|
||||
arrow
|
||||
enterDelay={200}
|
||||
title={
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ fontWeight: 600 }}>Browser</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Browser"
|
||||
tabIndex={0}
|
||||
onClick={onAddBrowser}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: BTN,
|
||||
height: BTN,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
color: c.text.tertiary,
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.15s, background-color 0.15s',
|
||||
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
|
||||
}}
|
||||
>
|
||||
<LanguageIcon sx={{ fontSize: 22 }} />
|
||||
</Box>
|
||||
</WarmTooltip>
|
||||
|
||||
{placeholderItems.map(({ icon: PlaceholderIcon, label, sub }) => (
|
||||
<WarmTooltip
|
||||
key={label}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useRef, useEffect, RefObject } from 'react';
|
||||
import type { CardPosition, ViewCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
export type CardType = 'agent' | 'view';
|
||||
export type CardType = 'agent' | 'view' | 'browser';
|
||||
|
||||
export interface SelectedCard {
|
||||
id: string;
|
||||
@@ -40,6 +40,7 @@ export function useDashboardSelection(
|
||||
canvas: ScreenToCanvas,
|
||||
cards: Record<string, CardPosition>,
|
||||
viewCards: Record<string, ViewCardPosition>,
|
||||
browserCards: Record<string, BrowserCardPosition> = {},
|
||||
) {
|
||||
const [selectedIds, setSelectedIds] = useState<Map<string, CardType>>(new Map());
|
||||
const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
|
||||
@@ -118,6 +119,19 @@ export function useDashboardSelection(
|
||||
}
|
||||
}
|
||||
|
||||
for (const bc of Object.values(browserCards)) {
|
||||
if (
|
||||
rectsIntersect(rect, {
|
||||
x: bc.x,
|
||||
y: bc.y,
|
||||
width: bc.width,
|
||||
height: bc.height,
|
||||
})
|
||||
) {
|
||||
intersecting.set(bc.browser_id, 'browser');
|
||||
}
|
||||
}
|
||||
|
||||
if (shiftKey) {
|
||||
const base = selectionBeforeMarqueeRef.current;
|
||||
const next = new Map(base);
|
||||
@@ -133,7 +147,7 @@ export function useDashboardSelection(
|
||||
|
||||
return intersecting;
|
||||
},
|
||||
[cards, viewCards],
|
||||
[cards, viewCards, browserCards],
|
||||
);
|
||||
|
||||
const handleCanvasMouseDown = useCallback(
|
||||
|
||||
@@ -25,6 +25,7 @@ import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import KeyboardIcon from '@mui/icons-material/Keyboard';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { updateSettings, closeSettingsModal, AppSettings } from '@/shared/state/settingsSlice';
|
||||
import { fetchModes } from '@/shared/state/modesSlice';
|
||||
@@ -444,6 +445,33 @@ const Settings: React.FC = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ── Browser ── */}
|
||||
<Typography sx={{ ...sectionSx, mt: 3 }}>Browser</Typography>
|
||||
|
||||
<Box sx={rowLastSx}>
|
||||
<Typography sx={labelSx}>Default homepage</Typography>
|
||||
<Typography sx={{ ...descSx, mb: 1.5 }}>
|
||||
URL loaded when opening a new browser card on the dashboard.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<LanguageIcon sx={{ fontSize: 18, color: c.text.tertiary, flexShrink: 0 }} />
|
||||
<TextField
|
||||
value={form.browser_homepage}
|
||||
onChange={(e) => setForm({ ...form, browser_homepage: e.target.value })}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="https://www.google.com"
|
||||
sx={{
|
||||
...fieldSx,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
...fieldSx['& .MuiOutlinedInput-root'],
|
||||
fontFamily: c.font.mono,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ── API ── */}
|
||||
<Typography sx={{ ...sectionSx, mt: 3 }}>API</Typography>
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ export const DEFAULT_CARD_W = 480;
|
||||
export const DEFAULT_CARD_H = 280;
|
||||
export const DEFAULT_VIEW_CARD_W = 480;
|
||||
export const DEFAULT_VIEW_CARD_H = 360;
|
||||
export const DEFAULT_BROWSER_CARD_W = 640;
|
||||
export const DEFAULT_BROWSER_CARD_H = 480;
|
||||
const GRID_GAP = 24;
|
||||
const GRID_ORIGIN = { x: 40, y: 100 };
|
||||
const GRID_COLS_FALLBACK = 4;
|
||||
@@ -28,9 +30,19 @@ export interface ViewCardPosition {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface BrowserCardPosition {
|
||||
browser_id: string;
|
||||
url: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DashboardLayoutState {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
loading: boolean;
|
||||
initialized: boolean;
|
||||
}
|
||||
@@ -38,6 +50,7 @@ export interface DashboardLayoutState {
|
||||
const initialState: DashboardLayoutState = {
|
||||
cards: {},
|
||||
viewCards: {},
|
||||
browserCards: {},
|
||||
loading: false,
|
||||
initialized: false,
|
||||
};
|
||||
@@ -45,6 +58,7 @@ const initialState: DashboardLayoutState = {
|
||||
interface LayoutPayload {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
}
|
||||
|
||||
export const fetchLayout = createAsyncThunk(
|
||||
@@ -56,6 +70,7 @@ export const fetchLayout = createAsyncThunk(
|
||||
return {
|
||||
cards: (layout.cards ?? {}) as Record<string, CardPosition>,
|
||||
viewCards: (layout.view_cards ?? {}) as Record<string, ViewCardPosition>,
|
||||
browserCards: (layout.browser_cards ?? {}) as Record<string, BrowserCardPosition>,
|
||||
} satisfies LayoutPayload;
|
||||
},
|
||||
);
|
||||
@@ -76,7 +91,7 @@ export const saveLayout = createAsyncThunk(
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
layout: { cards: payload.cards, view_cards: payload.viewCards },
|
||||
layout: { cards: payload.cards, view_cards: payload.viewCards, browser_cards: payload.browserCards },
|
||||
}),
|
||||
});
|
||||
resolve(payload);
|
||||
@@ -166,10 +181,14 @@ const dashboardLayoutSlice = createSlice({
|
||||
}
|
||||
|
||||
const hasDraftCard = Object.keys(state.cards).some((id) => id.startsWith('draft-'));
|
||||
const extraOccupied: Record<string, { x: number; y: number }> = {
|
||||
...Object.fromEntries(Object.values(state.viewCards).map((c) => [c.output_id, c])),
|
||||
...Object.fromEntries(Object.values(state.browserCards).map((c) => [c.browser_id, c])),
|
||||
};
|
||||
const newIds = action.payload.filter((id) => !state.cards[id]);
|
||||
for (const id of newIds) {
|
||||
if (hasDraftCard && !id.startsWith('draft-')) continue;
|
||||
const pos = findOpenGridCell(state.cards, new Set(), state.viewCards);
|
||||
const pos = findOpenGridCell(state.cards, new Set(), extraOccupied);
|
||||
state.cards[id] = {
|
||||
session_id: id,
|
||||
x: pos.x,
|
||||
@@ -183,12 +202,14 @@ const dashboardLayoutSlice = createSlice({
|
||||
tidyLayout(state) {
|
||||
const agentCards = Object.values(state.cards);
|
||||
const viewCards = Object.values(state.viewCards);
|
||||
const total = agentCards.length + viewCards.length;
|
||||
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 })),
|
||||
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y })),
|
||||
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y })),
|
||||
];
|
||||
allItems.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
|
||||
@@ -211,9 +232,12 @@ const dashboardLayoutSlice = createSlice({
|
||||
if (item.kind === 'agent') {
|
||||
const card = state.cards[item.id];
|
||||
if (card) { card.x = nx; card.y = ny; card.width = DEFAULT_CARD_W; card.height = DEFAULT_CARD_H; }
|
||||
} else {
|
||||
} else if (item.kind === 'view') {
|
||||
const card = state.viewCards[item.id];
|
||||
if (card) { card.x = nx; card.y = ny; card.width = DEFAULT_VIEW_CARD_W; card.height = DEFAULT_VIEW_CARD_H; }
|
||||
} else {
|
||||
const card = state.browserCards[item.id];
|
||||
if (card) { card.x = nx; card.y = ny; card.width = DEFAULT_BROWSER_CARD_W; card.height = DEFAULT_BROWSER_CARD_H; }
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -256,10 +280,61 @@ const dashboardLayoutSlice = createSlice({
|
||||
delete state.viewCards[action.payload];
|
||||
},
|
||||
|
||||
addBrowserCard(state, action: PayloadAction<{ url: string }>) {
|
||||
const id = `browser-${Date.now().toString(36)}`;
|
||||
const allOccupied: Record<string, { x: number; y: number }> = {
|
||||
...Object.fromEntries(Object.values(state.viewCards).map((c) => [c.output_id, c])),
|
||||
...Object.fromEntries(Object.values(state.browserCards).map((c) => [c.browser_id, c])),
|
||||
};
|
||||
const pos = findOpenGridCell(state.cards, new Set(), allOccupied);
|
||||
state.browserCards[id] = {
|
||||
browser_id: id,
|
||||
url: action.payload.url,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: DEFAULT_BROWSER_CARD_W,
|
||||
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 { browserId, url } = action.payload;
|
||||
const card = state.browserCards[browserId];
|
||||
if (card) { card.url = url; }
|
||||
},
|
||||
|
||||
moveCards(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
items: Array<{ id: string; type: 'agent' | 'view' }>;
|
||||
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' }>;
|
||||
dx: number;
|
||||
dy: number;
|
||||
}>,
|
||||
@@ -272,12 +347,18 @@ const dashboardLayoutSlice = createSlice({
|
||||
card.x += dx;
|
||||
card.y += dy;
|
||||
}
|
||||
} else {
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -297,6 +378,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
resetLayout(state) {
|
||||
state.cards = {};
|
||||
state.viewCards = {};
|
||||
state.browserCards = {};
|
||||
state.initialized = false;
|
||||
},
|
||||
|
||||
@@ -311,6 +393,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.initialized = true;
|
||||
state.cards = action.payload.cards;
|
||||
state.viewCards = action.payload.viewCards;
|
||||
state.browserCards = action.payload.browserCards;
|
||||
})
|
||||
.addCase(fetchLayout.rejected, (state) => {
|
||||
state.loading = false;
|
||||
@@ -338,6 +421,11 @@ export const {
|
||||
setViewCardPosition,
|
||||
setViewCardSize,
|
||||
removeViewCard,
|
||||
addBrowserCard,
|
||||
setBrowserCardPosition,
|
||||
setBrowserCardSize,
|
||||
removeBrowserCard,
|
||||
updateBrowserCardUrl,
|
||||
moveCards,
|
||||
resetLayout,
|
||||
} = dashboardLayoutSlice.actions;
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface AppSettings {
|
||||
theme: 'light' | 'dark';
|
||||
new_agent_shortcut: string;
|
||||
anthropic_api_key: string | null;
|
||||
browser_homepage: string;
|
||||
}
|
||||
|
||||
export interface BrowseResult {
|
||||
@@ -40,6 +41,7 @@ const initialState: SettingsState = {
|
||||
theme: 'dark',
|
||||
new_agent_shortcut: 'Meta+l',
|
||||
anthropic_api_key: null,
|
||||
browser_homepage: 'https://www.google.com',
|
||||
},
|
||||
loading: false,
|
||||
loaded: false,
|
||||
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
declare namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
webview: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement> & {
|
||||
src?: string;
|
||||
preload?: string;
|
||||
partition?: string;
|
||||
allowpopups?: string;
|
||||
nodeintegration?: string;
|
||||
},
|
||||
HTMLElement
|
||||
>;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ RESET='\033[0m'
|
||||
|
||||
BACKEND_PID=""
|
||||
FRONTEND_PID=""
|
||||
ELECTRON_PID=""
|
||||
SHUTTING_DOWN=false
|
||||
|
||||
kill_tree() {
|
||||
@@ -38,14 +39,14 @@ cleanup() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}${BOLD}Gracefully shutting down all services...${RESET}"
|
||||
|
||||
for pid in $BACKEND_PID $FRONTEND_PID; do
|
||||
for pid in $ELECTRON_PID $BACKEND_PID $FRONTEND_PID; do
|
||||
[[ -n "$pid" ]] && kill_tree "$pid" TERM
|
||||
done
|
||||
|
||||
local elapsed=0
|
||||
while (( elapsed < 5 )); do
|
||||
local alive=false
|
||||
for pid in $BACKEND_PID $FRONTEND_PID; do
|
||||
for pid in $ELECTRON_PID $BACKEND_PID $FRONTEND_PID; do
|
||||
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && alive=true
|
||||
done
|
||||
$alive || break
|
||||
@@ -53,7 +54,7 @@ cleanup() {
|
||||
((elapsed++))
|
||||
done
|
||||
|
||||
for pid in $BACKEND_PID $FRONTEND_PID; do
|
||||
for pid in $ELECTRON_PID $BACKEND_PID $FRONTEND_PID; do
|
||||
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && kill_tree "$pid" KILL
|
||||
done
|
||||
|
||||
@@ -104,13 +105,46 @@ bash "$PROJECT_ROOT/frontend/run.sh" > >(
|
||||
) 2>&1 &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
# --- Wait for frontend dev server to become available ---
|
||||
echo -e "${YELLOW}${BOLD}Waiting for frontend (http://localhost:3000) to be ready...${RESET}"
|
||||
FRONTEND_MAX_WAIT=60
|
||||
frontend_elapsed=0
|
||||
while (( frontend_elapsed < FRONTEND_MAX_WAIT )); do
|
||||
if curl -s -o /dev/null --connect-timeout 1 http://localhost:3000/ 2>/dev/null; then
|
||||
echo -e "${GREEN}${BOLD}Frontend is ready!${RESET}"
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$FRONTEND_PID" 2>/dev/null; then
|
||||
echo -e "${RED}${BOLD}Frontend process exited before becoming ready.${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
((frontend_elapsed += 2))
|
||||
done
|
||||
|
||||
if (( frontend_elapsed >= FRONTEND_MAX_WAIT )); then
|
||||
echo -e "${RED}${BOLD}Frontend did not become ready within ${FRONTEND_MAX_WAIT}s. Aborting.${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Start Electron in dev mode ---
|
||||
MAGENTA='\033[0;35m'
|
||||
echo -e "${MAGENTA}${BOLD}[electron]${RESET} Launching Electron dev shell..."
|
||||
(cd "$PROJECT_ROOT/electron" && ELECTRON_DEV=1 npx electron .) > >(
|
||||
while IFS= read -r line; do
|
||||
printf "${MAGENTA}${BOLD}[electron]${RESET} %s\n" "$line"
|
||||
done
|
||||
) 2>&1 &
|
||||
ELECTRON_PID=$!
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}Both services are running. Press Ctrl+C to stop.${RESET}"
|
||||
echo -e "${BOLD}All services are running. Press Ctrl+C to stop.${RESET}"
|
||||
echo -e " Backend: ${BLUE}http://localhost:8324${RESET}"
|
||||
echo -e " Frontend: ${GREEN}http://localhost:3000${RESET}"
|
||||
echo -e " Electron: ${MAGENTA}dev shell (pid $ELECTRON_PID)${RESET}"
|
||||
echo ""
|
||||
|
||||
# --- Monitor: if either service exits, tear down both ---
|
||||
# --- Monitor: if any service exits, tear down all ---
|
||||
while ! $SHUTTING_DOWN; do
|
||||
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
|
||||
echo -e "${RED}${BOLD}Backend process exited unexpectedly. Shutting down...${RESET}"
|
||||
@@ -120,5 +154,9 @@ while ! $SHUTTING_DOWN; do
|
||||
echo -e "${RED}${BOLD}Frontend process exited unexpectedly. Shutting down...${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$ELECTRON_PID" ]] && ! kill -0 "$ELECTRON_PID" 2>/dev/null; then
|
||||
echo -e "${YELLOW}${BOLD}Electron process exited. Shutting down...${RESET}"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
|
||||
Reference in New Issue
Block a user