[Haik]: Functional Browser

This commit is contained in:
haikdc
2026-03-15 02:15:59 -07:00
parent 7244967267
commit c728b462d6
12 changed files with 821 additions and 25 deletions
+1
View File
@@ -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
View File
@@ -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', () => {
+1
View File
@@ -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;
+34 -4
View File
@@ -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,
+14
View File
@@ -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
>;
}
}
+43 -5
View File
@@ -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