[Haik]: ckpt (shld have fixed tool mcp discovery in prod - but untested) (Added browser control animations) (Made browser top input function as search) (swapped run.sh to not open up site locally in addition to the electron app)

This commit is contained in:
haikdc
2026-03-16 00:27:34 -07:00
parent 61755e60eb
commit 08cab727a1
16 changed files with 296 additions and 52 deletions
+4 -4
View File
@@ -7,7 +7,7 @@
<p align="center">
<strong>An Army of AI Agents at Your Fingertips</strong>
<br>
A locally-running orchestrator for managing multiple Claude Code instances in parallel.
A locally-running orchestrator for managing multiple agents in parallel.
<br>
Launch, monitor, and coordinate entire swarms of coding agents from a single interface.
</p>
@@ -29,9 +29,9 @@
## Why Open Swarm?
Running Claude Code in a terminal works fine for one task. But when you're juggling five agents across different branches, approving tool calls in separate windows, and losing track of who's doing what — it falls apart fast.
Running agents in a terminal works fine for one task. But when you're juggling five agents across different branches, approving tool calls in separate windows, and losing track of who's doing what — it falls apart fast.
- **Parallel agents, one screen** — Launch as many Claude Code instances as you need, arranged on a spatial canvas you can pan and zoom freely
- **Parallel agents, one screen** — Launch as many agents as you need, arranged on a spatial canvas you can pan and zoom freely
- **Unified approval workflow** — Every tool-use request from every agent surfaces in one place. Approve or deny with a click or a keyboard shortcut.
- **Full conversation control** — Edit prior messages to fork conversations, navigate between branches, resume closed sessions
- **100% local** — Everything runs on your machine. No cloud relay, no telemetry, no third-party backend.
@@ -64,7 +64,7 @@ Running Claude Code in a terminal works fine for one task. But when you're juggl
**Cost Tracking** — Real-time USD spend tracking per agent session.
**Dark & Light Themes** — Full theme support with Claude-inspired design tokens.
**Dark & Light Themes** — Full theme support with design tokens.
**Keyboard Shortcuts** — Navigate between agents, approve/deny requests, and switch pages without touching a mouse.
+49 -13
View File
@@ -285,25 +285,58 @@ def _sanitize_server_name(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
def _extra_bin_dirs() -> list[str]:
"""Well-known user-local bin directories that may not be on PATH in packaged apps."""
home = os.path.expanduser("~")
dirs = [
os.path.join(home, ".bun", "bin"),
os.path.join(home, ".cargo", "bin"),
os.path.join(home, ".local", "bin"),
os.path.join(home, ".volta", "bin"),
"/opt/homebrew/bin",
"/usr/local/bin",
]
# nvm: pick the newest installed node version
nvm_node = os.path.join(home, ".nvm", "versions", "node")
try:
if os.path.isdir(nvm_node):
versions = sorted(os.listdir(nvm_node), reverse=True)
if versions:
dirs.insert(0, os.path.join(nvm_node, versions[0], "bin"))
except OSError:
pass
# fnm
fnm_bin = os.path.join(home, "Library", "Application Support", "fnm", "aliases", "default", "bin")
if os.path.isdir(fnm_bin):
dirs.insert(0, fnm_bin)
return dirs
def _resolve_command(command: str) -> str | None:
"""Find a command on PATH, falling back to common user-local bin directories."""
found = shutil.which(command)
if found:
return found
home = os.path.expanduser("~")
extra_dirs = [
os.path.join(home, ".bun", "bin"),
os.path.join(home, ".cargo", "bin"),
os.path.join(home, ".local", "bin"),
"/opt/homebrew/bin",
]
for d in extra_dirs:
for d in _extra_bin_dirs():
candidate = os.path.join(d, command)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
def _augmented_path() -> str:
"""Return PATH with extra bin dirs prepended (for child process environments)."""
extra = [d for d in _extra_bin_dirs() if os.path.isdir(d)]
current = os.environ.get("PATH", "")
seen: set[str] = set()
parts: list[str] = []
for p in extra + current.split(os.pathsep):
if p and p not in seen:
seen.add(p)
parts.append(p)
return os.pathsep.join(parts)
def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
"""Build the claude_agent_sdk mcp_servers config entry for a tool.
@@ -340,10 +373,13 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
if client_secret:
env["GOOGLE_WORKSPACE_CLIENT_SECRET"] = client_secret
if config.get("type") == "stdio" and config.get("command"):
resolved = _resolve_command(config["command"])
if resolved:
config["command"] = resolved
if config.get("type") == "stdio":
if config.get("command"):
resolved = _resolve_command(config["command"])
if resolved:
config["command"] = resolved
env = config.setdefault("env", {})
env.setdefault("PATH", _augmented_path())
return config
@@ -509,7 +545,7 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
if not cmd_path:
raise HTTPException(status_code=400, detail=f"Command '{command}' not found on PATH or common install locations")
proc_env = {**os.environ, **(env or {})}
proc_env = {**os.environ, **(env or {}), "PATH": _augmented_path()}
proc = await asyncio.create_subprocess_exec(
cmd_path, *(args or []),
+55 -1
View File
@@ -1,7 +1,9 @@
const { app, BrowserWindow, ipcMain } = require('electron');
const { autoUpdater } = require('electron-updater');
const path = require('path');
const { spawn } = require('child_process');
const { spawn, execFileSync } = require('child_process');
const os = require('os');
const fs = require('fs');
const getPort = require('get-port');
const http = require('http');
@@ -13,6 +15,55 @@ const isPackaged = app.isPackaged;
const isDev = process.env.ELECTRON_DEV === '1';
const iconPath = path.join(__dirname, 'build', 'icon.png');
/**
* macOS GUI apps launched from Finder/Dock inherit a minimal PATH from launchd
* (/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin) — none of the user's shell
* additions (nvm, volta, homebrew, bun, etc.) are present. Resolve the real
* PATH by asking the user's default shell, then fall back to well-known dirs.
*/
function getShellPath() {
if (process.platform !== 'darwin' || isDev) return process.env.PATH || '';
try {
const shell = process.env.SHELL || '/bin/zsh';
const result = execFileSync(shell, ['-ilc', 'echo $PATH'], {
encoding: 'utf8',
timeout: 5000,
env: { ...process.env, HOME: os.homedir() },
});
const resolved = result.trim();
if (resolved) return resolved;
} catch (_) { /* fall through */ }
const home = os.homedir();
const fallbackDirs = [
path.join(home, '.nvm/versions/node'),
path.join(home, '.volta/bin'),
path.join(home, '.fnm/aliases/default/bin'),
path.join(home, '.bun/bin'),
path.join(home, '.cargo/bin'),
path.join(home, '.local/bin'),
'/opt/homebrew/bin',
'/usr/local/bin',
];
// For nvm, resolve the current default version dynamically
const nvmDir = path.join(home, '.nvm/versions/node');
try {
if (fs.existsSync(nvmDir)) {
const versions = fs.readdirSync(nvmDir).sort().reverse();
if (versions.length) {
fallbackDirs.unshift(path.join(nvmDir, versions[0], 'bin'));
}
}
} catch (_) { /* ignore */ }
const existing = fallbackDirs.filter((d) => {
try { return fs.statSync(d).isDirectory(); } catch { return false; }
});
return [...existing, process.env.PATH || ''].join(':');
}
function getResourcePath(...segments) {
if (isPackaged) {
return path.join(process.resourcesPath, ...segments);
@@ -60,8 +111,11 @@ async function startBackend() {
const backendDir = getResourcePath('backend');
const projectRoot = isPackaged ? process.resourcesPath : path.join(__dirname, '..');
const shellPath = getShellPath();
const env = {
...process.env,
PATH: shellPath,
OPENSWARM_PACKAGED: isPackaged ? '1' : '0',
OPENSWARM_PORT: String(backendPort),
PYTHONDONTWRITEBYTECODE: '1',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openswarm",
"version": "1.0.0",
"version": "1.0.01",
"description": "OpenSwarm — AI Agent Orchestrator",
"main": "main.js",
"scripts": {
+1 -1
View File
@@ -5,7 +5,7 @@
"scripts": {
"build": "webpack --mode=production",
"build:watch": "webpack --mode=development --watch",
"dev": "webpack serve --mode=development --open",
"dev": "webpack serve --mode=development",
"clean": "rm -rf dist"
},
"dependencies": {
+11 -2
View File
@@ -32,6 +32,7 @@ import ChatInput, { ChatInputHandle } from './ChatInput';
import { ContextPath } from '@/app/components/DirectoryBrowser';
import BranchNavigator from './BranchNavigator';
import DiffViewer from './DiffViewer';
import { setGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const CONTEXT_WINDOWS: Record<string, number> = {
@@ -162,6 +163,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const curr = session?.status;
prevStatusRef.current = curr;
if (prev === 'running' && (curr === 'completed' || curr === 'stopped' || curr === 'error')) {
if (id) dispatch(clearGlowingBrowserCards(id));
const currentMode = modesMap[mode];
if (currentMode?.default_next_mode && modesMap[currentMode.default_next_mode]) {
setMode(currentMode.default_next_mode);
@@ -197,7 +199,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
}
}, [session?.messages.length, session?.streamingMessage?.content]);
const handleSend = (prompt: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>, forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>) => {
const handleSend = (prompt: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>, forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[]) => {
if (!id) return;
if (isDraft) {
const config: Record<string, any> = { model, mode };
@@ -207,10 +209,17 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
launchAndSendFirstMessage({ draftId: id, config, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills })
).then((action) => {
if (launchAndSendFirstMessage.fulfilled.match(action)) {
dispatch(generateTitle({ sessionId: action.payload.session.id, prompt }));
const realId = action.payload.session.id;
dispatch(generateTitle({ sessionId: realId, prompt }));
if (selectedBrowserIds?.length) {
dispatch(setGlowingBrowserCards({ browserIds: selectedBrowserIds, sessionId: realId }));
}
}
});
} else {
if (selectedBrowserIds?.length) {
dispatch(setGlowingBrowserCards({ browserIds: selectedBrowserIds, sessionId: id }));
}
dispatch(sendMessageThunk({ sessionId: id, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills }));
}
};
@@ -60,7 +60,7 @@ export interface ForcedToolGroup {
export type { AttachedSkill } from '@/app/components/richEditorUtils';
interface Props {
onSend: (message: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: ContextPath[], forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>) => void;
onSend: (message: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: ContextPath[], forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[]) => void;
disabled?: boolean;
mode: string;
onModeChange: (mode: string) => void;
@@ -364,12 +364,16 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const sendSkills = currentSkills.length > 0
? currentSkills.map((s) => ({ id: s.id, name: s.name, content: s.content }))
: undefined;
const browserIds = selectedEls
.filter((el) => el.semanticType === 'browser-card' && el.semanticData?.selectId)
.map((el) => el.semanticData!.selectId as string);
onSend(
trimmed,
sendImages,
contextPaths.length > 0 ? contextPaths : undefined,
allForcedToolNames.length > 0 ? allForcedToolNames : undefined,
sendSkills,
browserIds.length > 0 ? browserIds : undefined,
);
editor.innerHTML = '';
setImages([]);
@@ -12,6 +12,7 @@ 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 SearchIcon from '@mui/icons-material/Search';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import {
setBrowserCardPosition,
@@ -19,11 +20,12 @@ import {
removeBrowserCard,
updateBrowserCardUrl,
} from '@/shared/state/dashboardLayoutSlice';
import { useAppDispatch } from '@/shared/hooks';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { registerWebview, unregisterWebview, type BrowserWebview } from '@/shared/browserRegistry';
import { useBrowserActivity } from '@/shared/useBrowserActivity';
import { getActionLabel } from '@/shared/browserCommandHandler';
import { resolveInput, isGoogleSearch } from '@/shared/resolveUrl';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
@@ -68,12 +70,6 @@ interface Props {
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,
@@ -150,10 +146,12 @@ const BrowserCard: React.FC<Props> = ({
}, [browserId]);
const navigate = useCallback((targetUrl: string) => {
const finalUrl = ensureProtocol(targetUrl);
const finalUrl = resolveInput(targetUrl);
setUrlBarValue(finalUrl);
if (isElectron && webviewRef.current) {
webviewRef.current.loadURL(finalUrl);
webviewRef.current.loadURL(finalUrl).catch((err: Error) => {
if (!err.message?.includes('ERR_ABORTED')) console.error('Navigation failed:', err);
});
}
setCurrentUrl(finalUrl);
dispatch(updateBrowserCardUrl({ browserId, url: finalUrl }));
@@ -310,21 +308,43 @@ const BrowserCard: React.FC<Props> = ({
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
const isSecure = currentUrl.startsWith('https://');
const isSearch = isGoogleSearch(currentUrl);
const accentColor = c.accent.primary;
const accentHover = c.accent.hover;
const glowingBrowserCards = useAppSelector((s) => s.dashboardLayout.glowingBrowserCards);
const isGlowingFromRedux = !!glowingBrowserCards[browserId];
const [hasBeenTouched, setHasBeenTouched] = useState(false);
useEffect(() => {
if (isGlowingFromRedux && agentActive) setHasBeenTouched(true);
}, [isGlowingFromRedux, agentActive]);
useEffect(() => {
if (!isGlowingFromRedux) setHasBeenTouched(false);
}, [isGlowingFromRedux]);
const showGlow = isGlowingFromRedux && hasBeenTouched;
const agentBorder = agentActive
? `2px solid ${accentColor}`
: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`;
: showGlow
? `2px solid ${accentColor}`
: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`;
const innerGlow = showGlow && !agentActive
? `, inset 0 0 30px ${accentColor}25, inset 0 0 60px ${accentColor}10`
: '';
const agentShadow = agentActive
? `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15`
: isDragging || isResizing
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: c.shadow.md;
: showGlow
? `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15${innerGlow}`
: isDragging || isResizing
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: c.shadow.md;
return (
<Box
@@ -348,22 +368,51 @@ const BrowserCard: React.FC<Props> = ({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: (isDragging || isResizing) ? 100 : agentActive ? 50 : 1,
zIndex: (isDragging || isResizing) ? 100 : (agentActive || showGlow) ? 50 : 1,
transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease',
'&:hover .resize-handle': { opacity: 1 },
...(agentActive && {
...((agentActive || showGlow) && {
animation: 'agent-glow-pulse 2s ease-in-out infinite',
'@keyframes agent-glow-pulse': {
'0%, 100%': {
boxShadow: `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15`,
boxShadow: `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15${innerGlow}`,
},
'50%': {
boxShadow: `0 0 0 3px ${accentColor}60, 0 0 28px ${accentColor}45, 0 0 56px ${accentColor}25`,
boxShadow: `0 0 0 3px ${accentColor}60, 0 0 28px ${accentColor}45, 0 0 56px ${accentColor}25${innerGlow}`,
},
},
}),
}}
>
{/* Rotating gradient border glow for element selection / streaming */}
{showGlow && !agentActive && (
<Box
sx={{
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
zIndex: 20,
pointerEvents: 'none',
overflow: 'hidden',
padding: '3px',
mask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
maskComposite: 'exclude',
WebkitMaskComposite: 'xor',
'&::before': {
content: '""',
position: 'absolute',
inset: '-50%',
background: `conic-gradient(from 0deg, transparent 0%, ${accentColor} 25%, transparent 50%, ${accentColor} 75%, transparent 100%)`,
animation: 'rotate-glow 3s linear infinite',
},
'@keyframes rotate-glow': {
'100%': { transform: 'rotate(360deg)' },
},
}}
/>
)}
{/* Animated border glow (top edge overlay) */}
{agentActive && (
<Box
@@ -523,16 +572,18 @@ const BrowserCard: React.FC<Props> = ({
flexShrink: 0,
}}
>
{isSecure && (
{isSearch ? (
<SearchIcon sx={{ fontSize: 14, color: c.text.muted, flexShrink: 0 }} />
) : isSecure ? (
<LockIcon sx={{ fontSize: 13, color: c.status.success, flexShrink: 0 }} />
)}
) : null}
<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..."
placeholder="Search Google or enter URL..."
sx={{
flex: 1,
fontSize: '0.76rem',
@@ -700,6 +751,29 @@ const BrowserCard: React.FC<Props> = ({
</Box>
)}
{/* Orange inner shadow overlay for selection / streaming glow */}
{showGlow && !agentActive && (
<Box
sx={{
position: 'absolute',
inset: 0,
zIndex: 14,
pointerEvents: 'none',
borderRadius: 'inherit',
boxShadow: 'inset 0 0 40px rgba(255,140,0,0.35), inset 0 0 80px rgba(255,100,0,0.15)',
animation: 'orange-glow-pulse 2s ease-in-out infinite',
'@keyframes orange-glow-pulse': {
'0%, 100%': {
boxShadow: 'inset 0 0 40px rgba(255,140,0,0.35), inset 0 0 80px rgba(255,100,0,0.15)',
},
'50%': {
boxShadow: 'inset 0 0 50px rgba(255,140,0,0.45), inset 0 0 100px rgba(255,100,0,0.22)',
},
},
}}
/>
)}
{/* ===== Frosted glass overlay ===== */}
{agentActive && (
<Box
@@ -25,6 +25,7 @@ import {
addBrowserCard,
moveCards,
resetLayout,
setGlowingBrowserCards,
} from '@/shared/state/dashboardLayoutSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import { generateDashboardName } from '@/shared/state/dashboardsSlice';
@@ -253,6 +254,7 @@ const DashboardInner: React.FC = () => {
contextPaths?: ContextPath[],
forcedTools?: string[],
attachedSkills?: Array<{ id: string; name: string; content: string }>,
selectedBrowserIds?: string[],
) => {
setToolbarOpen(false);
@@ -290,6 +292,9 @@ const DashboardInner: React.FC = () => {
if (launchAndSendFirstMessage.fulfilled.match(action)) {
const realId = action.payload.session.id;
dispatch(generateTitle({ sessionId: realId, prompt }));
if (selectedBrowserIds?.length) {
dispatch(setGlowingBrowserCards({ browserIds: selectedBrowserIds, sessionId: realId }));
}
spawnOriginsRef.current[realId] = spawnOriginsRef.current[draftId];
delete spawnOriginsRef.current[draftId];
@@ -34,6 +34,7 @@ interface Props {
contextPaths?: ContextPath[],
forcedTools?: string[],
attachedSkills?: Array<{ id: string; name: string; content: string }>,
selectedBrowserIds?: string[],
) => void;
onAddView: (outputId: string) => void;
onHistoryResume: (sessionId: string) => void;
@@ -128,8 +129,9 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
contextPaths?: ContextPath[],
forcedTools?: string[],
attachedSkills?: Array<{ id: string; name: string; content: string }>,
selectedBrowserIds?: string[],
) => {
onSend(message, mode, model, images, contextPaths, forcedTools, attachedSkills);
onSend(message, mode, model, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds);
},
[onSend, mode, model],
);
+9 -3
View File
@@ -1,5 +1,6 @@
import { getWebview, type BrowserWebview } from './browserRegistry';
import { dashboardWs } from './ws/WebSocketManager';
import { resolveInput } from './resolveUrl';
let initialized = false;
@@ -61,9 +62,14 @@ async function handleGetText(wv: BrowserWebview): Promise<Record<string, any>> {
}
async function handleNavigate(wv: BrowserWebview, params: Record<string, any>): Promise<Record<string, any>> {
const url = params.url as string;
if (!url) return { error: 'url parameter is required' };
await wv.loadURL(url);
const raw = params.url as string;
if (!raw) return { error: 'url parameter is required' };
const url = resolveInput(raw);
try {
await wv.loadURL(url);
} catch (err: any) {
if (!err?.message?.includes('ERR_ABORTED')) throw err;
}
return { text: `Navigated to ${url}`, url };
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Resolves raw URL-bar input into a navigable URL.
*
* Priority:
* 1. Already has a scheme (http://, https://, file://, etc.) → pass through
* 2. Starts with / or ~ → file path, prefix with file://
* 3. localhost (with optional port/path) → http://
* 4. IP address (with optional port/path) → http://
* 5. No spaces + contains a dot followed by a 2+ char TLD → domain, prefix https://
* 6. Everything else → Google search
*/
export function resolveInput(input: string): string {
const trimmed = input.trim();
if (!trimmed) return trimmed;
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//i.test(trimmed)) return trimmed;
if (/^[~/]/.test(trimmed)) return `file://${trimmed}`;
if (/^localhost(:\d+)?(\/.*)?$/i.test(trimmed)) return `http://${trimmed}`;
if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?(\/.*)?$/.test(trimmed)) return `http://${trimmed}`;
if (!/\s/.test(trimmed) && /\.[a-zA-Z]{2,}/.test(trimmed)) return `https://${trimmed}`;
return `https://www.google.com/search?q=${encodeURIComponent(trimmed)}`;
}
export function isGoogleSearch(url: string): boolean {
return url.startsWith('https://www.google.com/search');
}
@@ -43,6 +43,7 @@ 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;
@@ -52,6 +53,7 @@ const initialState: DashboardLayoutState = {
cards: {},
viewCards: {},
browserCards: {},
glowingBrowserCards: {},
persistedExpandedSessionIds: [],
loading: false,
initialized: false,
@@ -384,10 +386,32 @@ const dashboardLayoutSlice = createSlice({
}
},
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;
},
@@ -438,6 +462,9 @@ export const {
removeBrowserCard,
updateBrowserCardUrl,
moveCards,
setGlowingBrowserCards,
clearGlowingBrowserCards,
clearAllGlowingBrowserCards,
resetLayout,
} = dashboardLayoutSlice.actions;
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -81,7 +81,7 @@ module.exports = (env, argv) => {
compress: true,
port: 3000,
hot: true,
open: true,
open: false,
historyApiFallback: true,
proxy: {
'/api': {
+1 -1
View File
@@ -50,7 +50,7 @@ fi
# Step 1: Build frontend
echo "[1/3] Building frontend..."
cd "$PROJECT_ROOT/frontend"
npm ci
npm install
npm run build
if [[ ! -f "$PROJECT_ROOT/frontend/dist/index.html" ]]; then