[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:
ciregenz
2026-07-29 19:58:02 -07:00
parent e321a35894
commit 776f4068f3
2 changed files with 73 additions and 132 deletions
@@ -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>
))}