diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index e39cce7d..3f7e9073 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -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 = ({ )} {appsWindowOpen && !fullscreenCardId && ( - setAppsWindowOpen(false)} /> + dispatch(addViewCard({ outputId }))} + onClose={() => setAppsWindowOpen(false)} + /> )} {/* Canvas viewport */} diff --git a/frontend/src/app/pages/Dashboard/desktop/ApplicationsWindow.tsx b/frontend/src/app/pages/Dashboard/desktop/ApplicationsWindow.tsx index b1915553..ee691fb2 100644 --- a/frontend/src/app/pages/Dashboard/desktop/ApplicationsWindow.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/ApplicationsWindow.tsx @@ -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; + 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 ; + } + // 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 ( - - {letter} + + {glyph && glyph.length <= 3 ? glyph : } ); } -/** Launchpad-style window over the canvas: the user's real /Applications, categorized. */ -function ApplicationsWindow({ onClose }: ApplicationsWindowProps): React.ReactElement { - const [apps, setApps] = useState(null); - const [error, setError] = useState(false); - const [icons, setIcons] = useState>({}); - const [category, setCategory] = useState('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 } }).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 } }).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 - {categories.length > 1 && ( - - {categories.map((cat) => ( - 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} - - ))} - + {Object.keys(outputs).length > 8 && ( + ) => 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)' }, + }} + /> )} - {!apps && !error && ( - - - - )} - {error && ( + {apps.length === 0 && ( - 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.'} )} - {apps && ( + {apps.length > 0 && ( - {visible.map((name) => ( + {apps.map((output) => ( { 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] ? ( - - ) : ( - - )} + - {name} + {output.name} ))}