feat(canvas): mobile-first shell with 6-screen iOS design + responsive desktop fixes

Implements the Claude Design handoff (Molecules AI Mobile.html) as a
viewport-gated React tree under canvas/src/components/mobile/. < 640px
renders the new shell instead of the desktop ReactFlow canvas.

Six screens, all bound to live store data:
- Home (agent list + filter chips + spawn FAB)
- Canvas (mini-graph with pinch-to-zoom + pan + reset)
- Detail (status pills, tabs: Overview / Activity / Config / Memory;
  Activity hits /workspaces/:id/activity)
- Chat (textarea composer, IME-safe Enter, sendInFlightRef guard;
  bootstraps from agentMessages so the prior thread shows on entry)
- Comms (live A2A feed via /workspaces/:id/activity + ACTIVITY_LOGGED)
- Spawn (bottom sheet; fetches /templates so users pick what's actually
  installed on their platform)

Plus a Me tab for mobile theme/accent/density.

Design system (palette.ts + primitives.tsx) ports tokens 1:1 from the
handoff: cream + dark palettes, T1-T4 tier chips, status dots with
halo, JetBrains Mono for IDs/timestamps. Inter + JetBrains Mono are
self-hosted via next/font/google so CSP `font-src 'self'` is honoured.

URL routing: routes sync to ?m=<route>&a=<id>; popstate restores route;
deep links seed initial state. /?m=detail without ?a collapses to home.

Accent override flows through React context (MobileAccentProvider) —
not by mutating the static MOL_LIGHT/MOL_DARK singletons.

SSR flash: isMobile is tri-state; loading spinner stays up until
matchMedia resolves so mobile devices never paint the desktop tree.

Desktop responsiveness fixes (separate but ride along):
- Toolbar: full-width with overflow-x-auto on mobile, logo text + count
  hidden < sm, divider/border collapse to sm: only.
- SidePanel: full-screen on mobile via matchMedia, resize handle hidden.
- Canvas: MiniMap hidden < sm (was overlapping the New Workspace FAB).

Tests (51 total, 33 new):
- palette.test.ts (12) - normalizeStatus, tierCode, light/dark parity
- components.test.ts (10) - toMobileAgent field mapping + classifyForFilter
- MobileApp.test.tsx (12) - route stack, deep links, popstate, tab bar
  hidden on chat, spawn overlay
- SidePanel.tabs.test.tsx (18) - regression-clean

Verified: tsc --noEmit clean across mobile/, page.tsx, layout.tsx.
Not yet verified: live phone browser (needs CP backend hydrated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hongmingwang 2026-05-10 06:06:24 -07:00 committed by Molecule AI App-FE
parent 7594fc5714
commit bc1e7ea8bc
20 changed files with 4293 additions and 31 deletions

View File

@ -1,6 +1,22 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { cookies, headers } from "next/headers";
import "./globals.css";
// Self-hosted at build time → CSP-safe (font-src 'self' covers them
// because Next.js serves the .woff2 from /_next/static). Exposed as
// CSS variables so the mobile palette can reference them without
// importing this module.
const interFont = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
const monoFont = JetBrains_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-jetbrains",
});
import { AuthGate } from "@/components/AuthGate";
import { CookieConsent } from "@/components/CookieConsent";
import { PurchaseSuccessModal } from "@/components/PurchaseSuccessModal";
@ -79,7 +95,7 @@ export default async function RootLayout({
dangerouslySetInnerHTML={{ __html: themeBootScript }}
/>
</head>
<body className="bg-surface text-ink">
<body className={`bg-surface text-ink ${interFont.variable} ${monoFont.variable}`}>
<ThemeProvider initialTheme={theme}>
{/* AuthGate is a client component; it checks the session on mount
and bounces anonymous users to the control plane's login page

View File

@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { Canvas } from "@/components/Canvas";
import { Legend } from "@/components/Legend";
import { CommunicationOverlay } from "@/components/CommunicationOverlay";
import { MobileApp } from "@/components/mobile/MobileApp";
import { Spinner } from "@/components/Spinner";
import { connectSocket, disconnectSocket } from "@/store/socket";
import { useCanvasStore } from "@/store/canvas";
@ -14,6 +15,23 @@ export default function Home() {
const hydrationError = useCanvasStore((s) => s.hydrationError);
const setHydrationError = useCanvasStore((s) => s.setHydrationError);
const [hydrating, setHydrating] = useState(true);
// < 640px viewport renders the dedicated mobile shell instead of the
// desktop canvas. Tri-state: `null` until matchMedia has resolved,
// then `true|false`. While null we keep the existing loading spinner
// up — that way mobile devices never flash the desktop tree (which
// they would if we defaulted to `false` and only flipped post-mount).
const [isMobile, setIsMobile] = useState<boolean | null>(null);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) {
setIsMobile(false);
return;
}
const mq = window.matchMedia("(max-width: 639px)");
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
// Distinct from hydrationError: platform-down is its own UX path
// (different copy, different action — the user's next step is to
// check local services, not to retry the API call). Tracked
@ -51,7 +69,10 @@ export default function Home() {
};
}, []);
if (hydrating) {
// Hold the spinner while data hydrates OR while the viewport
// resolution hasn't settled yet (avoids a desktop-tree flash on
// mobile devices between SSR-paint and matchMedia).
if (hydrating || isMobile === null) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-surface">
<div role="status" aria-live="polite" className="flex flex-col items-center gap-3">
@ -66,6 +87,32 @@ export default function Home() {
return <PlatformDownDiagnostic />;
}
if (isMobile) {
return (
<>
<MobileApp />
{hydrationError && (
<div
role="alert"
data-testid="hydration-error"
className="fixed inset-0 flex flex-col items-center justify-center bg-surface text-ink-mid gap-4 z-[9999] px-6"
>
<p className="text-ink-mid text-sm text-center">{hydrationError}</p>
<button
onClick={() => {
setHydrationError(null);
window.location.reload();
}}
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm"
>
Retry
</button>
</div>
)}
</>
);
}
return (
<>
<Canvas />

View File

@ -308,7 +308,9 @@ function CanvasInner() {
showInteractive={false}
/>
<MiniMap
className="!bg-surface-sunken/90 !border-line/50 !rounded-lg !shadow-xl !shadow-black/20"
// hidden < sm: minimap eats ~30% of a phone screen and
// overlaps with the New Workspace FAB at bottom-right.
className="!bg-surface-sunken/90 !border-line/50 !rounded-lg !shadow-xl !shadow-black/20 !hidden sm:!block"
// Mask dims off-viewport areas; tint matches the surface so
// the dimming doesn't show as a black bar in light mode.
maskColor={resolvedTheme === "dark" ? "rgba(0, 0, 0, 0.7)" : "rgba(232, 226, 211, 0.7)"}

View File

@ -63,9 +63,21 @@ export function SidePanel() {
? parsed
: SIDEPANEL_DEFAULT_WIDTH;
});
// On mobile (< 640px viewport) the configured width exceeds the screen,
// so the panel renders off-canvas-left. Force full-viewport width and
// disable resize on small screens; restore configured width on desktop.
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
setSidePanelWidth(width);
}, [width, setSidePanelWidth]);
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(max-width: 639px)");
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
useEffect(() => {
setSidePanelWidth(isMobile ? 0 : width);
}, [width, isMobile, setSidePanelWidth]);
const widthRef = useRef(width); // tracks live drag value for the mouseup handler
const dragging = useRef(false);
const startX = useRef(0);
@ -137,24 +149,28 @@ export function SidePanel() {
return (
<div
className="fixed top-0 right-0 h-full bg-surface/95 backdrop-blur-xl border-l border-line/50 flex flex-col z-50 shadow-2xl shadow-black/50 animate-in slide-in-from-right duration-200"
style={{ width }}
className={`fixed top-0 right-0 h-full bg-surface/95 backdrop-blur-xl border-line/50 flex flex-col z-50 shadow-2xl shadow-black/50 animate-in slide-in-from-right duration-200 ${
isMobile ? "left-0 w-screen" : "border-l"
}`}
style={isMobile ? undefined : { width }}
>
{/* Resize handle */}
<div
role="separator"
aria-label="Resize workspace panel"
aria-valuenow={width}
aria-valuemin={SIDEPANEL_MIN_WIDTH}
aria-valuemax={SIDEPANEL_MAX_WIDTH}
aria-orientation="vertical"
tabIndex={0}
onMouseDown={onMouseDown}
onKeyDown={onResizeKeyDown}
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset"
/>
{/* Resize handle — desktop only (no point resizing a full-screen mobile panel) */}
{!isMobile && (
<div
role="separator"
aria-label="Resize workspace panel"
aria-valuenow={width}
aria-valuemin={SIDEPANEL_MIN_WIDTH}
aria-valuemax={SIDEPANEL_MAX_WIDTH}
aria-orientation="vertical"
tabIndex={0}
onMouseDown={onMouseDown}
onKeyDown={onResizeKeyDown}
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset"
/>
)}
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-line/40 bg-surface-sunken/30">
<div className="flex items-center justify-between px-4 sm:px-5 py-4 border-b border-line/40 bg-surface-sunken/30">
<div className="flex items-center gap-3 min-w-0">
<div className="relative">
<StatusDot status={node.data.status} size="md" />
@ -190,7 +206,7 @@ export function SidePanel() {
</div>
{/* Capability summary */}
<div className="px-5 py-3 border-b border-line/40 bg-surface-sunken/20">
<div className="px-4 sm:px-5 py-3 border-b border-line/40 bg-surface-sunken/20">
<div className="flex flex-wrap gap-2">
<MetaPill label="Tier" value={`T${node.data.tier}`} />
<MetaPill label="Runtime" value={capability.runtime || "unknown"} />
@ -295,8 +311,8 @@ export function SidePanel() {
</div>
{/* Footer — workspace ID */}
<div className="px-5 py-2 border-t border-line/40 bg-surface-sunken/20">
<span className="text-[9px] font-mono text-ink-mid select-all">
<div className="px-4 sm:px-5 py-2 border-t border-line/40 bg-surface-sunken/20">
<span className="text-[9px] font-mono text-ink-mid select-all block truncate">
{selectedNodeId}
</span>
</div>

View File

@ -154,13 +154,13 @@ export function Toolbar() {
return (
<div
className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-surface-sunken/80 backdrop-blur-md border border-line/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200"
className="fixed top-3 z-20 flex items-center gap-3 bg-surface-sunken/80 backdrop-blur-md border border-line/60 rounded-xl px-3 sm:px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200 left-2 right-2 translate-x-0 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 overflow-x-auto sm:overflow-visible [&>*]:shrink-0"
style={toolbarOffsetStyle}
>
{/* Logo / Title */}
<div className="flex items-center gap-2 pr-3 border-r border-line/60">
{/* Logo / Title — title text drops on mobile to reclaim space */}
<div className="flex items-center gap-2 sm:pr-3 sm:border-r sm:border-line/60">
<img src="/molecule-icon.png" alt="Molecule AI" className="w-5 h-5" />
<span className="text-[11px] font-semibold text-ink-mid tracking-wide">Molecule AI</span>
<span className="hidden sm:inline text-[11px] font-semibold text-ink-mid tracking-wide">Molecule AI</span>
</div>
{/* Status pills + workspace total in one segment previously two
@ -179,15 +179,15 @@ export function Toolbar() {
{counts.failed > 0 && (
<StatusPill color={statusDotClass("failed")} count={counts.failed} label="failed" />
)}
<span className="text-ink-mid" aria-hidden="true">·</span>
<span className="text-[10px] text-ink-mid whitespace-nowrap">
<span className="hidden sm:inline text-ink-mid" aria-hidden="true">·</span>
<span className="hidden sm:inline text-[10px] text-ink-mid whitespace-nowrap">
{counts.roots} workspace{counts.roots !== 1 ? "s" : ""}
{counts.children > 0 && <span className="text-ink-mid"> + {counts.children} sub</span>}
</span>
</div>
{/* WebSocket connection status */}
<div className="pl-3 border-l border-line/60">
<div className="sm:pl-3 sm:border-l sm:border-line/60">
<WsStatusPill status={wsStatus} />
</div>

View File

@ -0,0 +1,210 @@
"use client";
// MobileApp — top-level mobile shell.
// Local route state, bottom tab bar, theme-aware palette. Only rendered
// on viewports < 640px (see app/page.tsx). The desktop Canvas is not
// instantiated when MobileApp is active, so no React Flow + heavy
// chrome cost on phones.
import { useEffect, useMemo, useState } from "react";
import { useTheme } from "@/lib/theme-provider";
import { TabBar, type MobileTabId } from "./components";
import { MobileCanvas } from "./MobileCanvas";
import { MobileChat } from "./MobileChat";
import { MobileComms } from "./MobileComms";
import { MobileDetail } from "./MobileDetail";
import { MobileHome } from "./MobileHome";
import { MobileMe } from "./MobileMe";
import { MobileSpawn } from "./MobileSpawn";
import { usePalette } from "./palette";
import { MobileAccentProvider } from "./palette-context";
type Route = "home" | "canvas" | "detail" | "chat" | "comms" | "me";
const ROUTES: Route[] = ["home", "canvas", "detail", "chat", "comms", "me"];
const ACCENT_KEY = "molecule.mobile.accent";
const DENSITY_KEY = "molecule.mobile.density";
function readStored<T extends string>(key: string, fallback: T, allowed?: T[]): T {
if (typeof window === "undefined") return fallback;
try {
const v = window.localStorage.getItem(key);
if (!v) return fallback;
if (allowed && !allowed.includes(v as T)) return fallback;
return v as T;
} catch {
return fallback;
}
}
interface UrlState {
route: Route;
agentId: string | null;
}
/** Parse the current URL into a (route, agentId) pair. Reads from
* `?m=<route>&a=<agentId>` `home` is the default when `m` is
* absent. Detail/chat without an agent id collapse back to `home`
* because they're meaningless without one. */
function readRouteFromUrl(): UrlState {
if (typeof window === "undefined") return { route: "home", agentId: null };
const params = new URLSearchParams(window.location.search);
const m = params.get("m");
const a = params.get("a");
const route: Route = ROUTES.includes(m as Route) ? (m as Route) : "home";
if ((route === "detail" || route === "chat") && !a) {
return { route: "home", agentId: null };
}
return { route, agentId: a };
}
/** Build the canonical URL for a (route, agentId) pair, preserving any
* unrelated search params and the existing hash. `home` is the default
* state, so we drop `m` from the URL to keep the no-state link clean. */
function buildRouteUrl(route: Route, agentId: string | null): string {
if (typeof window === "undefined") return "";
const params = new URLSearchParams(window.location.search);
if (route === "home") params.delete("m");
else params.set("m", route);
if (agentId && (route === "detail" || route === "chat")) params.set("a", agentId);
else params.delete("a");
const search = params.toString();
return window.location.pathname + (search ? "?" + search : "") + window.location.hash;
}
export function MobileApp() {
const { resolvedTheme } = useTheme();
const dark = resolvedTheme === "dark";
const p = usePalette(dark);
// Seed route + agentId from the URL so deep links like
// `/?m=detail&a=ws-42` open straight on the right screen.
const [route, setRoute] = useState<Route>(() => readRouteFromUrl().route);
const [agentId, setAgentId] = useState<string | null>(() => readRouteFromUrl().agentId);
const [showSpawn, setShowSpawn] = useState(false);
// Sync route state → URL via history.pushState. Skip the push when
// the URL is already what we'd produce — that handles the initial
// mount (we read FROM the URL) and prevents duplicate history entries
// when popstate restores state we just pushed.
useEffect(() => {
if (typeof window === "undefined") return;
const current = readRouteFromUrl();
if (current.route === route && current.agentId === agentId) return;
const url = buildRouteUrl(route, agentId);
window.history.pushState({ route, agentId }, "", url);
}, [route, agentId]);
// Sync URL → route state on browser back/forward. The popstate event
// fires AFTER the URL has changed, so re-reading is correct.
useEffect(() => {
if (typeof window === "undefined") return;
const onPop = () => {
const next = readRouteFromUrl();
setRoute(next.route);
setAgentId(next.agentId);
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
const [accent, setAccentState] = useState<string>(() => readStored(ACCENT_KEY, "#2f9e6a"));
const [density, setDensityState] = useState<"compact" | "regular">(() =>
readStored<"compact" | "regular">(DENSITY_KEY, "regular", ["compact", "regular"]),
);
// Persist accent. The accent itself is propagated into every palette
// read via React context (MobileAccentProvider below) — never by
// mutating the MOL_LIGHT/MOL_DARK singletons.
useEffect(() => {
try {
window.localStorage.setItem(ACCENT_KEY, accent);
} catch {
/* noop */
}
}, [accent]);
useEffect(() => {
try {
window.localStorage.setItem(DENSITY_KEY, density);
} catch {
/* noop */
}
}, [density]);
const activeTab: MobileTabId = useMemo(() => {
if (route === "canvas") return "canvas";
if (route === "comms") return "comms";
if (route === "me") return "me";
return "agents";
}, [route]);
const onTabChange = (id: MobileTabId) => {
if (id === "agents") setRoute("home");
else if (id === "canvas") setRoute("canvas");
else if (id === "comms") setRoute("comms");
else if (id === "me") setRoute("me");
};
const openAgent = (id: string) => {
setAgentId(id);
setRoute("detail");
};
// Tab bar visible everywhere except chat (per design).
const showTabBar = route !== "chat";
return (
<MobileAccentProvider accent={accent}>
<main
style={{
position: "fixed",
inset: 0,
background: p.bg,
color: p.text,
overflow: "hidden",
contain: "strict",
}}
>
{route === "home" && (
<MobileHome
dark={dark}
density={density}
onOpen={openAgent}
onSpawn={() => setShowSpawn(true)}
/>
)}
{route === "canvas" && (
<MobileCanvas dark={dark} onOpen={openAgent} onSpawn={() => setShowSpawn(true)} />
)}
{route === "detail" && agentId && (
<MobileDetail
agentId={agentId}
dark={dark}
onBack={() => setRoute("home")}
onChat={() => setRoute("chat")}
/>
)}
{route === "chat" && agentId && (
<MobileChat agentId={agentId} dark={dark} onBack={() => setRoute("detail")} />
)}
{route === "comms" && <MobileComms dark={dark} />}
{route === "me" && (
<MobileMe
dark={dark}
accent={accent}
setAccent={setAccentState}
density={density}
setDensity={setDensityState}
/>
)}
{showTabBar && <TabBar dark={dark} active={activeTab} onChange={onTabChange} />}
{showSpawn && <MobileSpawn dark={dark} onClose={() => setShowSpawn(false)} />}
</main>
</MobileAccentProvider>
);
}

View File

@ -0,0 +1,401 @@
"use client";
// 02 · Canvas graph — pan-friendly mini-graph with status-coloured nodes.
// Node positions come from the live store (the same x/y the desktop canvas
// uses). The screen normalizes them to a 0..1 viewport so the graph fits
// the phone frame regardless of where the user has the desktop pan/zoom.
import { useMemo, useRef, useState, type TouchEvent as ReactTouchEvent } from "react";
import { useCanvasStore } from "@/store/canvas";
import { type MobileAgent, WorkspacePill, toMobileAgent } from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
import { Icons, StatusDot, TierChip } from "./primitives";
const SCALE_MIN = 0.5;
const SCALE_MAX = 3;
interface Gesture {
kind: "none" | "pinch" | "pan";
startDist?: number;
startScale?: number;
startTouch?: { x: number; y: number };
startPan?: { x: number; y: number };
}
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
export function MobileCanvas({
dark,
onOpen,
onSpawn,
}: {
dark: boolean;
onOpen: (agentId: string) => void;
onSpawn: () => void;
}) {
const p = usePalette(dark);
const nodes = useCanvasStore((s) => s.nodes);
// Project store nodes into 0..100 (%) space, leaving 8% padding on each
// edge so cards don't clip. Falls back to a uniform circular layout
// when every node sits at (0,0) — common right after first hydrate.
const layout = useMemo(() => {
const items = nodes.map((n) => ({
id: n.id,
agent: toMobileAgent(n),
x: n.position?.x ?? 0,
y: n.position?.y ?? 0,
parentId: n.data.parentId ?? null,
}));
if (items.length === 0) return [] as Array<{ agent: MobileAgent; x: number; y: number; parentId: string | null }>;
const xs = items.map((i) => i.x);
const ys = items.map((i) => i.y);
const xMin = Math.min(...xs);
const xMax = Math.max(...xs);
const yMin = Math.min(...ys);
const yMax = Math.max(...ys);
const spread = (xMax - xMin) + (yMax - yMin);
if (spread < 1) {
// Degenerate (everything stacked) — fall back to a ring.
const n = items.length;
return items.map((it, idx) => {
const angle = (idx / n) * Math.PI * 2;
return {
agent: it.agent,
parentId: it.parentId,
x: 50 + Math.cos(angle) * 32,
y: 50 + Math.sin(angle) * 26,
};
});
}
const scaleX = (v: number) =>
xMax === xMin ? 50 : 8 + ((v - xMin) / (xMax - xMin)) * 84;
const scaleY = (v: number) =>
yMax === yMin ? 50 : 14 + ((v - yMin) / (yMax - yMin)) * 70;
return items.map((it) => ({
agent: it.agent,
parentId: it.parentId,
x: scaleX(it.x),
y: scaleY(it.y),
}));
}, [nodes]);
// Edges = parent→child relations from the store.
const edges = useMemo(() => {
const byId = new Map(layout.map((l) => [l.agent.id, l]));
return layout
.filter((l) => l.parentId && byId.has(l.parentId))
.map((l) => ({ from: byId.get(l.parentId!)!, to: l }));
}, [layout]);
// Pinch-to-zoom + single-finger pan over the graph layer. Header pill,
// legend, and FAB stay anchored to the viewport (outside the transform
// layer). Tap-to-open still works because a stationary touchend
// dispatches a click on the underlying button.
const [scale, setScale] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const gestureRef = useRef<Gesture>({ kind: "none" });
const onTouchStart = (e: ReactTouchEvent<HTMLDivElement>) => {
if (e.touches.length === 2) {
const a = e.touches[0];
const b = e.touches[1];
gestureRef.current = {
kind: "pinch",
startDist: Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY),
startScale: scale,
};
} else if (e.touches.length === 1) {
const t = e.touches[0];
gestureRef.current = {
kind: "pan",
startTouch: { x: t.clientX, y: t.clientY },
startPan: { ...pan },
};
}
};
const onTouchMove = (e: ReactTouchEvent<HTMLDivElement>) => {
const g = gestureRef.current;
if (g.kind === "pinch" && e.touches.length === 2 && g.startDist && g.startScale) {
const a = e.touches[0];
const b = e.touches[1];
const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
setScale(clamp(g.startScale * (dist / g.startDist), SCALE_MIN, SCALE_MAX));
} else if (g.kind === "pan" && e.touches.length === 1 && g.startTouch && g.startPan) {
const t = e.touches[0];
setPan({
x: g.startPan.x + (t.clientX - g.startTouch.x),
y: g.startPan.y + (t.clientY - g.startTouch.y),
});
}
};
const onTouchEnd = (e: ReactTouchEvent<HTMLDivElement>) => {
if (e.touches.length === 0) gestureRef.current = { kind: "none" };
};
const resetView = () => {
setScale(1);
setPan({ x: 0, y: 0 });
};
const transformStyle = {
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
transformOrigin: "50% 50%",
// Smooth out the pinch math without lagging the gesture; tighter
// than a CSS animation so it doesn't feel rubber-bandy.
willChange: "transform",
};
const zoomed = Math.abs(scale - 1) > 0.01 || pan.x !== 0 || pan.y !== 0;
return (
<div
style={{
position: "absolute",
inset: 0,
background: p.bg,
overflow: "hidden",
fontFamily: MOBILE_FONT_SANS,
// Tell the browser we own touch gestures here — without this, the
// browser performs default pinch-to-zoom on the page itself,
// which would zoom the entire phone shell, not just our graph.
touchAction: "none",
}}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
{/* Dotted grid background — fills the viewport, doesn't transform */}
<div
style={{
position: "absolute",
inset: 0,
backgroundImage: `radial-gradient(${dark ? "rgba(255,255,255,0.05)" : "rgba(40,30,20,0.07)"} 1px, transparent 1px)`,
backgroundSize: "18px 18px",
}}
/>
{/* Header pill */}
<div
style={{
position: "absolute",
top: "max(env(safe-area-inset-top), 44px)",
left: 0,
right: 0,
zIndex: 20,
display: "flex",
justifyContent: "center",
padding: "0 12px",
}}
>
<WorkspacePill dark={dark} count={nodes.length} />
</div>
{/* Reset-view button only shown after the user has zoomed or
panned, so the corner stays clean by default. Sits next to the
legend so it doesn't fight the spawn FAB. */}
{zoomed && (
<button
type="button"
onClick={resetView}
aria-label="Reset zoom"
style={{
position: "absolute",
right: 14,
top: "calc(max(env(safe-area-inset-top), 44px) + 56px)",
zIndex: 25,
padding: "6px 12px",
borderRadius: 999,
cursor: "pointer",
background: dark ? "rgba(34,33,28,0.78)" : "rgba(255,253,247,0.88)",
backdropFilter: "blur(20px)",
border: `0.5px solid ${p.border}`,
color: p.text2,
fontSize: 11,
fontFamily: MOBILE_FONT_MONO,
letterSpacing: "0.04em",
textTransform: "uppercase",
fontWeight: 600,
}}
>
Reset
</button>
)}
{/* Transform layer pinch-zoom + pan apply here. Edges and nodes
live inside so they scale together; everything outside this
layer (header, legend, FAB) is anchored to the viewport. */}
<div
style={{
position: "absolute",
inset: 0,
...transformStyle,
}}
>
{/* SVG edges */}
<svg
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
zIndex: 1,
pointerEvents: "none",
}}
aria-hidden="true"
>
{edges.map((e, i) => (
<line
key={i}
x1={`${e.from.x}%`}
y1={`${e.from.y}%`}
x2={`${e.to.x}%`}
y2={`${e.to.y}%`}
stroke={dark ? "rgba(255,255,255,0.12)" : "rgba(40,30,20,0.12)"}
strokeWidth={1 / scale}
strokeDasharray="2 4"
/>
))}
</svg>
{/* Nodes */}
{layout.map((l) => {
const isOnline = l.agent.status === "online";
return (
<button
key={l.agent.id}
type="button"
onClick={() => onOpen(l.agent.id)}
style={{
position: "absolute",
left: `${l.x}%`,
top: `${l.y}%`,
transform: "translate(-50%, -50%)",
width: 130,
maxWidth: "42%",
background:
l.agent.tier === "T4" && isOnline
? p.t4SoftCard
: isOnline
? p.greenSoft
: p.surface,
border: `0.5px solid ${p.border}`,
borderRadius: 12,
padding: "8px 10px",
display: "flex",
flexDirection: "column",
gap: 4,
cursor: "pointer",
textAlign: "left",
boxShadow: dark
? "0 4px 14px rgba(0,0,0,0.3)"
: "0 2px 8px rgba(40,30,20,0.06)",
zIndex: 5,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<StatusDot status={l.agent.status} size={7} dark={dark} halo={false} />
<span
style={{
flex: 1,
fontSize: 12,
fontWeight: 600,
color: p.text,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{l.agent.name}
</span>
<TierChip tier={l.agent.tier} dark={dark} />
</div>
<div
style={{
fontSize: 9,
color: p.text3,
letterSpacing: "0.04em",
fontFamily: MOBILE_FONT_MONO,
}}
>
{l.agent.tag}
</div>
</button>
);
})}
</div>
{/* End transform layer */}
{/* Bottom legend */}
<div
style={{
position: "absolute",
left: 14,
bottom: 96,
zIndex: 25,
background: dark ? "rgba(34,33,28,0.78)" : "rgba(255,253,247,0.88)",
backdropFilter: "blur(20px)",
border: `0.5px solid ${p.border}`,
borderRadius: 14,
padding: "10px 12px",
boxShadow: "0 4px 14px rgba(40,30,20,0.08)",
fontFamily: MOBILE_FONT_MONO,
fontSize: 9.5,
color: p.text2,
letterSpacing: "0.04em",
}}
>
<div
style={{
fontWeight: 600,
color: p.text3,
marginBottom: 6,
textTransform: "uppercase",
}}
>
Legend
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", maxWidth: 180 }}>
{(["online", "starting", "degraded", "failed", "paused"] as const).map((s) => (
<span key={s} style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<StatusDot status={s} size={6} dark={dark} halo={false} />
{s}
</span>
))}
</div>
</div>
{/* Spawn FAB */}
<button
type="button"
onClick={onSpawn}
aria-label="Spawn new agent"
style={{
position: "absolute",
right: 24,
bottom: 100,
zIndex: 25,
width: 54,
height: 54,
borderRadius: 999,
border: "none",
cursor: "pointer",
background: p.text,
color: dark ? p.bg : "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 8px 24px rgba(40,30,20,0.25)",
}}
>
{Icons.plus({ size: 22 })}
</button>
</div>
);
}

View File

@ -0,0 +1,493 @@
"use client";
// 04 · Chat — message thread + composer + sub-tabs.
// Wired to the same /workspaces/:id/a2a (method message/send) endpoint
// that the desktop ChatTab uses, but with a slimmer surface: no
// attachments, no A2A topology overlay, no conversation tracing.
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { toMobileAgent } from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
import { Icons, StatusDot, TierChip } from "./primitives";
interface ChatMessage {
id: string;
role: "user" | "agent" | "system";
text: string;
ts: string;
}
const formatStoredTimestamp = (iso: string): string => {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
};
type SubTab = "my" | "a2a";
interface A2AResponseShape {
result?: {
parts?: Array<{ kind?: string; text?: string }>;
};
error?: { message?: string };
}
const formatTime = (date: Date) =>
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
export function MobileChat({
agentId,
dark,
onBack,
}: {
agentId: string;
dark: boolean;
onBack: () => void;
}) {
const p = usePalette(dark);
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
// Bootstrap from the canvas store's per-workspace message buffer so the
// user sees their prior thread on entry. The store is updated by the
// socket → ChatTab flows the desktop runs; on mobile we read from the
// same buffer to keep state coherent across viewports.
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId] ?? []);
const [messages, setMessages] = useState<ChatMessage[]>(() =>
storedMessages.map((m) => ({
id: m.id,
role: "agent",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
})),
);
const [draft, setDraft] = useState("");
const [tab, setTab] = useState<SubTab>("my");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Synchronous re-entry guard. `setSending(true)` schedules a state
// update but doesn't flush before a second tap can fire send() — a ref
// mirrors the desktop ChatTab pattern (sendInFlightRef) and closes the
// double-send race a stale `sending` lets through.
const sendInFlightRef = useRef(false);
const composerRef = useRef<HTMLTextAreaElement>(null);
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
// shrinks when the user deletes text, then size to scrollHeight up to
// a 5-line cap. Beyond the cap, internal scroll kicks in.
useEffect(() => {
const el = composerRef.current;
if (!el) return;
el.style.height = "auto";
const next = Math.min(el.scrollHeight, 132); // ~5 lines at 14.5px/1.4
el.style.height = `${next}px`;
}, [draft]);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
if (!node) {
return (
<div
style={{
height: "100%",
background: p.bg,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: p.text3,
fontSize: 13,
fontFamily: MOBILE_FONT_SANS,
}}
>
Agent not found.
</div>
);
}
const a = toMobileAgent(node);
const reachable = a.status === "online" || a.status === "degraded";
const send = async () => {
const text = draft.trim();
if (!text || sending || !reachable) return;
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
setDraft("");
setError(null);
setSending(true);
const myMsg: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
text,
ts: formatTime(new Date()),
};
setMessages((m) => [...m, myMsg]);
try {
const res = await api.post<A2AResponseShape>(`/workspaces/${agentId}/a2a`, {
method: "message/send",
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
parts: [{ kind: "text", text }],
},
},
});
const reply =
res.result?.parts?.find((part) => part.kind === "text")?.text ?? "";
if (reply) {
setMessages((m) => [
...m,
{
id: crypto.randomUUID(),
role: "agent",
text: reply,
ts: formatTime(new Date()),
},
]);
} else if (res.error?.message) {
setError(res.error.message);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to send");
} finally {
setSending(false);
sendInFlightRef.current = false;
}
};
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
background: p.bg,
fontFamily: MOBILE_FONT_SANS,
}}
>
{/* Header */}
<div
style={{
padding: "max(env(safe-area-inset-top), 44px) 14px 10px",
borderBottom: `0.5px solid ${p.divider}`,
background: dark ? "rgba(21,20,15,0.85)" : "rgba(246,244,239,0.85)",
backdropFilter: "blur(14px)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<button
type="button"
onClick={onBack}
aria-label="Back"
style={{
width: 36,
height: 36,
borderRadius: 999,
border: "none",
cursor: "pointer",
background: "transparent",
color: p.text2,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{Icons.back({ size: 18 })}
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<StatusDot status={a.status} size={7} dark={dark} halo={false} />
<span
style={{
fontSize: 15,
fontWeight: 600,
color: p.text,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{a.name}
</span>
<TierChip tier={a.tier} dark={dark} />
</div>
<div
style={{
fontSize: 11,
color: p.text3,
marginTop: 2,
fontFamily: MOBILE_FONT_MONO,
}}
>
{a.runtime} · {a.skills} skills
</div>
</div>
<button
type="button"
aria-label="More"
style={{
width: 36,
height: 36,
borderRadius: 999,
border: "none",
cursor: "pointer",
background: "transparent",
color: p.text2,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{Icons.more({ size: 18 })}
</button>
</div>
{/* Sub-tabs */}
<div style={{ display: "flex", gap: 18, marginTop: 12, paddingLeft: 4 }}>
{(
[
{ id: "my", label: "My Chat" },
{ id: "a2a", label: "Agent Comms" },
] as const
).map((t) => {
const on = tab === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
style={{
padding: "4px 0 8px",
border: "none",
background: "transparent",
fontSize: 13.5,
cursor: "pointer",
color: on ? p.text : p.text3,
fontWeight: on ? 600 : 500,
borderBottom: on ? `2px solid ${p.accent}` : "2px solid transparent",
}}
>
{t.label}
</button>
);
})}
</div>
</div>
{/* Messages */}
<div
ref={scrollRef}
style={{
flex: 1,
overflow: "auto",
padding: "14px 14px 16px",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{tab === "a2a" && (
<div
style={{
padding: "20px 4px",
textAlign: "center",
color: p.text3,
fontSize: 13,
}}
>
Agent Comms peer-to-peer A2A traffic surfaces in the Comms tab.
</div>
)}
{tab === "my" && messages.length === 0 && (
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
Send a message to start chatting.
</div>
)}
{tab === "my" &&
messages.map((m) => {
const mine = m.role === "user";
return (
<div
key={m.id}
style={{
display: "flex",
justifyContent: mine ? "flex-end" : "flex-start",
}}
>
<div
style={{
maxWidth: "78%",
background: mine ? p.accent : dark ? "#22211c" : "#fff",
color: mine ? "#fff" : p.text,
border: mine ? "none" : `0.5px solid ${p.border}`,
borderRadius: mine ? "18px 18px 4px 18px" : "18px 18px 18px 4px",
padding: "9px 13px",
fontSize: 14.5,
lineHeight: 1.4,
overflowWrap: "anywhere",
}}
>
{m.text}
<div
style={{
fontSize: 10,
marginTop: 4,
opacity: mine ? 0.75 : 0.5,
fontFamily: MOBILE_FONT_MONO,
}}
>
{m.ts}
</div>
</div>
</div>
);
})}
{error && (
<div
role="alert"
style={{
alignSelf: "center",
padding: "6px 12px",
borderRadius: 12,
background: `${p.failed}1a`,
color: p.failed,
fontSize: 12,
}}
>
{error}
</div>
)}
</div>
{/* Footer ID */}
<div
style={{
padding: "0 14px 6px",
textAlign: "center",
fontFamily: MOBILE_FONT_MONO,
fontSize: 9.5,
color: p.text3,
letterSpacing: "0.04em",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{agentId}
</div>
{/* Composer */}
<div
style={{
padding: "10px 12px max(env(safe-area-inset-bottom), 16px)",
borderTop: `0.5px solid ${p.divider}`,
background: dark ? "rgba(21,20,15,0.92)" : "rgba(246,244,239,0.92)",
backdropFilter: "blur(14px)",
}}
>
<div
style={{
display: "flex",
alignItems: "flex-end",
gap: 8,
background: dark ? "#22211c" : "#fff",
border: `0.5px solid ${p.border}`,
borderRadius: 22,
padding: "6px 6px 6px 12px",
}}
>
<button
type="button"
aria-label="Attach"
style={{
width: 32,
height: 32,
borderRadius: 999,
border: "none",
cursor: "pointer",
background: "transparent",
color: p.text3,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{Icons.attach({ size: 16 })}
</button>
<textarea
ref={composerRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Enter sends; Shift+Enter inserts a newline. Skip when the
// IME is composing — pressing Enter to commit a Chinese/
// Japanese candidate would otherwise dispatch the half-typed
// message (the same regression the desktop ChatTab guards).
if (
e.key === "Enter" &&
!e.shiftKey &&
!e.nativeEvent.isComposing &&
e.keyCode !== 229
) {
e.preventDefault();
send();
}
}}
placeholder={reachable ? "Send a message…" : `Agent is ${a.status}`}
disabled={!reachable}
rows={1}
style={{
flex: 1,
border: "none",
outline: "none",
background: "transparent",
fontSize: 14.5,
lineHeight: 1.4,
color: p.text,
padding: "6px 0",
fontFamily: "inherit",
minWidth: 0,
resize: "none",
maxHeight: 132,
overflowY: "auto",
}}
/>
<button
type="button"
onClick={send}
disabled={!draft.trim() || !reachable || sending}
aria-label="Send"
style={{
width: 36,
height: 36,
borderRadius: 999,
border: "none",
cursor: draft.trim() && !sending ? "pointer" : "not-allowed",
flexShrink: 0,
background:
draft.trim() && reachable && !sending
? p.accent
: dark
? "#2a2823"
: "#ece9e0",
color: draft.trim() && reachable && !sending ? "#fff" : p.text3,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{Icons.send({ size: 16 })}
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,368 @@
"use client";
// 05 · Comms feed — workspace-wide A2A traffic.
// Bootstraps from /workspaces/:id/activity for the first few online
// workspaces, then prepends ACTIVITY_LOGGED events from the live socket.
import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { useSocketEvent } from "@/hooks/useSocketEvent";
import { useCanvasStore } from "@/store/canvas";
import { WorkspacePill } from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
import { SectionLabel } from "./primitives";
interface CommItem {
id: string;
from: string;
to: string;
kind: string;
status: "ok" | "err";
summary: string;
durationMs: number | null;
ago: string;
ts: number;
}
interface ActivityRecord {
id: string;
workspace_id: string;
activity_type: string;
source_id: string | null;
target_id: string | null;
summary: string | null;
status: string;
duration_ms: number | null;
created_at: string;
}
const FAN_OUT_CAP = 4;
const RENDER_CAP = 30;
type FilterId = "all" | "errors";
function relativeAgo(iso: string): string {
const t = Date.parse(iso);
if (isNaN(t)) return "";
const seconds = Math.max(0, Math.round((Date.now() - t) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.round(hours / 24);
return `${days}d`;
}
export function MobileComms({ dark }: { dark: boolean }) {
const p = usePalette(dark);
const nodes = useCanvasStore((s) => s.nodes);
const [items, setItems] = useState<CommItem[]>([]);
const [filter, setFilter] = useState<FilterId>("all");
const [loading, setLoading] = useState(true);
const nameOf = useCallback(
(id: string | null | undefined): string => {
if (!id) return "Unknown";
const n = nodes.find((x) => x.id === id);
return n?.data.name ?? id.slice(0, 8);
},
[nodes],
);
const toItem = useCallback(
(a: ActivityRecord): CommItem => ({
id: a.id,
from: nameOf(a.source_id ?? a.workspace_id),
to: nameOf(a.target_id),
kind: a.activity_type,
status: a.status === "error" || a.status === "err" ? "err" : "ok",
summary: a.summary ?? "",
durationMs: a.duration_ms,
ago: relativeAgo(a.created_at),
ts: Date.parse(a.created_at) || Date.now(),
}),
[nameOf],
);
// Stable signature of the online-workspace set. Re-runs the bootstrap
// only when which workspaces are online changes — not on every node
// position update or unrelated data churn.
const onlineWorkspaceIds = useMemo(
() =>
nodes
.filter((n) => n.data.status === "online")
.slice(0, FAN_OUT_CAP)
.map((n) => n.id),
[nodes],
);
const onlineSignature = onlineWorkspaceIds.join("|");
// Bootstrap: pull the most recent activity from the first few online
// workspaces. Identical fan-out cap to CommunicationOverlay to keep
// the load profile predictable on big tenants.
useEffect(() => {
let cancelled = false;
if (onlineWorkspaceIds.length === 0) {
setLoading(false);
return;
}
Promise.all(
onlineWorkspaceIds.map((id) =>
api.get<ActivityRecord[]>(`/workspaces/${id}/activity?limit=8`).catch(() => []),
),
).then((batches) => {
if (cancelled) return;
const flat = batches.flat().map(toItem);
flat.sort((a, b) => b.ts - a.ts);
setItems(flat.slice(0, RENDER_CAP));
setLoading(false);
});
return () => {
cancelled = true;
};
// Effect depends on the signature string (stable when the id set
// doesn't change) + toItem (memoized via useCallback). Listing the
// id-array directly would re-run on every render because the array
// identity changes even when the contents don't.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onlineSignature, toItem]);
// Live: prepend ACTIVITY_LOGGED events as they arrive.
useSocketEvent((msg) => {
if (msg.event !== "ACTIVITY_LOGGED") return;
const payload = msg.payload as Partial<ActivityRecord> | undefined;
if (!payload || !payload.id) return;
const rec: ActivityRecord = {
id: payload.id,
workspace_id: payload.workspace_id ?? msg.workspace_id ?? "",
activity_type: payload.activity_type ?? "a2a",
source_id: payload.source_id ?? null,
target_id: payload.target_id ?? null,
summary: payload.summary ?? null,
status: payload.status ?? "ok",
duration_ms: payload.duration_ms ?? null,
created_at: payload.created_at ?? new Date().toISOString(),
};
setItems((prev) => [toItem(rec), ...prev.filter((x) => x.id !== rec.id)].slice(0, RENDER_CAP));
});
const filtered = useMemo(
() => items.filter((c) => filter === "all" || c.status === "err"),
[items, filter],
);
const errCount = useMemo(() => items.filter((c) => c.status === "err").length, [items]);
return (
<div
style={{
height: "100%",
overflow: "auto",
background: p.bg,
paddingBottom: 96,
fontFamily: MOBILE_FONT_SANS,
}}
>
<div style={{ padding: "max(env(safe-area-inset-top), 44px) 16px 8px" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 14,
}}
>
<WorkspacePill dark={dark} count={nodes.length} />
{/* Header filter button reserved the All/Errors chips below
already cover the v1 filter axis. */}
</div>
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
<h1
style={{
margin: 0,
fontSize: 32,
fontWeight: 700,
color: p.text,
letterSpacing: "-0.025em",
}}
>
Comms
</h1>
<span
style={{
fontFamily: MOBILE_FONT_MONO,
fontSize: 11,
color: p.text3,
}}
>
{items.length} events
</span>
</div>
<p style={{ margin: "4px 0 0", fontSize: 13.5, color: p.text2 }}>
Live A2A traffic across the workspace.
</p>
</div>
<div style={{ display: "flex", gap: 6, padding: "12px 16px 8px" }}>
{(
[
{ id: "all", label: "All", n: items.length },
{ id: "errors", label: "Errors", n: errCount },
] as const
).map((o) => {
const on = filter === o.id;
return (
<button
key={o.id}
type="button"
onClick={() => setFilter(o.id)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "7px 12px",
borderRadius: 999,
cursor: "pointer",
background: on ? p.text : dark ? "#22211c" : "#fff",
color: on ? (dark ? p.bg : "#fff") : p.text,
border: `0.5px solid ${on ? "transparent" : p.border}`,
fontSize: 13,
fontWeight: 500,
}}
>
{o.label}
<span
style={{
fontSize: 10.5,
opacity: 0.7,
fontFamily: MOBILE_FONT_MONO,
}}
>
{o.n}
</span>
</button>
);
})}
</div>
<SectionLabel dark={dark}>Communications</SectionLabel>
<div style={{ padding: "0 14px", display: "flex", flexDirection: "column", gap: 8 }}>
{loading && items.length === 0 ? (
<div style={{ padding: "30px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
Loading recent comms
</div>
) : filtered.length === 0 ? (
<div style={{ padding: "30px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
No A2A traffic yet.
</div>
) : (
filtered.map((c) => <CommRow key={c.id} c={c} dark={dark} />)
)}
</div>
</div>
);
}
function CommRow({ c, dark }: { c: CommItem; dark: boolean }) {
const p = usePalette(dark);
const isErr = c.status === "err";
return (
<div
style={{
background: p.surface,
borderRadius: 14,
border: `0.5px solid ${p.border}`,
padding: "12px 14px",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 12,
fontWeight: 600,
color: p.text,
}}
>
<span
style={{
padding: "1px 6px",
borderRadius: 4,
background: isErr ? "#f5dad2" : "#dde9e1",
color: isErr ? "#a8341a" : p.greenInk,
fontFamily: MOBILE_FONT_MONO,
fontSize: 9,
fontWeight: 700,
letterSpacing: "0.06em",
}}
>
{isErr ? "ERR" : "OK"}
</span>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 110,
}}
>
{c.from}
</span>
<span style={{ color: p.text3, fontWeight: 500 }}></span>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 110,
}}
>
{c.to}
</span>
<span
style={{
marginLeft: "auto",
fontSize: 10.5,
color: p.text3,
fontFamily: MOBILE_FONT_MONO,
}}
>
{c.ago}
</span>
</div>
<div
style={{
fontSize: 11,
color: p.text3,
fontWeight: 600,
fontFamily: MOBILE_FONT_MONO,
letterSpacing: "0.02em",
}}
>
{c.kind}
{c.durationMs != null && (
<span style={{ marginLeft: 8, color: isErr ? "#a8341a" : p.text3 }}>{c.durationMs}ms</span>
)}
</div>
{c.summary && (
<div
style={{
fontSize: 12.5,
color: p.text2,
lineHeight: 1.4,
overflowWrap: "anywhere",
}}
>
{c.summary}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,589 @@
"use client";
// 03 · Agent detail — pills + tabbed content (Overview/Activity/Config/Memory).
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { RemoteBadge, toMobileAgent } from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } from "./palette";
import { Icons, StatusDot, TierChip } from "./primitives";
type TabId = "overview" | "activity" | "config" | "memory";
const TABS: { id: TabId; label: string }[] = [
{ id: "overview", label: "Overview" },
{ id: "activity", label: "Activity" },
{ id: "config", label: "Config" },
{ id: "memory", label: "Memory" },
];
export function MobileDetail({
agentId,
dark,
onBack,
onChat,
}: {
agentId: string;
dark: boolean;
onBack: () => void;
onChat: () => void;
}) {
const p = usePalette(dark);
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
const [tab, setTab] = useState<TabId>("overview");
if (!node) {
return (
<div
style={{
height: "100%",
background: p.bg,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: p.text3,
fontSize: 13,
fontFamily: MOBILE_FONT_SANS,
}}
>
Agent not found.
</div>
);
}
const a = toMobileAgent(node);
return (
<div
style={{
height: "100%",
overflow: "auto",
background: p.bg,
paddingBottom: 96,
fontFamily: MOBILE_FONT_SANS,
}}
>
{/* Top bar */}
<div
style={{
position: "sticky",
top: 0,
zIndex: 10,
padding: "max(env(safe-area-inset-top), 44px) 14px 0",
background: p.bg,
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<button
type="button"
onClick={onBack}
aria-label="Back"
style={iconButtonStyle(p, dark)}
>
{Icons.back({ size: 18 })}
</button>
<button type="button" aria-label="More" style={iconButtonStyle(p, dark)}>
{Icons.more({ size: 18 })}
</button>
</div>
</div>
{/* Hero */}
<div style={{ padding: "20px 20px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<StatusDot status={a.status} size={10} dark={dark} />
<span
style={{
fontFamily: MOBILE_FONT_MONO,
fontSize: 11,
color: p.greenInk,
fontWeight: 600,
letterSpacing: "0.04em",
textTransform: "uppercase",
}}
>
{a.status}
</span>
{a.remote && <RemoteBadge palette={p} />}
</div>
<h1
style={{
margin: 0,
fontSize: 28,
fontWeight: 700,
color: p.text,
letterSpacing: "-0.02em",
}}
>
{a.name}
</h1>
<p
style={{
margin: "6px 0 0",
fontSize: 14,
color: p.text2,
fontFamily: MOBILE_FONT_MONO,
}}
>
{a.tag}
</p>
</div>
{/* Stat pills */}
<div
style={{
display: "flex",
gap: 6,
padding: "0 16px 16px",
overflowX: "auto",
scrollbarWidth: "none",
}}
>
<PillStat label="TIER" value={a.tier} accent={p.t4Ink} dark={dark} chip="tier" />
<PillStat label="RUNTIME" value={a.runtime} dark={dark} />
<PillStat label="SKILLS" value={a.skills} dark={dark} />
<PillStat label="STATUS" value={a.status} accent={p.online} dark={dark} dot />
</div>
{/* Description card */}
{a.desc && (
<div style={{ padding: "0 14px" }}>
<div
style={{
background: p.surface,
borderRadius: 16,
border: `0.5px solid ${p.border}`,
padding: "14px 16px",
}}
>
<p style={{ margin: 0, fontSize: 14.5, lineHeight: 1.5, color: p.text }}>{a.desc}</p>
</div>
</div>
)}
{/* Tabs */}
<div
style={{
display: "flex",
gap: 4,
padding: "20px 14px 10px",
overflowX: "auto",
scrollbarWidth: "none",
}}
>
{TABS.map((t) => {
const on = tab === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
style={{
padding: "8px 14px",
borderRadius: 999,
border: "none",
cursor: "pointer",
background: on ? p.text : "transparent",
color: on ? (dark ? p.bg : "#fff") : p.text2,
fontSize: 13,
fontWeight: 600,
whiteSpace: "nowrap",
}}
>
{t.label}
</button>
);
})}
</div>
{/* Tab content */}
<div style={{ padding: "0 14px" }}>
{tab === "overview" && <DetailOverview a={a} dark={dark} />}
{tab === "activity" && <DetailActivity workspaceId={a.id} dark={dark} />}
{tab === "config" && <DetailConfig a={a} dark={dark} />}
{tab === "memory" && <DetailMemory dark={dark} />}
</div>
{/* Chat CTA */}
<div style={{ position: "absolute", left: 14, right: 14, bottom: 92, zIndex: 28 }}>
<button
type="button"
onClick={onChat}
style={{
width: "100%",
height: 52,
borderRadius: 16,
cursor: "pointer",
background: p.text,
color: dark ? p.bg : "#fff",
border: "none",
fontSize: 15,
fontWeight: 600,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
boxShadow: "0 8px 22px rgba(40,30,20,0.22)",
}}
>
{Icons.chat({ size: 18 })} Open chat
</button>
</div>
</div>
);
}
function iconButtonStyle(p: MobilePalette, dark: boolean) {
return {
width: 36,
height: 36,
borderRadius: 999,
cursor: "pointer",
background: dark ? "#22211c" : "#fff",
border: `0.5px solid ${p.border}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: p.text2,
} as const;
}
function PillStat({
label,
value,
accent,
dark,
dot,
chip,
}: {
label: string;
value: string | number;
accent?: string;
dark: boolean;
dot?: boolean;
chip?: "tier";
}) {
const p = usePalette(dark);
const active = !!accent;
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "7px 12px",
borderRadius: 999,
flexShrink: 0,
background: active ? `${accent}1a` : dark ? "#22211c" : "#fff",
border: `0.5px solid ${active ? `${accent}40` : p.border}`,
}}
>
<span
style={{
fontSize: 9.5,
color: active ? accent : p.text3,
fontFamily: MOBILE_FONT_MONO,
letterSpacing: "0.06em",
textTransform: "uppercase",
fontWeight: 600,
}}
>
{label}
</span>
{dot && <StatusDot status="online" size={6} dark={dark} halo={false} />}
{chip === "tier" ? (
<TierChip tier={value as "T1" | "T2" | "T3" | "T4"} dark={dark} />
) : (
<span
style={{
fontSize: 12,
color: active ? accent : p.text,
fontWeight: 600,
textTransform: label === "STATUS" ? "capitalize" : "none",
}}
>
{value}
</span>
)}
</div>
);
}
function DetailOverview({
a,
dark,
}: {
a: ReturnType<typeof toMobileAgent>;
dark: boolean;
}) {
const p = usePalette(dark);
const Row = ({ k, v, mono = true }: { k: string; v: string; mono?: boolean }) => (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 0",
borderBottom: `0.5px solid ${p.divider}`,
}}
>
<span
style={{
fontSize: 11.5,
color: p.text3,
letterSpacing: "0.04em",
fontFamily: MOBILE_FONT_MONO,
textTransform: "uppercase",
}}
>
{k}
</span>
<span
style={{
fontSize: 13,
color: p.text,
fontWeight: 500,
fontFamily: mono ? MOBILE_FONT_MONO : "inherit",
maxWidth: "60%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{v}
</span>
</div>
);
return (
<div
style={{
background: p.surface,
borderRadius: 16,
padding: "4px 16px",
border: `0.5px solid ${p.border}`,
}}
>
<Row k="ID" v={a.id} />
<Row k="Tier" v={a.tier} />
<Row k="Runtime" v={a.runtime} />
<Row k="Active tasks" v={String(a.calls)} />
<Row k="Skills" v={`${a.skills} loaded`} />
<Row k="Origin" v={a.remote ? "remote" : "platform"} />
</div>
);
}
interface ActivityRecord {
id: string;
activity_type: string;
status: string;
summary: string | null;
duration_ms: number | null;
created_at: string;
}
function DetailActivity({ workspaceId, dark }: { workspaceId: string; dark: boolean }) {
const p = usePalette(dark);
const [items, setItems] = useState<ActivityRecord[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setError(null);
setItems(null);
api
.get<ActivityRecord[]>(`/workspaces/${workspaceId}/activity?limit=12`)
.then((rows) => {
if (!cancelled) setItems(rows);
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : "Failed to load activity");
setItems([]);
}
});
return () => {
cancelled = true;
};
}, [workspaceId]);
if (items === null) {
return (
<div
style={{
background: p.surface,
borderRadius: 16,
padding: "20px 16px",
border: `0.5px solid ${p.border}`,
color: p.text3,
fontSize: 13,
}}
>
Loading activity
</div>
);
}
if (items.length === 0) {
return (
<div
style={{
background: p.surface,
borderRadius: 16,
padding: "20px 16px",
border: `0.5px solid ${p.border}`,
color: p.text3,
fontSize: 13,
}}
>
{error ?? "No recent activity. New events appear here as the agent reports them."}
</div>
);
}
return (
<div
style={{
background: p.surface,
borderRadius: 16,
padding: "6px 16px",
border: `0.5px solid ${p.border}`,
}}
>
{items.map((it, i) => {
const ts = new Date(it.created_at);
const label = isNaN(ts.getTime())
? ""
: ts.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
const isErr = it.status === "error" || it.status === "err";
return (
<div
key={it.id}
style={{
display: "flex",
gap: 12,
padding: "12px 0",
borderBottom: i < items.length - 1 ? `0.5px solid ${p.divider}` : "none",
}}
>
<span
style={{
fontSize: 11,
color: p.text3,
paddingTop: 2,
width: 48,
fontFamily: MOBILE_FONT_MONO,
flexShrink: 0,
}}
>
{label}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 11,
color: p.text3,
fontFamily: MOBILE_FONT_MONO,
letterSpacing: "0.02em",
marginBottom: 2,
}}
>
<span
style={{
padding: "1px 5px",
borderRadius: 4,
background: isErr ? "#f5dad2" : "#dde9e1",
color: isErr ? "#a8341a" : p.greenInk,
fontSize: 9,
fontWeight: 700,
letterSpacing: "0.06em",
}}
>
{isErr ? "ERR" : "OK"}
</span>
<span>{it.activity_type}</span>
{it.duration_ms != null && <span>· {it.duration_ms}ms</span>}
</div>
{it.summary && (
<span
style={{
fontSize: 13.5,
color: p.text,
lineHeight: 1.45,
overflowWrap: "anywhere",
}}
>
{it.summary}
</span>
)}
</div>
</div>
);
})}
</div>
);
}
function DetailConfig({
a,
dark,
}: {
a: ReturnType<typeof toMobileAgent>;
dark: boolean;
}) {
const p = usePalette(dark);
const cfg = JSON.stringify(
{
tier: a.tier,
runtime: a.runtime,
skills: a.skills,
remote: a.remote,
},
null,
2,
);
return (
<pre
style={{
background: dark ? "#0f0e0a" : "#fff",
borderRadius: 16,
padding: "14px 16px",
border: `0.5px solid ${p.border}`,
fontFamily: MOBILE_FONT_MONO,
fontSize: 11.5,
lineHeight: 1.55,
color: p.text2,
margin: 0,
overflow: "auto",
whiteSpace: "pre-wrap",
}}
>
{cfg}
</pre>
);
}
function DetailMemory({ dark }: { dark: boolean }) {
const p = usePalette(dark);
return (
<div
style={{
background: p.surface,
borderRadius: 16,
padding: "14px 16px",
border: `0.5px solid ${p.border}`,
fontSize: 13,
color: p.text2,
lineHeight: 1.5,
}}
>
<span style={{ color: p.text }}>Ephemeral session.</span> Memory clears on workspace
restart. Open the desktop canvas for the full memory inspector.
</div>
);
}

View File

@ -0,0 +1,208 @@
"use client";
// 01 · Workspace home — agent list + filter chips + FAB.
// Mirrors design/screen-home.jsx, swapped to live store data.
import { useMemo, useState } from "react";
import { useCanvasStore } from "@/store/canvas";
import {
type AgentFilter,
AgentCard,
FilterChips,
WorkspacePill,
classifyForFilter,
toMobileAgent,
} from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
import { Icons, SectionLabel } from "./primitives";
export function MobileHome({
dark,
density,
onOpen,
onSpawn,
workspaceLabel = "Default",
username,
}: {
dark: boolean;
density: "compact" | "regular";
onOpen: (agentId: string) => void;
onSpawn: () => void;
workspaceLabel?: string;
username?: string;
}) {
const p = usePalette(dark);
const nodes = useCanvasStore((s) => s.nodes);
const agents = useMemo(() => nodes.map(toMobileAgent), [nodes]);
const [filter, setFilter] = useState<AgentFilter>("all");
const counts = useMemo(() => {
const c = { all: agents.length, online: 0, issue: 0, paused: 0 };
for (const a of agents) {
const bucket = classifyForFilter(a.status);
if (bucket !== "all") c[bucket]++;
}
return c;
}, [agents]);
const filtered = useMemo(
() => agents.filter((a) => filter === "all" || classifyForFilter(a.status) === filter),
[agents, filter],
);
const compact = density === "compact";
const rootCount = useMemo(
() => agents.filter((a) => !a.parentId).length,
[agents],
);
return (
<div
style={{
height: "100%",
overflow: "auto",
background: p.bg,
paddingBottom: 96,
fontFamily: MOBILE_FONT_SANS,
}}
>
{/* Sticky header */}
<div
style={{
position: "sticky",
top: 0,
zIndex: 10,
background: `linear-gradient(${p.bg} 60%, ${p.bg}00)`,
padding: "max(env(safe-area-inset-top), 44px) 16px 8px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 14,
}}
>
<WorkspacePill dark={dark} count={agents.length} />
{/* Search button reserved — wire to a mobile SearchDialog in v1.1. */}
</div>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: 4,
}}
>
<h1
style={{
margin: 0,
fontSize: 32,
fontWeight: 700,
color: p.text,
letterSpacing: "-0.025em",
}}
>
Agents
</h1>
{username && (
<span
style={{
fontFamily: MOBILE_FONT_MONO,
fontSize: 11,
color: p.text3,
letterSpacing: "0.04em",
}}
>
{username}
</span>
)}
</div>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: p.text2 }}>
{rootCount} workspace{rootCount === 1 ? "" : "s"} · live
</p>
</div>
<FilterChips value={filter} onChange={setFilter} dark={dark} counts={counts} />
<SectionLabel
dark={dark}
right={
<span
style={{
color: p.text3,
fontSize: 10.5,
letterSpacing: "0.04em",
textTransform: "none",
}}
>
{filtered.length}/{agents.length}
</span>
}
>
Workspace · {workspaceLabel}
</SectionLabel>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
padding: "0 14px",
}}
>
{filtered.length === 0 ? (
<div
style={{
padding: "40px 8px",
textAlign: "center",
color: p.text3,
fontSize: 13,
}}
>
No agents match this filter.
</div>
) : (
filtered.map((a) => (
<AgentCard
key={a.id}
agent={a}
dark={dark}
compact={compact}
onClick={() => onOpen(a.id)}
/>
))
)}
</div>
{/* Spawn FAB */}
<button
type="button"
onClick={onSpawn}
aria-label="Spawn new agent"
style={{
position: "absolute",
right: 24,
bottom: 100,
zIndex: 25,
width: 54,
height: 54,
borderRadius: 999,
border: "none",
cursor: "pointer",
background: p.text,
color: dark ? p.bg : "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 8px 24px rgba(40,30,20,0.25), 0 2px 6px rgba(40,30,20,0.15)",
}}
>
{Icons.plus({ size: 22 })}
</button>
</div>
);
}

View File

@ -0,0 +1,194 @@
"use client";
// "Me" tab — the prototype design didn't ship a Me screen, so this is
// the natural mobile home for theme + accent + density preferences
// (the prototype's floating Tweaks panel collapses into this tab here).
import { useTheme, type ThemePreference } from "@/lib/theme-provider";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } from "./palette";
import { SectionLabel } from "./primitives";
const ACCENTS = ["#2f9e6a", "#3b6fe0", "#7a4dd1", "#d97757", "#1f8a8a"] as const;
export function MobileMe({
dark,
accent,
setAccent,
density,
setDensity,
}: {
dark: boolean;
accent: string;
setAccent: (v: string) => void;
density: "compact" | "regular";
setDensity: (v: "compact" | "regular") => void;
}) {
const p = usePalette(dark);
const { theme, setTheme } = useTheme();
return (
<div
style={{
height: "100%",
overflow: "auto",
background: p.bg,
paddingBottom: 96,
fontFamily: MOBILE_FONT_SANS,
}}
>
<div style={{ padding: "max(env(safe-area-inset-top), 44px) 20px 8px" }}>
<h1
style={{
margin: 0,
fontSize: 32,
fontWeight: 700,
color: p.text,
letterSpacing: "-0.025em",
}}
>
Me
</h1>
<p style={{ margin: "4px 0 0", fontSize: 13.5, color: p.text2 }}>
Theme, accent, and layout density.
</p>
</div>
<SectionLabel dark={dark}>Theme</SectionLabel>
<div style={{ padding: "0 14px" }}>
<Card palette={p}>
<SegmentedRow
options={[
{ id: "system", label: "System" },
{ id: "light", label: "Light" },
{ id: "dark", label: "Dark" },
]}
value={theme}
onChange={(v) => setTheme(v as ThemePreference)}
palette={p}
dark={dark}
/>
</Card>
</div>
<SectionLabel dark={dark}>Accent</SectionLabel>
<div style={{ padding: "0 14px" }}>
<Card palette={p}>
<div style={{ display: "flex", gap: 12, padding: "12px 4px", flexWrap: "wrap" }}>
{ACCENTS.map((c) => {
const on = c === accent;
return (
<button
key={c}
type="button"
onClick={() => setAccent(c)}
aria-label={`Set accent ${c}`}
style={{
width: 36,
height: 36,
borderRadius: 999,
cursor: "pointer",
background: c,
border: on ? `2px solid ${p.text}` : "2px solid transparent",
boxShadow: on ? `0 0 0 2px ${p.bg} inset` : "none",
}}
/>
);
})}
</div>
</Card>
</div>
<SectionLabel dark={dark}>Density</SectionLabel>
<div style={{ padding: "0 14px" }}>
<Card palette={p}>
<SegmentedRow
options={[
{ id: "regular", label: "Regular" },
{ id: "compact", label: "Compact" },
]}
value={density}
onChange={(v) => setDensity(v as "regular" | "compact")}
palette={p}
dark={dark}
/>
</Card>
</div>
<div
style={{
padding: "24px 20px",
fontFamily: MOBILE_FONT_MONO,
fontSize: 11,
color: p.text3,
letterSpacing: "0.04em",
}}
>
Mobile design preview · v0.1
</div>
</div>
);
}
function Card({
palette,
children,
}: {
palette: MobilePalette;
children: React.ReactNode;
}) {
return (
<div
style={{
background: palette.surface,
borderRadius: 16,
border: `0.5px solid ${palette.border}`,
padding: "4px 14px",
}}
>
{children}
</div>
);
}
function SegmentedRow({
options,
value,
onChange,
palette,
dark,
}: {
options: { id: string; label: string }[];
value: string;
onChange: (v: string) => void;
palette: MobilePalette;
dark: boolean;
}) {
return (
<div style={{ display: "flex", gap: 6, padding: "10px 0" }}>
{options.map((o) => {
const on = o.id === value;
return (
<button
key={o.id}
type="button"
onClick={() => onChange(o.id)}
style={{
flex: 1,
padding: "10px 8px",
borderRadius: 10,
cursor: "pointer",
background: on ? palette.text : "transparent",
color: on ? (dark ? palette.bg : "#fff") : palette.text,
border: `1px solid ${on ? "transparent" : palette.border}`,
fontSize: 13,
fontWeight: 600,
}}
>
{o.label}
</button>
);
})}
</div>
);
}

View File

@ -0,0 +1,429 @@
"use client";
// 06 · Spawn agent — bottom-sheet flow.
// Fetches /templates so the user picks from what's actually installed
// on this platform (no hardcoded ID guesswork). Posts to /workspaces
// with the same shape useTemplateDeploy uses. Skips the secret-key
// preflight — if a deploy needs missing keys, the API surfaces the
// error and we show it with a hint to fall through to the desktop
// dialog (which has the full preflight + key-import flow).
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { type Template } from "@/lib/deploy-preflight";
import { tierCode } from "./palette";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } from "./palette";
import { Icons, SectionLabel, TierChip } from "./primitives";
const TIER_LABEL: Record<"T1" | "T2" | "T3" | "T4", string> = {
T1: "Sandboxed",
T2: "Standard",
T3: "Privileged",
T4: "Full Access",
};
export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => void }) {
const p = usePalette(dark);
const [templates, setTemplates] = useState<Template[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(true);
const [tplId, setTplId] = useState<string | null>(null);
const [tier, setTier] = useState<"T1" | "T2" | "T3" | "T4">("T2");
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api
.get<Template[]>("/templates")
.then((list) => {
if (cancelled) return;
setTemplates(list);
if (list.length > 0) {
setTplId(list[0].id);
setTier(tierCode(list[0].tier));
}
})
.catch(() => {
if (!cancelled) setTemplates([]);
})
.finally(() => {
if (!cancelled) setLoadingTemplates(false);
});
return () => {
cancelled = true;
};
}, []);
const handleSpawn = async () => {
if (busy || !tplId) return;
const chosen = templates.find((t) => t.id === tplId);
if (!chosen) return;
setError(null);
setBusy(true);
try {
await api.post<{ id: string }>("/workspaces", {
name: (name.trim() || chosen.name),
template: chosen.id,
tier: Number(tier.slice(1)),
canvas: {
x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100,
},
});
onClose();
} catch (e) {
setError(
e instanceof Error
? `${e.message}. If this template needs missing API keys, use the desktop palette to import them.`
: "Spawn failed",
);
} finally {
setBusy(false);
}
};
return (
<div
role="dialog"
aria-modal="true"
aria-label="Spawn agent"
style={{
position: "absolute",
inset: 0,
zIndex: 100,
background: "rgba(20,15,10,0.42)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "flex-end",
fontFamily: MOBILE_FONT_SANS,
}}
onClick={(e) => {
// Click on the dim backdrop closes the sheet.
if (e.target === e.currentTarget) onClose();
}}
>
<div
style={{
width: "100%",
background: p.bg,
borderRadius: "24px 24px 0 0",
maxHeight: "88%",
overflow: "auto",
boxShadow: "0 -10px 40px rgba(0,0,0,0.18)",
}}
>
<Grabber palette={p} />
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "6px 18px 10px",
}}
>
<div>
<h2
style={{
margin: 0,
fontSize: 22,
fontWeight: 700,
color: p.text,
letterSpacing: "-0.02em",
}}
>
Spawn Agent
</h2>
<p style={{ margin: "2px 0 0", fontSize: 12.5, color: p.text2 }}>
In workspace · Default
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
width: 32,
height: 32,
borderRadius: 999,
cursor: "pointer",
background: dark ? "#22211c" : "#fff",
border: `0.5px solid ${p.border}`,
color: p.text2,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{Icons.close({ size: 16 })}
</button>
</div>
{/* Templates */}
<SectionLabel dark={dark}>Template</SectionLabel>
<div style={{ padding: "0 14px" }}>
{loadingTemplates ? (
<div
style={{
padding: "24px 8px",
textAlign: "center",
color: p.text3,
fontSize: 13,
}}
>
Loading templates
</div>
) : templates.length === 0 ? (
<div
style={{
padding: "16px 14px",
background: p.surface,
borderRadius: 14,
border: `0.5px solid ${p.border}`,
color: p.text2,
fontSize: 13,
lineHeight: 1.45,
}}
>
No templates installed on this platform yet. Open the desktop canvas
and use the template palette to import one (Claude Code, Hermes, or
an org template), then come back here to spawn.
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
}}
>
{templates.map((t) => {
const on = tplId === t.id;
const tCode = tierCode(t.tier);
return (
<button
key={t.id}
type="button"
onClick={() => {
setTplId(t.id);
setTier(tCode);
}}
style={{
background: on
? dark
? "#2a2823"
: "#fff"
: dark
? "#1d1c17"
: "#fbf9f4",
border: `1px solid ${on ? p.accent : p.border}`,
borderRadius: 14,
padding: "12px 12px",
textAlign: "left",
cursor: "pointer",
display: "flex",
flexDirection: "column",
gap: 4,
position: "relative",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 6,
}}
>
<span
style={{
fontSize: 13.5,
fontWeight: 600,
color: p.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{t.name}
</span>
<TierChip tier={tCode} dark={dark} />
</div>
{t.description && (
<span
style={{
fontSize: 11.5,
color: p.text2,
lineHeight: 1.35,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{t.description}
</span>
)}
{on && (
<span
style={{
position: "absolute",
top: 8,
right: 8,
width: 16,
height: 16,
borderRadius: 999,
background: p.accent,
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{Icons.check({ size: 10, sw: 2.5 })}
</span>
)}
</button>
);
})}
</div>
)}
</div>
{/* Name */}
<SectionLabel dark={dark}>Name</SectionLabel>
<div style={{ padding: "0 14px" }}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={tplId
? (templates.find((t) => t.id === tplId)?.name ?? "agent-name")
: "agent-name"}
style={{
width: "100%",
padding: "12px 14px",
background: dark ? "#22211c" : "#fff",
border: `0.5px solid ${p.border}`,
borderRadius: 12,
fontFamily: MOBILE_FONT_MONO,
fontSize: 13.5,
color: p.text,
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* Tier */}
<SectionLabel dark={dark}>Permission tier</SectionLabel>
<div style={{ padding: "0 14px", display: "flex", gap: 6 }}>
{(["T1", "T2", "T3", "T4"] as const).map((t) => {
const on = tier === t;
return (
<button
key={t}
type="button"
onClick={() => setTier(t)}
style={{
flex: 1,
padding: "10px 8px",
cursor: "pointer",
background: on ? (dark ? "#22211c" : "#fff") : "transparent",
border: `1px solid ${on ? p.accent : p.border}`,
borderRadius: 12,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
}}
>
<TierChip tier={t} dark={dark} size="lg" />
<span style={{ fontSize: 10.5, color: p.text2, fontWeight: 500 }}>
{TIER_LABEL[t]}
</span>
</button>
);
})}
</div>
{/* Error */}
{error && (
<div
role="alert"
style={{
margin: "12px 14px 0",
padding: "10px 14px",
background: `${p.failed}1a`,
border: `0.5px solid ${p.failed}40`,
borderRadius: 12,
color: p.failed,
fontSize: 12.5,
lineHeight: 1.4,
}}
>
{error}
</div>
)}
{/* Spawn button */}
<div style={{ padding: "20px 14px max(env(safe-area-inset-bottom), 28px)" }}>
<button
type="button"
onClick={handleSpawn}
disabled={busy || !tplId || templates.length === 0}
style={{
width: "100%",
height: 52,
borderRadius: 16,
border: "none",
cursor: busy ? "wait" : tplId ? "pointer" : "not-allowed",
background: p.text,
color: dark ? p.bg : "#fff",
fontSize: 15,
fontWeight: 600,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
boxShadow: "0 8px 22px rgba(40,30,20,0.22)",
opacity: busy || !tplId ? 0.55 : 1,
}}
>
{Icons.zap({ size: 16 })} {busy ? "Spawning…" : "Spawn agent"}
</button>
<p
style={{
margin: "10px 0 0",
textAlign: "center",
fontSize: 11.5,
color: p.text3,
lineHeight: 1.4,
}}
>
Boots in ~3s. Tier {tier} permissions apply on first call.
</p>
</div>
</div>
</div>
);
}
function Grabber({ palette }: { palette: MobilePalette }) {
return (
<div style={{ display: "flex", justifyContent: "center", padding: "8px 0 4px" }}>
<span
style={{
width: 38,
height: 4,
borderRadius: 999,
background: palette.text3,
opacity: 0.4,
}}
/>
</div>
);
}

View File

@ -0,0 +1,211 @@
// @vitest-environment jsdom
/**
* MobileApp route-state contract.
*
* The mobile shell uses local React state (not URL routing) for
* navigation between the 6 screens. This test pins the back-stack
* shape so a future refactor can't silently regress:
*
* home (open agent) detail
* detail (open chat) chat chat (back) detail
* detail (back) home
*
* home / canvas / comms / me reachable via the bottom tab bar.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
beforeEach(() => {
// URL state persists across tests in jsdom — reset to a clean slate
// so each test starts on the home route regardless of what the
// previous test pushed onto the history stack.
window.history.replaceState(null, "", "/");
});
afterEach(() => {
cleanup();
});
// Mock the theme provider — MobileApp reads resolvedTheme to pick a
// palette; for routing we don't care which one, light is fine.
vi.mock("@/lib/theme-provider", () => ({
useTheme: () => ({ theme: "light", resolvedTheme: "light", setTheme: vi.fn() }),
}));
// Stub each screen to a sentinel that exposes the props MobileApp passes
// in. The whole point is to verify the routing handoff, not the screens
// themselves — those have their own tests.
vi.mock("../MobileHome", () => ({
MobileHome: ({ onOpen, onSpawn }: { onOpen: (id: string) => void; onSpawn: () => void }) => (
<div>
<span data-testid="screen">home</span>
<button onClick={() => onOpen("ws-42")}>open-ws-42</button>
<button onClick={onSpawn}>open-spawn</button>
</div>
),
}));
vi.mock("../MobileCanvas", () => ({
MobileCanvas: () => <span data-testid="screen">canvas</span>,
}));
vi.mock("../MobileDetail", () => ({
MobileDetail: ({
agentId,
onBack,
onChat,
}: {
agentId: string;
onBack: () => void;
onChat: () => void;
}) => (
<div>
<span data-testid="screen">detail:{agentId}</span>
<button onClick={onBack}>detail-back</button>
<button onClick={onChat}>detail-open-chat</button>
</div>
),
}));
vi.mock("../MobileChat", () => ({
MobileChat: ({ agentId, onBack }: { agentId: string; onBack: () => void }) => (
<div>
<span data-testid="screen">chat:{agentId}</span>
<button onClick={onBack}>chat-back</button>
</div>
),
}));
vi.mock("../MobileComms", () => ({
MobileComms: () => <span data-testid="screen">comms</span>,
}));
vi.mock("../MobileMe", () => ({
MobileMe: () => <span data-testid="screen">me</span>,
}));
vi.mock("../MobileSpawn", () => ({
MobileSpawn: ({ onClose }: { onClose: () => void }) => (
<div>
<span data-testid="spawn-sheet">spawn</span>
<button onClick={onClose}>spawn-close</button>
</div>
),
}));
// MobileApp's shared TabBar is the user's gateway to the Canvas / Comms /
// Me screens. Rather than depend on its visual icon set we expose a
// label-based stub so the test can call onChange directly.
vi.mock("../components", async () => {
const actual = await vi.importActual<typeof import("../components")>("../components");
type TabId = "agents" | "canvas" | "comms" | "me";
return {
...actual,
TabBar: ({ onChange }: { active: TabId; onChange: (id: TabId) => void }) => (
<div data-testid="tab-bar">
{(["agents", "canvas", "comms", "me"] as const).map((id) => (
<button key={id} onClick={() => onChange(id)}>
tab-{id}
</button>
))}
</div>
),
};
});
import { MobileApp } from "../MobileApp";
const visibleScreen = () =>
Array.from(document.querySelectorAll('[data-testid="screen"]'))
.map((el) => el.textContent ?? "")
.filter(Boolean);
describe("MobileApp — route state", () => {
it("starts on the home screen", () => {
render(<MobileApp />);
expect(visibleScreen()).toEqual(["home"]);
});
it("home → open agent → detail (passes agentId through)", () => {
render(<MobileApp />);
fireEvent.click(screen.getByText("open-ws-42"));
expect(visibleScreen()).toEqual(["detail:ws-42"]);
});
it("detail → open chat → chat (carries the same agentId)", () => {
render(<MobileApp />);
fireEvent.click(screen.getByText("open-ws-42"));
fireEvent.click(screen.getByText("detail-open-chat"));
expect(visibleScreen()).toEqual(["chat:ws-42"]);
});
it("chat back returns to detail (NOT to home — preserves the back-stack)", () => {
render(<MobileApp />);
fireEvent.click(screen.getByText("open-ws-42"));
fireEvent.click(screen.getByText("detail-open-chat"));
fireEvent.click(screen.getByText("chat-back"));
expect(visibleScreen()).toEqual(["detail:ws-42"]);
});
it("detail back returns to home", () => {
render(<MobileApp />);
fireEvent.click(screen.getByText("open-ws-42"));
fireEvent.click(screen.getByText("detail-back"));
expect(visibleScreen()).toEqual(["home"]);
});
it("hides the tab bar on chat (per design — composer reclaims that space)", () => {
render(<MobileApp />);
expect(screen.queryByTestId("tab-bar")).not.toBeNull();
fireEvent.click(screen.getByText("open-ws-42"));
expect(screen.queryByTestId("tab-bar")).not.toBeNull(); // detail
fireEvent.click(screen.getByText("detail-open-chat"));
expect(screen.queryByTestId("tab-bar")).toBeNull(); // chat
});
it("tab bar switches the four primary screens (Agents / Canvas / Comms / Me)", () => {
render(<MobileApp />);
fireEvent.click(screen.getByText("tab-canvas"));
expect(visibleScreen()).toEqual(["canvas"]);
fireEvent.click(screen.getByText("tab-comms"));
expect(visibleScreen()).toEqual(["comms"]);
fireEvent.click(screen.getByText("tab-me"));
expect(visibleScreen()).toEqual(["me"]);
fireEvent.click(screen.getByText("tab-agents"));
expect(visibleScreen()).toEqual(["home"]);
});
it("spawn sheet overlays from anywhere, closes on dismiss", () => {
render(<MobileApp />);
expect(screen.queryByTestId("spawn-sheet")).toBeNull();
fireEvent.click(screen.getByText("open-spawn"));
expect(screen.queryByTestId("spawn-sheet")).not.toBeNull();
fireEvent.click(screen.getByText("spawn-close"));
expect(screen.queryByTestId("spawn-sheet")).toBeNull();
});
it("seeds initial route from ?m= and ?a= so deep links open the right screen", () => {
window.history.replaceState(null, "", "/?m=detail&a=ws-99");
render(<MobileApp />);
expect(visibleScreen()).toEqual(["detail:ws-99"]);
});
it("collapses ?m=detail without ?a to home (detail without an agent is meaningless)", () => {
window.history.replaceState(null, "", "/?m=detail");
render(<MobileApp />);
expect(visibleScreen()).toEqual(["home"]);
});
it("syncs in-app navigation to the URL so browser back leaves the mobile stack", () => {
render(<MobileApp />);
expect(window.location.search).toBe("");
fireEvent.click(screen.getByText("open-ws-42"));
expect(window.location.search).toBe("?m=detail&a=ws-42");
fireEvent.click(screen.getByText("detail-open-chat"));
expect(window.location.search).toBe("?m=chat&a=ws-42");
});
it("popstate (back button) restores the previous route", () => {
render(<MobileApp />);
fireEvent.click(screen.getByText("open-ws-42"));
fireEvent.click(screen.getByText("detail-open-chat"));
// Simulate browser back: rewind URL ourselves, then dispatch popstate.
window.history.replaceState(null, "", "/?m=detail&a=ws-42");
fireEvent.popState(window);
expect(visibleScreen()).toEqual(["detail:ws-42"]);
});
});

View File

@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import type { Node } from "@xyflow/react";
import { type WorkspaceNodeData } from "@/store/canvas";
import { classifyForFilter, toMobileAgent } from "../components";
const baseData: WorkspaceNodeData = {
name: "test-agent",
status: "online",
tier: 2,
agentCard: null,
activeTasks: 0,
collapsed: false,
role: "",
lastErrorRate: 0,
lastSampleError: "",
url: "",
parentId: null,
currentTask: "",
runtime: "claude-code",
needsRestart: false,
budgetLimit: null,
};
const makeNode = (overrides: Partial<WorkspaceNodeData> = {}, id = "ws-1"): Node<WorkspaceNodeData> => ({
id,
type: "workspaceNode",
position: { x: 0, y: 0 },
data: { ...baseData, ...overrides },
});
describe("toMobileAgent", () => {
it("maps name, status, tier, runtime through the design's 6-key palette", () => {
const a = toMobileAgent(makeNode({ status: "online", tier: 3, runtime: "hermes" }));
expect(a.name).toBe("test-agent");
expect(a.status).toBe("online");
expect(a.tier).toBe("T3");
expect(a.runtime).toBe("hermes");
expect(a.tag).toBe("hermes"); // tag mirrors runtime in v1
});
it("flags 'external' runtime as remote (drives the ★ REMOTE badge)", () => {
expect(toMobileAgent(makeNode({ runtime: "external" })).remote).toBe(true);
expect(toMobileAgent(makeNode({ runtime: "claude-code" })).remote).toBe(false);
});
it("falls back to 'unknown' runtime when both workspace + agentCard are blank", () => {
const a = toMobileAgent(makeNode({ runtime: "" }));
expect(a.runtime).toBe("unknown");
expect(a.tag).toBe("unknown");
});
it("uses workspace id as fallback name when name is missing", () => {
const a = toMobileAgent(makeNode({ name: "" }, "ws-fallback"));
expect(a.name).toBe("ws-fallback");
});
it("preserves the parent link so MobileCanvas can draw parent→child edges", () => {
const a = toMobileAgent(makeNode({ parentId: "ws-parent" }, "ws-child"));
expect(a.parentId).toBe("ws-parent");
});
it("maps platform 'provisioning' to design 'starting'", () => {
expect(toMobileAgent(makeNode({ status: "provisioning" })).status).toBe("starting");
});
it("counts skills from agentCard.skills array", () => {
const a = toMobileAgent(
makeNode({
agentCard: {
skills: [{ name: "skill-a" }, { name: "skill-b" }, { name: "skill-c" }],
},
}),
);
expect(a.skills).toBe(3);
});
it("reports 0 skills when agentCard is null", () => {
expect(toMobileAgent(makeNode({ agentCard: null })).skills).toBe(0);
});
});
describe("classifyForFilter", () => {
it("buckets online statuses to the Online filter", () => {
expect(classifyForFilter("online")).toBe("online");
});
it("buckets failure-state statuses to the Issues filter", () => {
// Issues = anything the user needs to look at NOW.
expect(classifyForFilter("failed")).toBe("issue");
expect(classifyForFilter("degraded")).toBe("issue");
});
it("buckets non-online non-failure statuses to the Paused filter", () => {
// Catch-all for transient or intentional offline states.
expect(classifyForFilter("paused")).toBe("paused");
expect(classifyForFilter("offline")).toBe("paused");
expect(classifyForFilter("starting")).toBe("paused");
});
});

View File

@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { MOL_DARK, MOL_LIGHT, getPalette, normalizeStatus, tierCode } from "../palette";
describe("normalizeStatus", () => {
it("passes design-known statuses through verbatim", () => {
expect(normalizeStatus("online")).toBe("online");
expect(normalizeStatus("degraded")).toBe("degraded");
expect(normalizeStatus("failed")).toBe("failed");
expect(normalizeStatus("paused")).toBe("paused");
expect(normalizeStatus("offline")).toBe("offline");
});
it("maps platform 'provisioning' to design 'starting'", () => {
// The platform's 14-state machine collapses to the design's 6 keys.
// 'provisioning' (post-spawn boot) is the same UX bucket as 'starting'.
expect(normalizeStatus("provisioning")).toBe("starting");
expect(normalizeStatus("starting")).toBe("starting");
});
it("maps unknown / null / empty to offline", () => {
expect(normalizeStatus(undefined)).toBe("offline");
expect(normalizeStatus(null)).toBe("offline");
expect(normalizeStatus("")).toBe("offline");
expect(normalizeStatus("garbage-status")).toBe("offline");
});
});
describe("tierCode", () => {
it("maps numeric tiers to T-codes", () => {
expect(tierCode(1)).toBe("T1");
expect(tierCode(2)).toBe("T2");
expect(tierCode(3)).toBe("T3");
expect(tierCode(4)).toBe("T4");
});
it("clamps below-1 to T1 (never below sandboxed)", () => {
expect(tierCode(0)).toBe("T1");
expect(tierCode(-5)).toBe("T1");
});
it("clamps above-4 to T4 (never above full-access)", () => {
expect(tierCode(5)).toBe("T4");
expect(tierCode(99)).toBe("T4");
});
it("falls back to T2 (Standard) on null/undefined", () => {
// T2 is the platform default for fresh agents — matches the
// CreateWorkspaceDialog default. Keeps the mobile spawn UX
// consistent with the desktop when tier metadata is missing.
expect(tierCode(undefined)).toBe("T2");
expect(tierCode(null)).toBe("T2");
});
});
describe("getPalette", () => {
it("returns the light palette when dark is false", () => {
expect(getPalette(false)).toBe(MOL_LIGHT);
});
it("returns the dark palette when dark is true", () => {
expect(getPalette(true)).toBe(MOL_DARK);
});
it("light + dark palettes have the same key set (no drift)", () => {
expect(Object.keys(MOL_LIGHT).sort()).toEqual(Object.keys(MOL_DARK).sort());
});
});

View File

@ -0,0 +1,444 @@
"use client";
// Screen-shared composites: TabBar, WorkspacePill, AgentCard, FilterChips.
// Mirrors molecules-ai-mobile-app/project/screens-shared.jsx but reads
// from the live canvas store rather than the prototype's mock AGENTS.
import type { Node } from "@xyflow/react";
import { type WorkspaceNodeData, summarizeWorkspaceCapabilities } from "@/store/canvas";
import {
MOBILE_FONT_MONO,
type MobilePalette,
type MobileStatus,
normalizeStatus,
tierCode,
usePalette,
} from "./palette";
import { Icons, StatusDot, TierChip } from "./primitives";
// Derived view-model the mobile screens consume. Built once per render
// from the store's Node<WorkspaceNodeData>.
export interface MobileAgent {
id: string;
name: string;
tag: string;
tier: "T1" | "T2" | "T3" | "T4";
status: MobileStatus;
remote: boolean;
runtime: string;
skills: number;
calls: number;
desc: string;
parentId: string | null;
}
export function toMobileAgent(node: Node<WorkspaceNodeData>): MobileAgent {
const cap = summarizeWorkspaceCapabilities(node.data);
const runtime = cap.runtime ?? "unknown";
const remote = runtime === "external";
return {
id: node.id,
name: node.data.name || node.id,
tag: runtime,
tier: tierCode(node.data.tier),
status: normalizeStatus(node.data.status),
remote,
runtime,
skills: cap.skillCount,
calls: typeof node.data.activeTasks === "number" ? node.data.activeTasks : 0,
desc: node.data.role || cap.currentTask || "",
parentId: node.data.parentId ?? null,
};
}
// ── Tab bar ────────────────────────────────────────────────────
export type MobileTabId = "agents" | "canvas" | "comms" | "me";
export function TabBar({
active,
onChange,
dark,
}: {
active: MobileTabId;
onChange: (id: MobileTabId) => void;
dark: boolean;
}) {
const p = usePalette(dark);
const tabs: { id: MobileTabId; label: string; icon: keyof typeof Icons }[] = [
{ id: "agents", label: "Agents", icon: "list" },
{ id: "canvas", label: "Canvas", icon: "graph" },
{ id: "comms", label: "Comms", icon: "pulse" },
{ id: "me", label: "Me", icon: "user" },
];
return (
<div
style={{
position: "absolute",
left: 14,
right: 14,
bottom: 16,
height: 64,
borderRadius: 26,
zIndex: 30,
background: dark ? "rgba(34,33,28,0.78)" : "rgba(255,253,247,0.82)",
backdropFilter: "blur(24px) saturate(160%)",
WebkitBackdropFilter: "blur(24px) saturate(160%)",
border: `0.5px solid ${p.border}`,
boxShadow: dark
? "0 8px 28px rgba(0,0,0,0.4), inset 0 0.5px 0 rgba(255,255,255,0.05)"
: "0 6px 20px rgba(40,30,20,0.07), 0 1px 0 rgba(255,255,255,0.6) inset",
display: "flex",
alignItems: "center",
justifyContent: "space-around",
padding: "0 10px",
}}
>
{tabs.map((t) => {
const on = active === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => onChange(t.id)}
style={{
background: "none",
border: "none",
cursor: "pointer",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 3,
padding: "6px 10px",
minWidth: 56,
color: on ? p.accent : p.text3,
}}
>
<span
style={{
width: 36,
height: 28,
borderRadius: 10,
background: on ? `${p.accent}1a` : "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{Icons[t.icon]({ size: 18 })}
</span>
<span
style={{
fontSize: 10,
letterSpacing: "0.02em",
fontWeight: on ? 600 : 500,
}}
>
{t.label}
</span>
</button>
);
})}
</div>
);
}
// ── Workspace pill (header) ────────────────────────────────────
export function WorkspacePill({
dark,
count,
live = true,
}: {
dark: boolean;
count: number | string;
live?: boolean;
}) {
const p = usePalette(dark);
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 0,
borderRadius: 999,
padding: 4,
background: dark ? "rgba(34,33,28,0.6)" : "rgba(255,255,255,0.7)",
border: `0.5px solid ${p.border}`,
backdropFilter: "blur(12px)",
}}
>
<span
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px 6px 8px",
borderRight: `0.5px solid ${p.divider}`,
}}
>
<span
style={{
width: 22,
height: 22,
borderRadius: 6,
background: `linear-gradient(135deg, ${p.accent}, ${p.greenInk})`,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: 11,
fontWeight: 700,
}}
>
M
</span>
<span style={{ fontSize: 13.5, fontWeight: 600, color: p.text }}>Molecule AI</span>
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 10px",
fontFamily: MOBILE_FONT_MONO,
fontSize: 11,
color: p.text2,
}}
>
<StatusDot status="online" size={6} dark={dark} />
<span>{count}</span>
</span>
{live && (
<span
style={{
display: "flex",
alignItems: "center",
gap: 5,
padding: "6px 10px 6px 8px",
fontSize: 11,
color: p.greenInk,
fontWeight: 600,
fontFamily: MOBILE_FONT_MONO,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 999,
background: p.online,
boxShadow: `0 0 0 3px ${p.online}26`,
}}
/>
LIVE
</span>
)}
</div>
);
}
// ── Agent row card ─────────────────────────────────────────────
export function AgentCard({
agent,
dark,
onClick,
compact = false,
}: {
agent: MobileAgent;
dark: boolean;
onClick?: () => void;
compact?: boolean;
}) {
const p = usePalette(dark);
const isOnline = agent.status === "online";
const isT4Soft = agent.tier === "T4" && isOnline;
return (
<button
type="button"
onClick={onClick}
style={{
display: "block",
width: "100%",
textAlign: "left",
cursor: "pointer",
background: isT4Soft ? p.t4SoftCard : isOnline ? p.greenSoft : p.surface,
border: `0.5px solid ${p.border}`,
borderRadius: 18,
padding: compact ? "12px 14px" : "14px 16px",
boxShadow: dark
? "none"
: "0 1px 0 rgba(255,255,255,0.5) inset, 0 1px 2px rgba(40,30,20,0.03)",
transition: "transform .12s",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<StatusDot status={agent.status} size={9} dark={dark} />
<span
style={{
flex: 1,
fontSize: 16,
fontWeight: 600,
color: p.text,
letterSpacing: "-0.01em",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{agent.name}
</span>
<TierChip tier={agent.tier} dark={dark} />
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 8,
flexWrap: "wrap",
}}
>
{agent.remote && <RemoteBadge palette={p} />}
<span
style={{
fontSize: 10.5,
color: p.text3,
fontFamily: MOBILE_FONT_MONO,
letterSpacing: "0.02em",
}}
>
{agent.tag}
</span>
</div>
{!compact && agent.desc && (
<p
style={{
margin: "8px 0 0",
fontSize: 13,
lineHeight: 1.45,
color: p.text2,
}}
>
{agent.desc}
</p>
)}
{!compact && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 14,
marginTop: 10,
fontSize: 10.5,
color: p.text3,
fontFamily: MOBILE_FONT_MONO,
}}
>
<span>SKILLS {agent.skills}</span>
<span>CALLS {agent.calls}</span>
<span style={{ marginLeft: "auto" }}>{agent.runtime.toUpperCase()}</span>
</div>
)}
</button>
);
}
export function RemoteBadge({ palette }: { palette: MobilePalette }) {
return (
<span
style={{
padding: "2px 7px",
borderRadius: 4,
background: palette.remoteBg,
color: palette.remote,
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.04em",
fontFamily: MOBILE_FONT_MONO,
display: "inline-flex",
alignItems: "center",
gap: 3,
}}
>
REMOTE
</span>
);
}
// ── Filter chips ───────────────────────────────────────────────
export type AgentFilter = "all" | "online" | "issue" | "paused";
export function FilterChips({
value,
onChange,
dark,
counts,
}: {
value: AgentFilter;
onChange: (v: AgentFilter) => void;
dark: boolean;
counts: { all: number; online: number; issue: number; paused: number };
}) {
const p = usePalette(dark);
const opts: { id: AgentFilter; label: string; n: number }[] = [
{ id: "all", label: "All", n: counts.all },
{ id: "online", label: "Online", n: counts.online },
{ id: "issue", label: "Issues", n: counts.issue },
{ id: "paused", label: "Paused", n: counts.paused },
];
return (
<div
style={{
display: "flex",
gap: 6,
padding: "0 16px 10px",
overflowX: "auto",
scrollbarWidth: "none",
}}
>
{opts.map((o) => {
const on = value === o.id;
return (
<button
key={o.id}
type="button"
onClick={() => onChange(o.id)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "7px 12px",
borderRadius: 999,
cursor: "pointer",
background: on ? p.text : dark ? "#22211c" : "#fff",
color: on ? (dark ? p.bg : "#fff") : p.text,
border: `0.5px solid ${on ? "transparent" : p.border}`,
fontSize: 13,
fontWeight: 500,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{o.label}
<span
style={{
fontSize: 10.5,
opacity: 0.7,
fontFamily: MOBILE_FONT_MONO,
}}
>
{o.n}
</span>
</button>
);
})}
</div>
);
}
export function classifyForFilter(status: MobileStatus): AgentFilter {
if (status === "online") return "online";
if (status === "failed" || status === "degraded") return "issue";
return "paused"; // starting / paused / offline
}

View File

@ -0,0 +1,40 @@
"use client";
// React context for accent overrides + the React-side `usePalette` hook.
// Keeps the pure data (MOL_LIGHT/MOL_DARK) in palette.ts and the
// pure-function `getPalette` available for tests; this file is the
// React-only entry point so mobile components don't have to plumb
// accent through props.
import { createContext, useContext, type ReactNode } from "react";
import { MOL_DARK, MOL_LIGHT, type MobilePalette } from "./palette";
const MobileAccentContext = createContext<string | null>(null);
export function MobileAccentProvider({
accent,
children,
}: {
accent: string | null;
children: ReactNode;
}) {
return <MobileAccentContext.Provider value={accent}>{children}</MobileAccentContext.Provider>;
}
/**
* Hook variant of palette resolution. Reads the user's accent override
* from context and returns a fresh palette object with the override
* applied. Critically, it never mutates the static MOL_LIGHT/MOL_DARK
* singletons that was the foot-gun the prior version had.
*
* Outside of a `<MobileAccentProvider>`, the context default of `null`
* means we just return the static palette unchanged. That's the right
* behaviour for tests + for any non-mobile caller that imports a token.
*/
export function usePalette(dark: boolean): MobilePalette {
const accent = useContext(MobileAccentContext);
const base = dark ? MOL_DARK : MOL_LIGHT;
if (!accent || accent === base.accent) return base;
return { ...base, accent, online: accent };
}

View File

@ -0,0 +1,147 @@
// Mobile design system tokens — verbatim from the Claude Design handoff
// (molecules-ai-mobile-app/project/shared.jsx). Kept as an inline-style
// palette object so screens can mirror the design 1:1; theming routes
// through `usePalette(dark)` exactly like the prototype.
export interface MobilePalette {
bg: string;
surface: string;
surface2: string;
border: string;
divider: string;
text: string;
text2: string;
text3: string;
green: string;
greenSoft: string;
greenInk: string;
t1Bg: string; t1Ink: string; t1Br: string;
t2Bg: string; t2Ink: string; t2Br: string;
t3Bg: string; t3Ink: string; t3Br: string;
t4Bg: string; t4Ink: string; t4Br: string;
t4SoftCard: string;
online: string;
starting: string;
degraded: string;
failed: string;
paused: string;
offline: string;
remote: string;
remoteBg: string;
accent: string;
}
export const MOL_LIGHT: MobilePalette = {
bg: "#f6f4ef",
surface: "#ffffff",
surface2: "#fbf9f4",
border: "rgba(40,30,20,0.08)",
divider: "rgba(40,30,20,0.06)",
text: "#29261b",
text2: "rgba(41,38,27,0.62)",
text3: "rgba(41,38,27,0.42)",
green: "#2f9e6a",
greenSoft: "#d9ebe0",
greenInk: "#1f6a47",
t1Bg: "#dde6f1", t1Ink: "#3a6aa3", t1Br: "#b9c8de",
t2Bg: "#dbe5f4", t2Ink: "#2f5fb4", t2Br: "#b1c2e0",
t3Bg: "#e3dcef", t3Ink: "#6a4ba1", t3Br: "#c8b9e1",
t4Bg: "#f5dcc7", t4Ink: "#a8501d", t4Br: "#e8c6a4",
t4SoftCard: "#f9ece0",
online: "#2f9e6a",
starting: "#e9b53b",
degraded: "#d28a2a",
failed: "#c8472a",
paused: "#7a8696",
offline: "#9aa0a6",
remote: "#7a4dd1",
remoteBg: "#ede2ff",
accent: "#2f9e6a",
};
export const MOL_DARK: MobilePalette = {
bg: "#15140f",
surface: "#1d1c17",
surface2: "#22211c",
border: "rgba(255,250,240,0.08)",
divider: "rgba(255,250,240,0.06)",
text: "#f1eee5",
text2: "rgba(241,238,229,0.6)",
text3: "rgba(241,238,229,0.38)",
green: "#3eb37c",
greenSoft: "#1f3a2c",
greenInk: "#7fd3a8",
t1Bg: "#1a2230", t1Ink: "#7ea4d4", t1Br: "#2a3a52",
t2Bg: "#1b2434", t2Ink: "#86a6e2", t2Br: "#2c3c58",
t3Bg: "#251f33", t3Ink: "#b39be0", t3Br: "#3e3450",
t4Bg: "#332316", t4Ink: "#e5a878", t4Br: "#553622",
t4SoftCard: "#2a1f17",
online: "#3eb37c",
starting: "#e9b53b",
degraded: "#d28a2a",
failed: "#d65a3e",
paused: "#8a96a6",
offline: "#6a6a6a",
remote: "#a38aff",
remoteBg: "#2a1f44",
accent: "#3eb37c",
};
/**
* Pure-function variant of palette resolution. No React, no context,
* no mutation for tests and other non-component code.
*
* Components should import `usePalette` from `./palette-context` so the
* user's accent override (held in context, not in module state) flows
* through automatically. Re-exported below so the existing
* `import { usePalette } from "./palette"` call sites keep working.
*/
export const getPalette = (dark: boolean): MobilePalette => (dark ? MOL_DARK : MOL_LIGHT);
// Back-compat re-export. Once we're confident nothing imports
// `usePalette` from this file we can drop this line.
export { usePalette } from "./palette-context";
// References the CSS variables that next/font/google emits in
// app/layout.tsx. Falls through to system fonts if the variable is
// undefined (e.g. in unit tests with no <body> font class).
export const MOBILE_FONT_SANS = "var(--font-inter), 'Inter', ui-sans-serif, system-ui, sans-serif";
export const MOBILE_FONT_MONO = "var(--font-jetbrains), 'JetBrains Mono', ui-monospace, monospace";
// Status keys we surface in the mobile UI. Anything else from the
// platform falls back to "offline" tinting — the desktop has more
// statuses ("provisioning", etc.) than the design's 6-key palette.
export type MobileStatus =
| "online" | "starting" | "degraded" | "failed" | "paused" | "offline";
export function normalizeStatus(s: string | undefined | null): MobileStatus {
if (s === "online" || s === "degraded" || s === "failed" || s === "paused" || s === "offline") {
return s;
}
if (s === "provisioning" || s === "starting") return "starting";
return "offline";
}
// Platform tier (number 1-4) → design tier code "T1".."T4"
export function tierCode(tier: number | undefined | null): "T1" | "T2" | "T3" | "T4" {
const n = typeof tier === "number" ? tier : 2;
if (n <= 1) return "T1";
if (n === 2) return "T2";
if (n === 3) return "T3";
return "T4";
}

View File

@ -0,0 +1,278 @@
"use client";
// Mobile primitives — StatusDot, TierChip, Chip, Icons, SectionLabel.
// Ports shared.jsx 1:1 from the design handoff; React + TypeScript flavor.
import type { CSSProperties, ReactNode, SVGProps } from "react";
import {
MOBILE_FONT_MONO,
type MobilePalette,
type MobileStatus,
usePalette,
} from "./palette";
type TierCode = "T1" | "T2" | "T3" | "T4";
export function StatusDot({
status = "online",
size = 8,
dark = false,
halo = true,
}: {
status?: MobileStatus;
size?: number;
dark?: boolean;
halo?: boolean;
}) {
const p = usePalette(dark);
const c: string = (p as unknown as Record<string, string>)[status] ?? p.online;
return (
<span
style={{
display: "inline-block",
width: size,
height: size,
borderRadius: 999,
background: c,
flexShrink: 0,
boxShadow: halo ? `0 0 0 ${Math.max(2, size * 0.45)}px ${c}26` : "none",
}}
/>
);
}
export function TierChip({
tier = "T2",
dark = false,
size = "sm",
}: {
tier?: TierCode;
dark?: boolean;
size?: "sm" | "lg";
}) {
const p = usePalette(dark);
const map: Record<TierCode, { bg: string; ink: string; br: string }> = {
T1: { bg: p.t1Bg, ink: p.t1Ink, br: p.t1Br },
T2: { bg: p.t2Bg, ink: p.t2Ink, br: p.t2Br },
T3: { bg: p.t3Bg, ink: p.t3Ink, br: p.t3Br },
T4: { bg: p.t4Bg, ink: p.t4Ink, br: p.t4Br },
};
const { bg, ink, br } = map[tier];
const dim = size === "lg" ? { w: 32, h: 22, fs: 11 } : { w: 26, h: 19, fs: 10 };
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: dim.w,
height: dim.h,
borderRadius: 5,
background: bg,
color: ink,
border: `0.5px solid ${br}`,
fontFamily: MOBILE_FONT_MONO,
fontSize: dim.fs,
fontWeight: 600,
letterSpacing: "0.02em",
flexShrink: 0,
}}
>
{tier}
</span>
);
}
export function Chip({
label,
value,
accent,
dark = false,
soft = false,
}: {
label?: string;
value: ReactNode;
accent?: string;
dark?: boolean;
soft?: boolean;
}) {
const p = usePalette(dark);
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 9px",
borderRadius: 999,
background: soft
? `${accent ?? p.accent}1a`
: dark
? "#2a2823"
: "#f0ede5",
border: `0.5px solid ${dark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)"}`,
fontSize: 11,
fontFamily: MOBILE_FONT_MONO,
color: p.text2,
letterSpacing: "0.02em",
}}
>
{label && (
<span style={{ textTransform: "uppercase", fontSize: 9.5, opacity: 0.7 }}>{label}</span>
)}
<span style={{ color: accent ?? p.text, fontWeight: 600 }}>{value}</span>
</span>
);
}
// ── icons (stroke-based, 20×20 viewBox) ───────────────────────
type IcoOpts = { stroke?: string; size?: number; fill?: string; sw?: number };
const ico = (
paths: ReactNode,
{ stroke = "currentColor", size = 18, fill = "none", sw = 1.6 }: IcoOpts = {},
) => {
const props: SVGProps<SVGSVGElement> = {
width: size,
height: size,
viewBox: "0 0 20 20",
fill,
stroke,
strokeWidth: sw,
strokeLinecap: "round",
strokeLinejoin: "round",
};
return <svg {...props}>{paths}</svg>;
};
export const Icons = {
graph: (o?: IcoOpts) =>
ico(
<>
<circle cx="5" cy="5" r="2" />
<circle cx="15" cy="5" r="2" />
<circle cx="10" cy="15" r="2" />
<path d="M6.4 6.5l2.7 7M13.6 6.5l-2.7 7" />
</>,
o,
),
list: (o?: IcoOpts) =>
ico(
<>
<path d="M6 5h10M6 10h10M6 15h10" />
<circle cx="3.5" cy="5" r="0.6" fill="currentColor" />
<circle cx="3.5" cy="10" r="0.6" fill="currentColor" />
<circle cx="3.5" cy="15" r="0.6" fill="currentColor" />
</>,
o,
),
search: (o?: IcoOpts) =>
ico(
<>
<circle cx="9" cy="9" r="5" />
<path d="M13 13l4 4" />
</>,
o,
),
plus: (o?: IcoOpts) => ico(<path d="M10 4v12M4 10h12" />, o),
bell: (o?: IcoOpts) =>
ico(
<>
<path d="M5 8a5 5 0 0 1 10 0v4l1.5 2H3.5L5 12V8z" />
<path d="M8.5 16a1.5 1.5 0 0 0 3 0" />
</>,
o,
),
chat: (o?: IcoOpts) =>
ico(
<path d="M4 5h12a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 16 14h-3l-3 3v-3H4a1.5 1.5 0 0 1-1.5-1.5v-6A1.5 1.5 0 0 1 4 5z" />,
o,
),
send: (o?: IcoOpts) =>
ico(<path d="M3 10l14-6-5 14-3-6-6-2z" fill="currentColor" />, { ...o, sw: 1 }),
attach: (o?: IcoOpts) =>
ico(
<path d="M14 6.5L7.5 13a2.5 2.5 0 0 0 3.5 3.5l7-7a4 4 0 0 0-5.6-5.6L4.8 11A6 6 0 0 0 13.3 19.5" />,
o,
),
back: (o?: IcoOpts) => ico(<path d="M12.5 4l-6 6 6 6" />, o),
more: (o?: IcoOpts) =>
ico(
<>
<circle cx="5" cy="10" r="1.2" fill="currentColor" />
<circle cx="10" cy="10" r="1.2" fill="currentColor" />
<circle cx="15" cy="10" r="1.2" fill="currentColor" />
</>,
o,
),
filter: (o?: IcoOpts) => ico(<path d="M3 5h14M5 10h10M8 15h4" />, o),
user: (o?: IcoOpts) =>
ico(
<>
<circle cx="10" cy="7" r="3" />
<path d="M3.5 17a6.5 6.5 0 0 1 13 0" />
</>,
o,
),
settings: (o?: IcoOpts) =>
ico(
<>
<circle cx="10" cy="10" r="2.2" />
<path d="M10 2.5v2M10 15.5v2M2.5 10h2M15.5 10h2M4.7 4.7l1.4 1.4M13.9 13.9l1.4 1.4M4.7 15.3l1.4-1.4M13.9 6.1l1.4-1.4" />
</>,
o,
),
pulse: (o?: IcoOpts) => ico(<path d="M2 10h3l2-5 3 10 2-7 2 4 4-2" />, o),
close: (o?: IcoOpts) => ico(<path d="M5 5l10 10M15 5L5 15" />, o),
zap: (o?: IcoOpts) => ico(<path d="M11 2l-6 9h4l-1 7 6-9h-4l1-7z" />, o),
check: (o?: IcoOpts) => ico(<path d="M4 10l4 4 8-9" />, o),
swatch: (o?: IcoOpts) =>
ico(
<>
<rect x="3" y="3" width="6" height="6" rx="1" />
<rect x="11" y="3" width="6" height="6" rx="1" />
<rect x="3" y="11" width="6" height="6" rx="1" />
<circle cx="14" cy="14" r="3.2" />
</>,
o,
),
};
export function SectionLabel({
children,
dark = false,
right,
style,
}: {
children: ReactNode;
dark?: boolean;
right?: ReactNode;
style?: CSSProperties;
}) {
const p = usePalette(dark);
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "14px 20px 6px",
fontFamily: MOBILE_FONT_MONO,
fontSize: 10.5,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: p.text3,
fontWeight: 600,
...style,
}}
>
<span>{children}</span>
{right}
</div>
);
}
// Convenience: avoid repeating the (palette, dark) plumbing in screens
// that only need the palette object.
export function withPalette<T>(dark: boolean, fn: (p: MobilePalette) => T): T {
return fn(usePalette(dark));
}