mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] dock: Applications lists YOUR OpenSwarm apps (was the machine's /Applications via a launcher whose click IPC never existed)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, type RefObject } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { clearTiledCard, selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { addViewCard, clearTiledCard, selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice';
|
||||
import DashboardHeader from './DashboardHeader';
|
||||
import TetherLayer from './TetherLayer';
|
||||
import DashboardCardLayer from './DashboardCardLayer';
|
||||
@@ -303,7 +303,11 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
)}
|
||||
|
||||
{appsWindowOpen && !fullscreenCardId && (
|
||||
<ApplicationsWindow onClose={() => setAppsWindowOpen(false)} />
|
||||
<ApplicationsWindow
|
||||
outputs={outputs}
|
||||
onOpenApp={(outputId) => dispatch(addViewCard({ outputId }))}
|
||||
onClose={() => setAppsWindowOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Canvas viewport */}
|
||||
|
||||
@@ -1,103 +1,49 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
|
||||
import type { Output } from '@/shared/state/outputsSlice';
|
||||
|
||||
interface ApplicationsWindowProps {
|
||||
outputs: Record<string, Output>;
|
||||
onOpenApp: (outputId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CATEGORY_RULES: Array<{ label: string; re: RegExp }> = [
|
||||
{ label: 'Developer Tools', re: /code|cursor|docker|terminal|xcode|git|iterm|studio|postman|figma|utm|dev/i },
|
||||
{ label: 'Productivity & Finance', re: /notion|calendar|mail|numbers|pages|keynote|excel|word|slides|office|linear|wallet|slack|zoom|meet|drive|todo|remind/i },
|
||||
{ label: 'Social', re: /message|discord|telegram|whatsapp|signal|wechat|facetime|x\b|instagram/i },
|
||||
{ label: 'Entertainment', re: /spotify|music|tv|netflix|youtube|game|steam|chess|vlc|iina|podcast/i },
|
||||
{ label: 'Utilities', re: /calculator|clock|settings|finder|preview|utility|cleaner|monitor|keychain|archive|font/i },
|
||||
{ label: 'Travel', re: /maps|weather|flight|uber|airbnb/i },
|
||||
{ label: 'Creativity', re: /photo|imovie|garageband|final cut|logic|premiere|illustrator|sketch|blender|procreate|paint|davinci/i },
|
||||
{ label: 'Information', re: /news|books|stocks|dictionary|wiki|safari|chrome|edge|firefox|arc|browser/i },
|
||||
];
|
||||
|
||||
function categorize(name: string): string {
|
||||
for (const rule of CATEGORY_RULES) if (rule.re.test(name)) return rule.label;
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
function LetterTile({ name }: { name: string }): React.ReactElement {
|
||||
const letter = name.match(/[a-z0-9]/i)?.[0]?.toUpperCase() || '?';
|
||||
function AppTile({ output }: { output: Output }): React.ReactElement {
|
||||
const tile = {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
} as const;
|
||||
if (output.thumbnail) {
|
||||
return <Box component="img" src={output.thumbnail} alt="" sx={{ ...tile, objectFit: 'cover' }} />;
|
||||
}
|
||||
// The icon field holds an emoji for most apps; anything longer is not a glyph, so fall back to the app symbol.
|
||||
const glyph = (output.icon || '').trim();
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: '12px',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.22), rgba(255,255,255,0.08))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.375rem',
|
||||
fontWeight: 700,
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
}}
|
||||
>
|
||||
{letter}
|
||||
<Box sx={{ ...tile, background: 'linear-gradient(135deg, #ef9552, #d96a2b)', fontSize: '1.375rem' }}>
|
||||
{glyph && glyph.length <= 3 ? glyph : <GridViewRoundedIcon sx={{ fontSize: 26, color: '#fff' }} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Launchpad-style window over the canvas: the user's real /Applications, categorized. */
|
||||
function ApplicationsWindow({ onClose }: ApplicationsWindowProps): React.ReactElement {
|
||||
const [apps, setApps] = useState<string[] | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [icons, setIcons] = useState<Record<string, string>>({});
|
||||
const [category, setCategory] = useState<string>('All');
|
||||
/** Launchpad-style window over the canvas: the user's OpenSwarm apps, newest first. Deliberately NOT the machine's /Applications; this launcher is for things built in OpenSwarm. */
|
||||
function ApplicationsWindow({ outputs, onOpenApp, onClose }: ApplicationsWindowProps): React.ReactElement {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch(`${API_BASE}/onboarding/scan`, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (cancelled) return;
|
||||
const names: string[] = Array.isArray(d?.apps) ? d.apps : [];
|
||||
setApps(names);
|
||||
})
|
||||
.catch(() => { if (!cancelled) setError(true); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const getIcon = (window as unknown as { openswarm?: { getAppIcon?: (n: string) => Promise<string | null> } }).openswarm?.getAppIcon;
|
||||
useEffect(() => {
|
||||
if (!apps || !getIcon) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
for (const name of apps.slice(0, 60)) {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const dataUrl = await getIcon(name);
|
||||
if (cancelled) return;
|
||||
if (dataUrl) setIcons((prev) => (prev[name] ? prev : { ...prev, [name]: dataUrl }));
|
||||
} catch {
|
||||
/* icon-less tile falls back to the letter */
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [apps]);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
if (!apps) return [];
|
||||
const present = new Set(apps.map(categorize));
|
||||
return ['All', ...CATEGORY_RULES.map((r) => r.label).filter((l) => present.has(l)), ...(present.has('Other') ? ['Other'] : [])];
|
||||
}, [apps]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!apps) return [];
|
||||
return category === 'All' ? apps : apps.filter((a) => categorize(a) === category);
|
||||
}, [apps, category]);
|
||||
|
||||
const openApp = (window as unknown as { openswarm?: { openApplication?: (n: string) => Promise<boolean> } }).openswarm?.openApplication;
|
||||
const apps = useMemo(() => {
|
||||
const all = Object.values(outputs);
|
||||
const q = query.trim().toLowerCase();
|
||||
const matched = q
|
||||
? all.filter((o) => o.name.toLowerCase().includes(q) || (o.description || '').toLowerCase().includes(q))
|
||||
: all;
|
||||
return [...matched].sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
|
||||
}, [outputs, query]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -129,49 +75,44 @@ function ApplicationsWindow({ onClose }: ApplicationsWindowProps): React.ReactEl
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{categories.length > 1 && (
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mb: 2, overflowX: 'auto', pb: 0.5, scrollbarWidth: 'none', '&::-webkit-scrollbar': { display: 'none' } }}>
|
||||
{categories.map((cat) => (
|
||||
<Box
|
||||
key={cat}
|
||||
onClick={() => setCategory(cat)}
|
||||
sx={{
|
||||
px: 1.25,
|
||||
py: 0.4,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
color: category === cat ? '#fff' : 'rgba(255,255,255,0.6)',
|
||||
background: category === cat ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)',
|
||||
'&:hover': { background: 'rgba(255,255,255,0.16)' },
|
||||
}}
|
||||
>
|
||||
{cat}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{Object.keys(outputs).length > 8 && (
|
||||
<Box
|
||||
component="input"
|
||||
autoFocus
|
||||
value={query}
|
||||
placeholder="Search your apps"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
|
||||
sx={{
|
||||
mb: 2,
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
borderRadius: 999,
|
||||
border: '1px solid rgba(255,255,255,0.14)',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
color: '#fff',
|
||||
fontSize: '0.8125rem',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
'&::placeholder': { color: 'rgba(255,255,255,0.45)' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box sx={{ overflowY: 'auto', flex: 1, minHeight: 120 }}>
|
||||
{!apps && !error && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={22} sx={{ color: 'rgba(255,255,255,0.5)' }} />
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
{apps.length === 0 && (
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.55)', fontSize: '0.875rem', textAlign: 'center', py: 5 }}>
|
||||
Could not read /Applications.
|
||||
{Object.keys(outputs).length === 0
|
||||
? 'No apps yet. Ask an agent to build one and it lands here.'
|
||||
: 'No apps match that search.'}
|
||||
</Typography>
|
||||
)}
|
||||
{apps && (
|
||||
{apps.length > 0 && (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(78px, 1fr))', gap: 1.5 }}>
|
||||
{visible.map((name) => (
|
||||
{apps.map((output) => (
|
||||
<Box
|
||||
key={name}
|
||||
onClick={() => { if (openApp) void openApp(name); }}
|
||||
title={name}
|
||||
key={output.id}
|
||||
onClick={() => { onOpenApp(output.id); onClose(); }}
|
||||
title={output.description || output.name}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -179,17 +120,13 @@ function ApplicationsWindow({ onClose }: ApplicationsWindowProps): React.ReactEl
|
||||
gap: 0.75,
|
||||
py: 0.75,
|
||||
borderRadius: '10px',
|
||||
cursor: openApp ? 'pointer' : 'default',
|
||||
'&:hover': openApp ? { background: 'rgba(255,255,255,0.08)' } : undefined,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { background: 'rgba(255,255,255,0.08)' },
|
||||
}}
|
||||
>
|
||||
{icons[name] ? (
|
||||
<Box component="img" src={icons[name]} alt="" sx={{ width: 52, height: 52, borderRadius: '12px' }} />
|
||||
) : (
|
||||
<LetterTile name={name} />
|
||||
)}
|
||||
<AppTile output={output} />
|
||||
<Typography sx={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.82)', textAlign: 'center', maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{name}
|
||||
{output.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user