lvwerra's picture
lvwerra HF Staff
One creation button: quickstart with more options and a group tile
744d9e5
Raw
History Blame Contribute Delete
26.2 kB
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Sidebar from './components/Sidebar';
import TerminalPane from './components/TerminalPane';
import FilesPane from './components/FilesPane';
import SettingsView from './components/SettingsView';
import NewSession from './components/NewSession';
import LayoutPicker from './components/LayoutPicker';
import Logo from './components/Logo';
import Overview from './components/Overview';
import Locked from './components/Locked';
import Welcome from './components/Welcome';
import * as api from './api';
import type { Cli, GridSpec, MoveTarget, OverviewFilter, Session, Tree } from './types';
import { GridGlyph, ListGlyph } from './components/icons';
// Phone-sized viewport: the app becomes two full-screen views (list ⇄ pane).
function useIsMobile() {
const [m, setM] = useState(() => window.matchMedia('(max-width: 720px)').matches);
useEffect(() => {
const mq = window.matchMedia('(max-width: 720px)');
const h = (e: MediaQueryListEvent) => setM(e.matches);
mq.addEventListener('change', h);
return () => mq.removeEventListener('change', h);
}, []);
return m;
}
// Auto layout: the grid grows to fit however many agents the group has.
function autoGrid(n: number): GridSpec {
if (n <= 1) return { cols: 1, rows: 1 };
if (n === 2) return { cols: 2, rows: 1 };
if (n <= 4) return { cols: 2, rows: 2 };
if (n <= 6) return { cols: 3, rows: 2 };
return { cols: 3, rows: 3 };
}
type SettingsPage = 'general' | 'usage' | 'skills';
const ROOT_PATH = '.';
const normalizePath = (p?: string | null) => (p && p.trim() ? p : ROOT_PATH);
function initialTheme(): 'light' | 'dark' {
const stored = localStorage.getItem('am-theme');
if (stored === 'light' || stored === 'dark') return stored;
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export default function App() {
const [clis, setClis] = useState<Cli[]>([]);
const [tree, setTree] = useState<Tree>({ order: [], groups: [], sessions: [] });
const [activeRef, setActiveRef] = useState<string | null>(null);
const [focusedId, setFocusedId] = useState<string | null>(null);
const [theme, setTheme] = useState<'light' | 'dark'>(initialTheme);
const [dropMain, setDropMain] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsPage, setSettingsPage] = useState<SettingsPage>('general');
// First-run welcome: null until /api/info loads; true when reopened manually.
const [showWelcome, setShowWelcome] = useState(false);
// Transient error toast for failed mutations (create/delete/move/…).
const [toast, setToast] = useState<string | null>(null);
const showErr = (msg: string) => (e: unknown) => { console.error(msg, e); setToast(msg); window.setTimeout(() => setToast(null), 4000); };
// Overview presentation: tiles (default) or the classic list.
const [ovView, setOvViewRaw] = useState<'tiles' | 'list'>(() =>
(localStorage.getItem('am-ov-view') === 'list' ? 'list' : 'tiles'));
const setOvView = (v: 'tiles' | 'list') => { setOvViewRaw(v); localStorage.setItem('am-ov-view', v); };
// Archiving: sessions quiet for longer than the configured window are hidden
// from the sidebar and overview unless "archived" is checked. Derived, never
// stored — flipping the setting instantly (un)archives.
const [showArchived, setShowArchived] = useState(false);
const [archiveAfter, setArchiveAfter] = useState<'week' | 'month' | 'never'>('month');
const [zoom, setZoom] = useState<number>(() => {
const z = parseInt(localStorage.getItem('am-zoom') || '100', 10);
return Number.isFinite(z) ? z : 100;
});
const [info, setInfo] = useState<Awaited<ReturnType<typeof api.getInfo>> | null>(null);
// Default location for the next agent = where the last one was created,
// falling back to the workspaces root.
const [lastPath, setLastPath] = useState(() => normalizePath(localStorage.getItem('am-last-path')));
// Mobile navigation: false = the sidebar is the (full-screen) home view,
// true = the selected session/group fills the screen. Desktop ignores this.
const isMobile = useIsMobile();
const [mobileStage, setMobileStage] = useState(false);
const [ovFilter, setOvFilter] = useState<OverviewFilter>('all');
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
const rememberPath = (p?: string | null) => {
const next = normalizePath(p);
setLastPath(next);
localStorage.setItem('am-last-path', next);
};
useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem('am-theme', theme);
}, [theme]);
// Refresh info while locked so flipping the Space to Private unlocks the UI
// without a manual reload (the server re-checks visibility every minute).
useEffect(() => {
api.getInfo().then(setInfo).catch(() => {});
if (!info?.locked) return;
const t = setInterval(() => api.getInfo().then(setInfo).catch(() => {}), 15_000);
return () => clearInterval(t);
}, [info?.locked]);
useEffect(() => { localStorage.setItem('am-zoom', String(zoom)); }, [zoom]);
// Show the welcome once, when /api/info first loads: on first run (never seen)
// or whenever demo mode is active (so the Space reads like a fresh install).
// Fires once so dismissing it during a demo doesn't make it re-pop.
const welcomeBoot = useRef(false);
useEffect(() => {
if (!info || welcomeBoot.current) return;
welcomeBoot.current = true;
if (!info.locked && (info.welcomeSeen === false || info.demoMode)) setShowWelcome(true);
}, [info]);
const dismissWelcome = () => {
setShowWelcome(false);
setInfo((i) => (i ? { ...i, welcomeSeen: true } : i));
api.dismissWelcome().catch(() => {});
};
const openWelcome = () => { setSettingsOpen(false); setShowWelcome(true); };
// Demo mode: hide current sessions from view (nothing is deleted). Toggling
// off restores the full sidebar. Turning it on shows the welcome; off hides it.
const toggleDemo = async () => {
const next = !info?.demoMode;
try {
const r = await api.setDemo(next);
setInfo((i) => (i ? { ...i, demoMode: r.active } : i));
setSettingsOpen(false);
await refresh();
setShowWelcome(next);
} catch (e) { showErr('Couldn’t toggle demo mode')(e); }
};
const refresh = useCallback(async () => {
try { setTree(await api.getTree()); } catch { /* offline */ }
}, []);
useEffect(() => {
api.getClis().then(setClis).catch(() => {});
refresh();
// Skip polling while the tab is hidden; catch up immediately on return.
const t = setInterval(() => { if (!document.hidden) refresh(); }, 2500);
const onVisible = () => { if (!document.hidden) refresh(); };
document.addEventListener('visibilitychange', onVisible);
return () => { clearInterval(t); document.removeEventListener('visibilitychange', onVisible); };
}, [refresh]);
// Last-activity ages for the sidebar clock column (from the trace digests;
// low-frequency — the Overview does its own faster polling when open).
const [ages, setAges] = useState<Record<string, number>>({});
useEffect(() => {
let alive = true;
const load = () => api.getMeta()
.then((r) => {
if (!alive) return;
setAges(Object.fromEntries(r.sessions.map((s) => [
s.id, Math.max(s.digest?.lastAssistantTs || 0, s.digest?.lastPromptTs || 0),
])));
})
.catch(() => {});
load();
const t = setInterval(() => { if (!document.hidden) load(); }, 20_000);
return () => { alive = false; clearInterval(t); };
}, []);
// Archive threshold from the operator config; refresh when settings closes
// (that's where it's edited).
useEffect(() => {
if (settingsOpen) return;
api.getConfig().then((c) => setArchiveAfter(c.archive?.after ?? 'month')).catch(() => {});
}, [settingsOpen]);
const archivedIds = useMemo(() => {
const out = new Set<string>();
if (archiveAfter === 'never') return out;
const cut = Date.now() - (archiveAfter === 'week' ? 7 : 30) * 864e5;
for (const s of tree.sessions) {
// Shells and file panes have no trace clock — never archive them.
if (s.cli === 'shell' || s.cli === 'files' || s.state === 'working') continue;
const last = ages[s.id] || Date.parse(s.createdAt) || 0;
if (last && last < cut) out.add(s.id);
}
return out;
}, [tree.sessions, ages, archiveAfter]);
const cliMap = useMemo(() => Object.fromEntries(clis.map((c) => [c.id, c])), [clis]);
const sessById = useMemo(() => Object.fromEntries(tree.sessions.map((s) => [s.id, s])), [tree.sessions]);
const groupById = useMemo(() => Object.fromEntries(tree.groups.map((g) => [g.id, g])), [tree.groups]);
// Keep a valid selection ('overview' is always valid).
useEffect(() => {
const ok = activeRef && (activeRef === 'overview'
|| (activeRef.startsWith('g:') ? groupById[activeRef.slice(2)] : sessById[activeRef.slice(2)]));
if (!ok) setActiveRef(tree.order[0] ?? null);
}, [tree.order, groupById, sessById, activeRef]);
const activeGroup = activeRef?.startsWith('g:') ? groupById[activeRef.slice(2)] : null;
const activeSingle = activeRef?.startsWith('s:') ? sessById[activeRef.slice(2)] : null;
const groupSessions = useMemo(
() => (activeGroup ? activeGroup.sessionIds.map((id) => sessById[id]).filter(Boolean) as Session[] : []),
[activeGroup, sessById],
);
// Tile grid for the active group: the chosen layout, or auto (fit the count).
// On mobile it's always a single pane — the chip strip switches between them.
const grid: GridSpec = activeGroup && !isMobile ? (activeGroup.layout ?? autoGrid(groupSessions.length)) : { cols: 1, rows: 1 };
const cap = grid.cols * grid.rows;
const pageCount = Math.max(1, Math.ceil(groupSessions.length / cap));
// Page resets happen explicitly in the navigation handlers (an effect on
// activeRef would clobber openSession's "land on this agent's pane").
const [pageRaw, setPage] = useState(0);
const page = Math.min(pageRaw, pageCount - 1); // clamp when agents/layout change
const pageSessions = activeGroup ? groupSessions.slice(page * cap, (page + 1) * cap) : [];
const visibleSessions = activeGroup ? pageSessions : activeSingle ? [activeSingle] : [];
const visibleIds = visibleSessions.map((s) => s.id).join(',');
const showZoom = visibleSessions.length > 0;
// Pane rearrangement (drag a pane header onto another tile).
const [paneDrag, setPaneDrag] = useState(false);
const [overTile, setOverTile] = useState<number | null>(null);
// A sidebar row being dragged ("s:<id>" / "g:<id>") — the stage shows
// per-cell drop targets for sessions instead of one big outline.
const [sessionDrag, setSessionDrag] = useState<string | null>(null);
const sessionDragActive = !!sessionDrag?.startsWith('s:');
// Keep a focused pane within the visible set.
useEffect(() => {
const ids = visibleIds ? visibleIds.split(',') : [];
if (!focusedId || !ids.includes(focusedId)) setFocusedId(ids[0] ?? null);
}, [visibleIds, focusedId]);
// actions
const createSession = async (name: string, cli: string, path: string, groupId?: string) => {
try {
const s = await api.createSession(name, cli, groupId, path);
rememberPath(s.path);
await refresh();
if (groupId) { setActiveRef(`g:${groupId}`); setFocusedId(s.id); }
else setActiveRef(`s:${s.id}`);
} catch (e) { showErr('Couldn’t create the agent')(e); }
};
// Quickstart: server boots the agent and types the prompt; we jump straight
// to the new pane so you watch it happen.
const quickStart = async (cli: string, prompt: string, name = '', path = '.') => {
try {
const s = await api.quickStart(cli, prompt, name, path);
rememberPath(s.path);
await refresh();
setActiveRef(`s:${s.id}`);
} catch (e) { showErr('Couldn’t quickstart the agent')(e); }
};
// Creations land in an explicitly targeted group (the group's + button),
// else the group you're currently looking at; loose otherwise.
const newSession = (name: string, cli: string, path: string, groupId?: string) =>
createSession(name, cli, path, groupId ?? activeGroup?.id);
const newGroup = async (name: string, cart?: { cli: string; count: number }[], path = ROOT_PATH) => {
try {
const g = await api.createGroup(name);
for (const { cli, count } of cart || []) {
const base = cliMap[cli]?.label || cli;
for (let i = 0; i < count; i++) {
const s = await api.createSession(count > 1 ? `${base} ${i + 1}` : base, cli, g.id, path);
rememberPath(s.path);
}
}
await refresh();
setActiveRef(`g:${g.id}`);
} catch (e) { showErr('Couldn’t create the group')(e); }
};
const doMove = (ref: string, to: MoveTarget) => api.move(ref, to).then(refresh).catch(showErr('Couldn’t move that'));
const renameGroup = (id: string, name: string) => api.renameGroup(id, name).then(refresh).catch(showErr('Couldn’t rename'));
const renameSession = (id: string, name: string) => { if (name.trim()) api.renameSession(id, name.trim()).then(refresh).catch(showErr('Couldn’t rename')); };
const deleteGroup = (id: string) => api.deleteGroup(id).then(() => { if (activeRef === `g:${id}`) setActiveRef(null); refresh(); }).catch(showErr('Couldn’t delete the group'));
const stopSession = (id: string) => api.stopSession(id).then(refresh).catch(showErr('Couldn’t stop the agent'));
const deleteSession = (id: string) => api.deleteSession(id).then(() => { if (activeRef === `s:${id}`) setActiveRef(null); refresh(); }).catch(showErr('Couldn’t delete the agent'));
// Clicking a session: nested → open its group with that pane focused; loose → solo view.
const openSession = (sid: string, groupId?: string) => {
if (groupId) {
setActiveRef(`g:${groupId}`);
setFocusedId(sid);
// Land on the page that actually contains this agent's pane.
const g = groupById[groupId];
const idx = g?.sessionIds.indexOf(sid) ?? -1;
const gg = isMobile ? { cols: 1, rows: 1 } : (g?.layout ?? autoGrid(g?.sessionIds.length ?? 1));
setPage(idx >= 0 ? Math.floor(idx / (gg.cols * gg.rows)) : 0);
} else {
setActiveRef(`s:${sid}`);
setPage(0);
}
if (isMobile) setMobileStage(true);
};
const activate = (ref: string) => {
setActiveRef(ref);
setPage(0);
if (isMobile) setMobileStage(true);
};
const closePane = (sid: string) => {
// On mobile ✕ just returns to the list — never the desktop ungroup gesture.
if (isMobile) { setMobileStage(false); return; }
if (activeGroup) doMove(`s:${sid}`, { kind: 'after', ref: activeRef! });
else setActiveRef(null);
};
const setLayout = async (l: GridSpec | null) => {
if (!activeGroup) return;
await api.updateGroup(activeGroup.id, { layout: l });
refresh();
};
// Drop pane `paneId` on the tile showing position `targetIdx` (absolute index
// into the group's order): occupied tile → swap places; empty tile → move to
// the end.
const movePane = async (paneId: string, targetIdx: number) => {
if (!activeGroup) return;
const ids = activeGroup.sessionIds.slice();
const from = ids.indexOf(paneId);
if (from < 0) return;
const target = groupSessions[targetIdx];
if (target && target.id !== paneId) {
const ti = ids.indexOf(target.id);
ids[from] = ids[ti];
ids[ti] = paneId;
} else if (!target) {
ids.splice(from, 1);
ids.push(paneId);
} else return;
await api.updateGroup(activeGroup.id, { sessionIds: ids });
refresh();
};
const promptUser = info?.spaceId?.split('/')[0] || 'you';
// Empty states speak the app's native language: a prompt, waiting.
const EmptyPrompt = ({ path, hints }: { path: string; hints: string[] }) => (
<div className="empty-term">
<div className="et-prompt mono">
<span className="et-user">{promptUser}</span>
<span className="et-dim">/</span>
<span className="et-path">{path}</span>
<span className="et-dim"> $</span>
<span className="et-cursor" />
</div>
{hints.map((h) => <p key={h} className="et-hint">{h}</p>)}
</div>
);
const allowDrop = (e: React.DragEvent) => { if (paneDrag) return; e.preventDefault(); setDropMain(true); };
const onDropMain = (e: React.DragEvent) => {
e.preventDefault();
const ref = e.dataTransfer.getData('text/plain');
if (ref?.startsWith('s:') && activeGroup) doMove(ref, { kind: 'into', groupId: activeGroup.id });
setDropMain(false);
};
// One grid cell. Empty cells become visible drop targets while a pane drags
// (rearrange) or a sidebar session drags over the group (join at that spot).
const tileDnd = (i: number, occupied: boolean) => ({
onDragOver: (e: React.DragEvent) => {
if (paneDrag || (sessionDragActive && !occupied)) { e.preventDefault(); e.stopPropagation(); setOverTile(i); }
},
onDragLeave: () => setOverTile((t) => (t === i ? null : t)),
onDrop: (e: React.DragEvent) => {
const d = e.dataTransfer.getData('text/plain');
if (d.startsWith('p:')) { e.preventDefault(); e.stopPropagation(); movePane(d.slice(2), page * cap + i); }
else if (d.startsWith('s:') && activeGroup) { e.preventDefault(); e.stopPropagation(); doMove(d, { kind: 'into', groupId: activeGroup.id }); }
setOverTile(null);
},
});
const renderTiles = (sessions: Session[], g: GridSpec) => {
const slotCount = activeGroup ? g.cols * g.rows : sessions.length;
const slots = Array.from({ length: slotCount }, (_, i) => sessions[i] ?? null);
const canDrag = !!activeGroup && groupSessions.length > 1;
// A sidebar session hovering a full grid still needs somewhere to land:
// offer a ghost strip appended below the tiles.
const ghost = sessionDragActive && !!activeGroup;
return (
<div
className={`tiles${paneDrag ? ' pane-dragging' : ''}${sessionDragActive && activeGroup ? ' session-dragging' : ''}`}
style={{ gridTemplateColumns: `repeat(${g.cols}, 1fr)`, gridTemplateRows: `repeat(${g.rows}, minmax(0, 1fr))` }}
onDragOver={activeGroup ? allowDrop : undefined}
onDragLeave={() => setDropMain(false)}
onDrop={activeGroup ? onDropMain : undefined}
>
{slots.map((s, i) => (
<div
key={s ? s.id : `empty-${i}`}
className={`tile${s ? '' : ' tile-empty'}${(paneDrag || sessionDragActive) && overTile === i ? ' tile-over' : ''}`}
{...(activeGroup ? tileDnd(i, !!s) : {})}
>
{s && (s.cli === 'files' ? (
<FilesPane
session={s}
zoom={zoom}
focused={visibleSessions.length > 1 && s.id === focusedId}
dragId={canDrag ? `p:${s.id}` : undefined}
onDragActive={setPaneDrag}
onFocus={() => setFocusedId(s.id)}
onClose={() => closePane(s.id)}
/>
) : (
<TerminalPane
session={s}
cli={cliMap[s.cli]}
theme={theme}
zoom={zoom}
focused={visibleSessions.length > 1 && s.id === focusedId}
active={s.id === focusedId}
dragId={canDrag ? `p:${s.id}` : undefined}
onDragActive={setPaneDrag}
onFocus={() => setFocusedId(s.id)}
onRename={(name) => renameSession(s.id, name)}
onClose={() => closePane(s.id)}
/>
))}
</div>
))}
{ghost && (
<div
className={`tile tile-ghost${overTile === slots.length ? ' tile-over' : ''}`}
{...tileDnd(slots.length, false)}
>
drop to add to {activeGroup!.name}
</div>
)}
</div>
);
};
if (info?.locked) return <Locked spaceId={info.spaceId} reason={info.lockReason} bucket={info.lockBucket} />;
if (settingsOpen) {
return (
<SettingsView
page={settingsPage}
onPage={setSettingsPage}
onClose={() => setSettingsOpen(false)}
theme={theme}
onToggleTheme={toggleTheme}
clis={clis}
info={info}
onShowWelcome={openWelcome}
demoMode={!!info?.demoMode}
onToggleDemo={toggleDemo}
/>
);
}
return (
<div className={`app${isMobile ? (mobileStage ? ' m-stage' : ' m-home') : ''}`}>
{showWelcome && <Welcome onClose={dismissWelcome} />}
{toast && <div className="toast mono" role="alert">{toast}</div>}
<Sidebar
clis={clis}
tree={tree}
activeRef={activeRef}
focusedId={focusedId}
defaultPath={lastPath}
ages={ages}
onActivate={activate}
onOpenSession={openSession}
onNewSession={newSession}
onNewGroup={newGroup}
onRenameGroup={renameGroup}
onRenameSession={renameSession}
onDeleteGroup={deleteGroup}
onStopSession={stopSession}
onDeleteSession={deleteSession}
onMove={doMove}
onDragState={setSessionDrag}
onOpenSettings={() => setSettingsOpen(true)}
theme={theme}
onToggleTheme={toggleTheme}
onQuickStart={quickStart}
archived={archivedIds}
showArchived={showArchived}
onToggleArchived={() => setShowArchived((v) => !v)}
/>
<div className="main">
{isMobile && mobileStage && (
<div className="mbar">
<button className="icon-btn mback" onClick={() => setMobileStage(false)} title="Back to list">‹</button>
{activeGroup ? (
<div className="mchips">
{groupSessions.map((s, i) => (
<button key={s.id} className={`mchip${i === page ? ' on' : ''}`} title={s.name} onClick={() => setPage(i)}>
<Logo cli={s.cli} size={13} tint={cliMap[s.cli]?.color} />
<span className={`status ${s.state}`} />
</button>
))}
</div>
) : (
<span className="mtitle mono">{activeRef === 'overview' ? 'Overview' : activeSingle?.name}</span>
)}
</div>
)}
<div className="stage">
{activeRef === 'overview' ? (
<Overview
clis={clis}
tree={tree}
filter={ovFilter}
view={ovView}
archived={archivedIds}
showArchived={showArchived}
onOpen={(sid) => {
const g = tree.groups.find((x) => x.sessionIds.includes(sid));
openSession(sid, g?.id);
}}
/>
) : activeGroup ? (
groupSessions.length === 0 ? (
<div
className={`empty-group${dropMain ? ' drop-over' : ''}`}
onDragOver={allowDrop}
onDragLeave={() => setDropMain(false)}
onDrop={onDropMain}
>
<div className="empty-card">
<EmptyPrompt path={activeGroup.name} hints={['create an agent here, or drag one in from the sidebar']} />
<NewSession clis={clis} sessions={tree.sessions} defaultPath={lastPath} onCreate={newSession} />
<div className="dropline">drop an agent here to add it to this group</div>
</div>
</div>
) : renderTiles(pageSessions, grid)
) : activeSingle ? (
renderTiles([activeSingle], { cols: 1, rows: 1 })
) : (
<div className="empty-group">
<EmptyPrompt
path="workspaces"
hints={['create an agent or a group from the sidebar', 'drag an agent onto another to group them']}
/>
</div>
)}
</div>
{activeRef === 'overview' && (
<div className="zoombar ov-bar">
<div className="seg ov-seg">
{(['all', 'waiting', 'working', 'quiet'] as OverviewFilter[]).map((f) => (
<button key={f} className={ovFilter === f ? 'on' : ''} onClick={() => setOvFilter(f)}>
{f === 'waiting' ? 'done' : f === 'working' ? 'running' : f === 'quiet' ? 'stopped' : 'all'}
</button>
))}
</div>
<div className="seg ov-seg ov-viewseg">
<button className={ovView === 'tiles' ? 'on' : ''} title="Tiles" onClick={() => setOvView('tiles')}><GridGlyph /></button>
<button className={ovView === 'list' ? 'on' : ''} title="List" onClick={() => setOvView('list')}><ListGlyph /></button>
</div>
</div>
)}
{showZoom && (
<div className="zoombar">
{!isMobile && activeGroup && groupSessions.length > 0 && (
<LayoutPicker grid={grid} isAuto={!activeGroup.layout} onPick={setLayout} />
)}
{!isMobile && activeGroup && pageCount > 1 && (
<span className="pager">
<button className="zbtn" title="Previous panes" disabled={page === 0} onClick={() => setPage(page - 1)}>‹</button>
<span className="plbl mono">{page + 1}/{pageCount}</span>
<button className="zbtn" title="Next panes" disabled={page >= pageCount - 1} onClick={() => setPage(page + 1)}>›</button>
</span>
)}
<span className="spacer" />
<button className="zbtn" title="Zoom out" onClick={() => setZoom((z) => Math.max(50, z - 10))}>−</button>
<button className="zlvl" title="Reset to 100%" onClick={() => setZoom(100)}>{zoom}%</button>
<button className="zbtn" title="Zoom in" onClick={() => setZoom((z) => Math.min(200, z + 10))}>+</button>
</div>
)}
</div>
</div>
);
}