diff --git a/app/agents/[id]/page.tsx b/app/agents/[id]/page.tsx index 19480bd..4b476ad 100644 --- a/app/agents/[id]/page.tsx +++ b/app/agents/[id]/page.tsx @@ -1,284 +1,740 @@ -"use client"; -import { useEffect, useState, use } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import type { Agent, CronJob } from "@/lib/types"; - -function timeAgo(dateStr: string | null): string { - if (!dateStr) return "never"; - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60000); - const hrs = Math.floor(diff / 3600000); - const days = Math.floor(diff / 86400000); - if (mins < 1) return "just now"; - if (mins < 60) return `${mins}m ago`; - if (hrs < 24) return `${hrs}h ago`; - return `${days}d ago`; -} - -const statusColors: Record = { - ok: { text: 'var(--green)', bg: 'rgba(48,209,88,0.1)' }, - error: { text: 'var(--red)', bg: 'rgba(255,69,58,0.1)' }, - idle: { text: 'var(--text-secondary)', bg: 'rgba(120,120,128,0.1)' }, -}; +"use client" +import { useEffect, useState, use, useCallback } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import type { Agent, CronJob } from "@/lib/types" +import { Skeleton } from "@/components/ui/skeleton" +import { ErrorState } from "@/components/ErrorState" const TOOL_ICONS: Record = { - web_search: "🔍", - read: "📁", - write: "✏️", - exec: "💻", - web_fetch: "🌐", - message: "🔔", - tts: "💬", -}; - -function SoulViewer({ content }: { content: string }) { - const lines = content.split("\n"); - return ( -
-
- {lines.map((_, i) => ( -
- {i + 1} -
- ))} -
-
-        {content}
-      
-
- ); + web_search: "\uD83D\uDD0D", + read: "\uD83D\uDCC1", + write: "\u270F\uFE0F", + exec: "\uD83D\uDCBB", + web_fetch: "\uD83C\uDF10", + message: "\uD83D\uDD14", + tts: "\uD83D\uDCAC", + edit: "\u2702\uFE0F", + sessions_spawn: "\uD83D\uDD04", + memory_search: "\uD83E\udDE0", } -export default function AgentDetailPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params); - const router = useRouter(); - const [agent, setAgent] = useState(null); - const [allAgents, setAllAgents] = useState([]); - const [crons, setCrons] = useState([]); - const [loading, setLoading] = useState(true); +function StatusDot({ status }: { status: CronJob["status"] }) { + return ( + + ) +} - useEffect(() => { - Promise.all([fetch("/api/agents").then((r) => r.json()), fetch("/api/crons").then((r) => r.json())]) - .then(([agents, c]) => { - setAllAgents(agents); - setAgent(agents.find((a: Agent) => a.id === id) || null); - setCrons(c.filter((cr: CronJob) => cr.agentId === id)); - }) - .finally(() => setLoading(false)); - }, [id]); +function SoulViewer({ content }: { content: string }) { + const [copied, setCopied] = useState(false) - if (loading) return
Loading agent...
; - if (!agent) return
Agent not found. ← Back
; - - const parent = agent.reportsTo ? allAgents.find((a) => a.id === agent.reportsTo) : null; - const children = agent.directReports.map((cid) => allAgents.find((a) => a.id === cid)).filter(Boolean) as Agent[]; + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(content).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + }, [content]) return ( -
- {/* Header */} +
+
+        {content}
+      
+
+ +
+
+ ) +} + +function CopyButton({ text, label }: { text: string; label: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + }, [text]) + + return ( + + ) +} + +/* ────────────────────────────────────────────── + Card wrapper with consistent styling + ────────────────────────────────────────────── */ +function Card({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) +} + +/* ────────────────────────────────────────────── + Loading skeleton for the detail page + ────────────────────────────────────────────── */ +function DetailSkeleton() { + return ( +
+ {/* Header skeleton */}
+ + +
+
+ {/* Hero skeleton */}
- ← Map -
- {agent.emoji} -
- {agent.name} -
{agent.title}
-
+ +
+ +
-
+
+ ) +} + +/* ────────────────────────────────────────────── + Agent Detail Page + ────────────────────────────────────────────── */ +export default function AgentDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = use(params) + const router = useRouter() + const [agent, setAgent] = useState(null) + const [allAgents, setAllAgents] = useState([]) + const [crons, setCrons] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const loadData = useCallback(() => { + setLoading(true) + setError(null) + Promise.all([ + fetch("/api/agents").then((r) => { + if (!r.ok) throw new Error("Failed to fetch agents") + return r.json() + }), + fetch("/api/crons").then((r) => { + if (!r.ok) throw new Error("Failed to fetch crons") + return r.json() + }), + ]) + .then(([agents, c]) => { + setAllAgents(agents) + setAgent(agents.find((a: Agent) => a.id === id) || null) + setCrons(c.filter((cr: CronJob) => cr.agentId === id)) + }) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)) + }, [id]) + + useEffect(() => { + loadData() + }, [loadData]) + + if (loading) return + if (error) return + if (!agent) { + return ( +
+
- Open Chat - + Agent not found +
+ + ← Back to Map + +
+ ) + } + + const parent = agent.reportsTo + ? allAgents.find((a) => a.id === agent.reportsTo) + : null + const children = agent.directReports + .map((cid) => allAgents.find((a) => a.id === cid)) + .filter(Boolean) as Agent[] + + return ( +
+ {/* ── Sticky header ── */} +
+ {/* Color strip */} +
+ +
+ + ← Back to Map + + +
-
- {/* Left column */} -
- {/* About */} + {/* ── Content ── */} +
+ {/* ── Hero section ── */} +
- -
About
-

{agent.description}

+ {agent.emoji}
+
+

+ {agent.name} +

+

+ {agent.title} +

+ {/* Color swatch */} +
+
+
- {/* Tools */} -
+
+ About +
+

-

Tools
-
+ {agent.description} +

+ + + {/* ── Two-column: Tools + Hierarchy ── */} +
+ {/* Tools card */} + +
+ Tools +
+
{agent.tools.map((t) => ( - {TOOL_ICONS[t] && {TOOL_ICONS[t]}} + {TOOL_ICONS[t] && ( + + {TOOL_ICONS[t]} + + )} {t} ))}
-
+ - {/* Voice */} -
-
Voice
- {agent.voiceId ? ( -
- ElevenLabs -
{agent.voiceId}
-
- ) : ( - No voice configured - )} -
- - {/* Hierarchy */} -
-
Hierarchy
+ {/* Hierarchy card */} + +
+ Hierarchy +
{parent && ( -
-
Reports to
- +
+
+ Reports to +
+ {parent.emoji} - {parent.name} + {parent.name} +
)} {children.length > 0 && (
-
Direct reports ({children.length})
-
+
+ Direct reports ({children.length}) +
+
{children.map((c) => ( - + {c.emoji} - {c.name} + {c.name} + ))}
)} -
-
- - {/* Right column */} -
- {/* SOUL.md */} - {agent.soul && ( -
-
SOUL.md
- -
- )} - - {/* Crons */} -
-
- Associated Crons {crons.length > 0 && `(${crons.length})`} -
- {crons.length === 0 ? ( -
No crons associated with this agent
- ) : ( -
- {crons.map((c, i) => ( -
- - {c.name} - {c.schedule} - - {c.status} - - {timeAgo(c.nextRun)} -
- ))} + {!parent && children.length === 0 && ( +
+ No hierarchy connections
)} -
+
+ + {/* ── SOUL.md card ── */} + {agent.soul && ( + +
+ SOUL.md +
+ +
+ )} + + {/* ── Crons card ── */} + +
+ Crons {crons.length > 0 && `(${crons.length})`} +
+ {crons.length === 0 ? ( +
+ No crons associated with this agent +
+ ) : ( +
+ {crons.map((c, idx) => ( +
0 ? "1px solid var(--separator)" : undefined, + background: + c.status === "error" ? "rgba(255,69,58,0.06)" : undefined, + }} + > + + + {c.name} + + + {c.schedule} + + + {c.status} + +
+ ))} +
+ )} + {crons.length > 0 && ( +
+ + View all crons → + +
+ )} +
+ + {/* ── Voice card ── */} + +
+ Voice +
+ {agent.voiceId ? ( +
+ + ElevenLabs + + + {agent.voiceId} + + +
+ ) : ( +
+ No voice configured +
+ )} +
- ); + ) } diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 3e96720..9c5ea3d 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback, Suspense } from 'react' import { useSearchParams, useRouter } from 'next/navigation' import type { Agent } from '@/lib/types' -import { AgentList } from '@/components/chat/AgentList' +import { AgentList, AgentListMobile } from '@/components/chat/AgentList' import { ConversationView } from '@/components/chat/ConversationView' import { loadConversations, saveConversations, getOrCreateConversation, @@ -16,6 +16,7 @@ function MessengerApp() { const [conversations, setConversations] = useState({}) const [activeAgentId, setActiveAgentId] = useState(searchParams.get('agent')) const [loading, setLoading] = useState(true) + const [mobileShowConversation, setMobileShowConversation] = useState(!!searchParams.get('agent')) // Load agents useEffect(() => { @@ -37,15 +38,19 @@ function MessengerApp() { } }, [conversations]) - // Set default active agent + // Set default active agent on desktop only (don't auto-select on mobile) useEffect(() => { if (!loading && agents.length > 0 && !activeAgentId) { - setActiveAgentId(agents[0].id) + // On desktop (>= 768px), select first agent + if (window.innerWidth >= 768) { + setActiveAgentId(agents[0].id) + } } }, [loading, agents, activeAgentId]) const handleSelectAgent = useCallback((agent: Agent) => { setActiveAgentId(agent.id) + setMobileShowConversation(true) setConversations(prev => { const conv = getOrCreateConversation(prev, agent) const next = { ...prev, [agent.id]: conv } @@ -58,6 +63,10 @@ function MessengerApp() { setConversations(prev => updater(prev)) }, []) + const handleMobileBack = useCallback(() => { + setMobileShowConversation(false) + }, []) + const activeAgent = agents.find(a => a.id === activeAgentId) || null // Init conversation for active agent @@ -68,10 +77,11 @@ function MessengerApp() { return markRead({ ...prev, [activeAgent.id]: conv }, activeAgent.id) }) } - }, [activeAgent?.id]) + }, [activeAgent?.id]) // eslint-disable-line react-hooks/exhaustive-deps return (
+ {/* Desktop sidebar — always visible on md+ */} - {activeAgent && conversations[activeAgent.id] ? ( - - ) : ( -
-
🏰
-
Manor Messages
-
Select an agent to start chatting
+ height: '100%', + }} + > + +
+ + {/* Desktop conversation view — visible when agent selected on md+ */} +
+ {activeAgent && conversations[activeAgent.id] ? ( + + ) : ( + + )} +
+ + {/* Mobile conversation view — shown full width when agent selected */} + {mobileShowConversation && activeAgent && conversations[activeAgent.id] && ( +
+
)}
) } +function EmptyState() { + return ( +
+
+ + + +
+
+ Manor Messages +
+
+ Select an agent from the sidebar to start chatting +
+
+ Press Cmd+K to search agents +
+
+ ) +} + export default function ChatPage() { return ( diff --git a/app/crons/page.tsx b/app/crons/page.tsx index 09adc44..e91d4d1 100644 --- a/app/crons/page.tsx +++ b/app/crons/page.tsx @@ -1,10 +1,13 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; + +import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import type { Agent, CronJob } from "@/lib/types"; import { Skeleton } from "@/components/ui/skeleton"; import { ErrorState } from "@/components/ErrorState"; +/* ─── Time helpers ──────────────────────────────────────────────── */ + function timeAgo(dateStr: string | null): string { if (!dateStr) return "never"; const d = new Date(dateStr); @@ -42,8 +45,25 @@ function nextRunLabel(dateStr: string | null): string { return `in ${days}d`; } +/* ─── Types ─────────────────────────────────────────────────────── */ + type Filter = "all" | "ok" | "error" | "idle"; +const STATUS_DOT: Record = { + ok: "var(--system-green)", + error: "var(--system-red)", + idle: "var(--text-tertiary)", +}; + +const PILLS: { key: Filter; label: string; dotColor: string }[] = [ + { key: "all", label: "All", dotColor: "var(--text-primary)" }, + { key: "ok", label: "OK", dotColor: "var(--system-green)" }, + { key: "error", label: "Errors", dotColor: "var(--system-red)" }, + { key: "idle", label: "Idle", dotColor: "var(--text-tertiary)" }, +]; + +/* ─── Component ─────────────────────────────────────────────────── */ + export default function CronsPage() { const [crons, setCrons] = useState([]); const [agents, setAgents] = useState([]); @@ -51,39 +71,57 @@ export default function CronsPage() { const [expanded, setExpanded] = useState(null); const [lastRefresh, setLastRefresh] = useState(new Date()); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); + const [updatedAgo, setUpdatedAgo] = useState("just now"); + const [copiedId, setCopiedId] = useState(null); + + /* Filter pill keyboard navigation */ + const pillsRef = useRef(null); const refresh = useCallback(() => { - setLoading(true); + setRefreshing(true); setError(null); Promise.all([ fetch("/api/crons").then((r) => { - if (!r.ok) throw new Error(`Crons API: ${r.status}`); + if (!r.ok) throw new Error("Failed to load crons"); return r.json(); }), fetch("/api/agents").then((r) => { - if (!r.ok) throw new Error(`Agents API: ${r.status}`); + if (!r.ok) throw new Error("Failed to load agents"); return r.json(); }), ]) .then(([c, a]) => { - if (Array.isArray(c)) setCrons(c); - if (Array.isArray(a)) setAgents(a); + setCrons(c); + setAgents(a); setLastRefresh(new Date()); setLoading(false); + setRefreshing(false); }) - .catch((e) => { - setError(e.message); + .catch((err) => { + setError(err instanceof Error ? err.message : "Unknown error"); setLoading(false); + setRefreshing(false); }); }, []); + /* Auto-refresh every 60s */ useEffect(() => { refresh(); const interval = setInterval(refresh, 60000); return () => clearInterval(interval); }, [refresh]); + /* Update "Updated Xm ago" label every 30s */ + useEffect(() => { + const tick = () => setUpdatedAgo(timeAgo(lastRefresh.toISOString())); + tick(); + const interval = setInterval(tick, 30000); + return () => clearInterval(interval); + }, [lastRefresh]); + + /* Derived data */ const agentMap = new Map(agents.map((a) => [a.id, a])); const statusOrder: Record = { error: 0, idle: 1, ok: 2 }; const filtered = crons @@ -98,160 +136,285 @@ export default function CronsPage() { idle: crons.filter((c) => c.status === "idle").length, }; - const pills: { - key: Filter; - label: string; - dotColor: string; - }[] = [ - { key: "all", label: "All", dotColor: "var(--text-primary)" }, - { key: "ok", label: "Passing", dotColor: "var(--system-green)" }, - { key: "error", label: "Errors", dotColor: "var(--system-red)" }, - { key: "idle", label: "Idle", dotColor: "var(--text-tertiary)" }, - ]; + /* Pill keyboard handler */ + function handlePillKeyDown(e: React.KeyboardEvent) { + const pills = pillsRef.current; + if (!pills) return; + const buttons = Array.from( + pills.querySelectorAll('[role="tab"]') + ); + const current = buttons.findIndex((b) => b.getAttribute("aria-selected") === "true"); + let next = current; + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + next = (current + 1) % buttons.length; + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + next = (current - 1 + buttons.length) % buttons.length; + } + if (next !== current) { + buttons[next].focus(); + buttons[next].click(); + } + } + /* Copy error text */ + function copyError(cronId: string, text: string) { + navigator.clipboard.writeText(text).then(() => { + setCopiedId(cronId); + setTimeout(() => setCopiedId(null), 2000); + }); + } + + /* ─── Error state ──────────────────────────────────────────────── */ if (error && crons.length === 0) { - return ; + return ; } return (
- {/* Header */} -
-
-

- Cron Monitor -

- - {crons.length} - -
-
- - Updated {timeAgo(lastRefresh.toISOString())} - - -
-
- - {/* Filter pills */} -
- {pills.map((pill) => { - const isActive = filter === pill.key; - return ( - - ); - })} -
+ {counts.all} job{counts.all !== 1 ? "s" : ""} + {counts.error > 0 && ( + + {" \u00b7 "}{counts.error} error{counts.error !== 1 ? "s" : ""} + + )} + {" \u00b7 "}{counts.ok} ok +

+ )} +
- {/* Cron list */} -
+ {/* Right: updated label + refresh */} +
+ + Updated {updatedAgo} + + +
+
+ + {/* ── Filter pills ─────────────────────────────────────── */} +
+ {PILLS.map((pill) => { + const isActive = filter === pill.key; + return ( + + ); + })} +
+ + + {/* ── Cron list ──────────────────────────────────────────── */} +
{loading ? ( -
+ /* ── Loading skeleton ─────────────────────────────────── */ +
{[1, 2, 3, 4, 5].map((i) => ( -
1 ? "1px solid var(--separator)" : undefined, - padding: "8px 0", - }}> - - -
- - +
+ + +
+ +
))}
) : filtered.length === 0 ? ( + /* ── Empty state ──────────────────────────────────────── */
- No crons match this filter + + + + + + {crons.length === 0 + ? "No cron jobs found" + : "No crons match this filter"} + + + {crons.length === 0 + ? "Cron jobs will appear here once configured" + : "Try selecting a different status filter"} +
) : ( + /* ── Cron rows ───────────────────────────────────────── */
- {/* Separator between rows (not on first) */} - {!isFirst && ( + {/* Separator */} + {idx > 0 && (
)} - {/* Row */} + {/* Collapsed row */}
setExpanded(isExpanded ? null : cron.id) } - className="flex items-center cursor-pointer transition-colors" - role="button" - aria-expanded={isExpanded} - tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setExpanded(isExpanded ? null : cron.id); } }} + className="flex items-center cursor-pointer hover-bg focus-ring" style={{ - minHeight: 44, - padding: "0 16px", + minHeight: 48, + padding: "0 var(--space-4)", background: isError ? "rgba(255,69,58,0.06)" : undefined, - borderLeft: isError - ? "3px solid var(--system-red)" - : "3px solid transparent", - }} - onMouseEnter={(e) => { - if (!isError) - e.currentTarget.style.background = - "var(--material-ultra-thin)"; - }} - onMouseLeave={(e) => { - if (!isError) - e.currentTarget.style.background = ""; + borderLeft: `3px solid ${ + isError + ? "var(--system-red)" + : cron.status === "ok" + ? "var(--system-green)" + : "transparent" + }`, }} > {/* Status dot */} 0 - ? "animate-error-pulse" - : "" + className={`flex-shrink-0 rounded-full ${ + isError ? "animate-error-pulse" : "" }`} style={{ - background: - cron.status === "ok" - ? "var(--system-green)" - : cron.status === "error" - ? "var(--system-red)" - : "var(--text-tertiary)", + width: 8, + height: 8, + background: STATUS_DOT[cron.status] ?? "var(--text-tertiary)", }} /> - {/* Name */} - - {cron.name} - + + {cron.name} + + {/* Agent name under cron name on mobile */} + {agent && ( + e.stopPropagation()} + className="md:hidden focus-ring" + aria-label={`Chat with ${agent.name}`} + style={{ + fontSize: "var(--text-caption1)", + color: "var(--system-blue)", + textDecoration: "none", + lineHeight: "var(--leading-snug)", + }} + > + {agent.name} + + )} +
- {/* Right side: agent link, schedule, chevron */} -
+ {/* Right side: agent, schedule, chevron */} +
+ {/* Agent (desktop) */} {agent ? ( e.stopPropagation()} - className="text-[13px] hover:underline transition-colors" - style={{ color: "var(--system-blue)" }} + className="hidden md:inline focus-ring" + aria-label={`Chat with ${agent.name}`} + style={{ + fontSize: "var(--text-caption1)", + color: "var(--system-blue)", + textDecoration: "none", + }} > {agent.name} ) : ( {"\u2014"} )} - {/* Schedule */} + {/* Schedule (hidden on mobile) */} {cron.schedule} {/* Chevron */} @@ -389,51 +589,149 @@ export default function CronsPage() { {/* Expanded detail */} {isExpanded && ( -
+
+ {/* Detail grid */} +
+ + Last run + + + {timeAgo(cron.lastRun)} + + + + Next run + + + {nextRunLabel(cron.nextRun)} + + + + Status + + + {cron.status} + + + + Schedule + + + {cron.schedule} + +
+ + {/* Error box */} {cron.lastError && (
-
-                            {cron.lastError}
-                          
+
+                              {cron.lastError}
+                            
+ +
)} -
- - Last run: {timeAgo(cron.lastRun)} - - - Next run: {nextRunLabel(cron.nextRun)} - - - ID: {cron.id} - + + {/* Actions */} +
+ {agent && ( + + Chat with {agent.name} + + + )}
)} diff --git a/app/globals.css b/app/globals.css index 3944a05..1e68663 100644 --- a/app/globals.css +++ b/app/globals.css @@ -9,6 +9,47 @@ --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif; --font-mono: "SF Mono", Monaco, Menlo, "Courier New", monospace; + /* Typography Scale — Apple HIG */ + --text-caption2: 11px; + --text-caption1: 12px; + --text-footnote: 13px; + --text-subheadline: 15px; + --text-body: 17px; + --text-title3: 20px; + --text-title2: 22px; + --text-title1: 28px; + --text-large-title: 34px; + + /* Leading */ + --leading-tight: 1.15; + --leading-snug: 1.3; + --leading-normal: 1.47; + --leading-relaxed: 1.65; + + /* Tracking */ + --tracking-tight: -0.41px; + --tracking-normal: -0.24px; + --tracking-wide: 0.07em; + + /* Font Weights */ + --weight-regular: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; + + /* Spacing Scale — 4px grid */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-16: 64px; + + /* Tailwind animation tokens */ --animate-fade-in: fadeIn 0.2s ease-out; --animate-slide-in: slideIn 0.2s ease-out; --animate-pulse-red: pulse-red 1.5s ease-in-out infinite; @@ -23,6 +64,8 @@ /* DEFAULT: Dark (Apple Dark Mode) */ :root, [data-theme="dark"] { --bg: #000000; + --bg-secondary: rgba(28,28,30,1); + --bg-tertiary: rgba(44,44,46,1); --material-regular: rgba(28,28,30,0.92); --material-thick: rgba(22,22,24,0.96); --material-thin: rgba(255,255,255,0.06); @@ -44,10 +87,15 @@ --system-red: #FF453A; --system-orange: #FF9F0A; --system-purple: #BF5AF2; + --inset-shine: inset 0 1px 0 rgba(255,255,255,0.08); + --shadow-subtle: 0 1px 2px rgba(0,0,0,0.20); --shadow-ambient: 0 0 0 0.5px rgba(0,0,0,0.20); --shadow-key: 0 4px 16px rgba(0,0,0,0.40); --shadow-card: 0 0 0 0.5px rgba(0,0,0,0.20), 0 4px 16px rgba(0,0,0,0.40), inset 0 1px 0 rgba(255,255,255,0.08); --shadow-overlay: 0 0 0 0.5px rgba(0,0,0,0.30), 0 16px 48px rgba(0,0,0,0.60), inset 0 1px 0 rgba(255,255,255,0.06); + --code-bg: rgba(255,255,255,0.06); + --code-border: rgba(255,255,255,0.10); + --code-text: #e5e5ea; --sidebar-bg: rgba(28,28,30,0.92); --sidebar-backdrop: blur(40px) saturate(180%); --radius-sm: 6px; @@ -63,6 +111,8 @@ /* GLASS: Frosted glass dark variant */ [data-theme="glass"] { --bg: #0d0d18; + --bg-secondary: rgba(20,20,32,1); + --bg-tertiary: rgba(30,30,48,1); --material-regular: rgba(255,255,255,0.07); --material-thick: rgba(255,255,255,0.10); --material-thin: rgba(255,255,255,0.06); @@ -84,10 +134,15 @@ --system-red: #FF5C57; --system-orange: #FFB340; --system-purple: #CC6FF0; + --inset-shine: inset 0 1px 0 rgba(255,255,255,0.15); + --shadow-subtle: 0 1px 3px rgba(0,0,0,0.25); --shadow-ambient: 0 0 0 0.5px rgba(255,255,255,0.06); --shadow-key: 0 8px 32px rgba(0,0,0,0.45); --shadow-card: 0 0 0 0.5px rgba(255,255,255,0.06), 0 8px 32px rgba(0,0,0,0.40), inset 0 1px 0 rgba(255,255,255,0.15); --shadow-overlay: 0 0 0 0.5px rgba(255,255,255,0.08), 0 16px 56px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.12); + --code-bg: rgba(255,255,255,0.07); + --code-border: rgba(255,255,255,0.12); + --code-text: #e5e5ea; --sidebar-bg: rgba(255,255,255,0.05); --sidebar-backdrop: blur(40px) saturate(180%); --radius-sm: 6px; @@ -103,6 +158,8 @@ /* COLOR: Vibrant purple-indigo variant */ [data-theme="color"] { --bg: #0a0814; + --bg-secondary: #16112a; + --bg-tertiary: #1e1838; --material-regular: #16112a; --material-thick: #1e1838; --material-thin: rgba(139,92,246,0.12); @@ -124,10 +181,15 @@ --system-red: #F87171; --system-orange: #FB923C; --system-purple: #C084FC; + --inset-shine: inset 0 1px 0 rgba(139,92,246,0.15); + --shadow-subtle: 0 1px 3px rgba(88,28,135,0.20); --shadow-ambient: 0 0 0 0.5px rgba(88,28,135,0.30); --shadow-key: 0 8px 32px rgba(88,28,135,0.40); --shadow-card: 0 0 0 0.5px rgba(88,28,135,0.25), 0 4px 24px rgba(88,28,135,0.30), inset 0 1px 0 rgba(139,92,246,0.15); --shadow-overlay: 0 0 0 0.5px rgba(88,28,135,0.30), 0 16px 48px rgba(88,28,135,0.45), inset 0 1px 0 rgba(139,92,246,0.12); + --code-bg: rgba(139,92,246,0.10); + --code-border: rgba(139,92,246,0.20); + --code-text: #ddd6fe; --sidebar-bg: #0f0b20; --sidebar-backdrop: blur(40px) saturate(200%); --radius-sm: 6px; @@ -143,6 +205,8 @@ /* LIGHT: Apple Light Mode */ [data-theme="light"] { --bg: #f2f2f7; + --bg-secondary: #ffffff; + --bg-tertiary: #e5e5ea; --material-regular: #ffffff; --material-thick: rgba(255,255,255,0.97); --material-thin: rgba(0,0,0,0.03); @@ -157,17 +221,22 @@ --text-secondary: rgba(60,60,67,0.60); --text-tertiary: rgba(60,60,67,0.30); --text-quaternary: rgba(60,60,67,0.18); - --accent: #D4A017; - --accent-fill: rgba(212,160,23,0.12); + --accent: #B8860B; + --accent-fill: rgba(184,134,11,0.12); --system-blue: #007AFF; --system-green: #28CD41; --system-red: #FF3B30; --system-orange: #FF9500; --system-purple: #AF52DE; + --inset-shine: inset 0 1px 0 rgba(255,255,255,0.70); + --shadow-subtle: 0 1px 2px rgba(0,0,0,0.06); --shadow-ambient: 0 0 0 0.5px rgba(0,0,0,0.08); - --shadow-key: 0 2px 8px rgba(0,0,0,0.12); - --shadow-card: 0 0 0 0.5px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.70); - --shadow-overlay: 0 0 0 0.5px rgba(0,0,0,0.10), 0 8px 32px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.80); + --shadow-key: 0 2px 8px rgba(0,0,0,0.12), 0 8px 24px rgba(0,0,0,0.08); + --shadow-card: 0 0 0 0.5px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.06), 0 4px 12px rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,0.70); + --shadow-overlay: 0 0 0 0.5px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.10), 0 16px 48px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.80); + --code-bg: rgba(0,0,0,0.04); + --code-border: rgba(0,0,0,0.08); + --code-text: #1c1c1e; --sidebar-bg: #ffffff; --sidebar-backdrop: blur(20px) saturate(150%); --radius-sm: 6px; @@ -184,6 +253,8 @@ @media (prefers-color-scheme: light) { [data-theme="system"] { --bg: #f2f2f7; + --bg-secondary: #ffffff; + --bg-tertiary: #e5e5ea; --material-regular: #ffffff; --material-thick: rgba(255,255,255,0.97); --material-thin: rgba(0,0,0,0.03); @@ -198,17 +269,22 @@ --text-secondary: rgba(60,60,67,0.60); --text-tertiary: rgba(60,60,67,0.30); --text-quaternary: rgba(60,60,67,0.18); - --accent: #D4A017; - --accent-fill: rgba(212,160,23,0.12); + --accent: #B8860B; + --accent-fill: rgba(184,134,11,0.12); --system-blue: #007AFF; --system-green: #28CD41; --system-red: #FF3B30; --system-orange: #FF9500; --system-purple: #AF52DE; + --inset-shine: inset 0 1px 0 rgba(255,255,255,0.70); + --shadow-subtle: 0 1px 2px rgba(0,0,0,0.06); --shadow-ambient: 0 0 0 0.5px rgba(0,0,0,0.08); - --shadow-key: 0 2px 8px rgba(0,0,0,0.12); - --shadow-card: 0 0 0 0.5px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.70); - --shadow-overlay: 0 0 0 0.5px rgba(0,0,0,0.10), 0 8px 32px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.80); + --shadow-key: 0 2px 8px rgba(0,0,0,0.12), 0 8px 24px rgba(0,0,0,0.08); + --shadow-card: 0 0 0 0.5px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.06), 0 4px 12px rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,0.70); + --shadow-overlay: 0 0 0 0.5px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.10), 0 16px 48px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.80); + --code-bg: rgba(0,0,0,0.04); + --code-border: rgba(0,0,0,0.08); + --code-text: #1c1c1e; --sidebar-bg: #ffffff; --sidebar-backdrop: blur(20px) saturate(150%); --radius-sm: 6px; @@ -235,6 +311,8 @@ body { background: var(--bg); color: var(--text-primary); font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif; + font-size: var(--text-body); + line-height: var(--leading-normal); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; @@ -293,6 +371,32 @@ body { 50% { transform: translate(-3px, -3px); } } +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes scaleUp { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes fadeOut { + from { opacity: 1; } + to { opacity: 0; } +} + +/* Typing dots */ +@keyframes bounce-dot { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-4px); } +} + /* ============================================ Utility Animation Classes ============================================ */ @@ -306,6 +410,107 @@ body { .animate-blink { animation: blink-cursor 1s step-end infinite; } .animate-float-hint { animation: float-hint 2s ease-in-out infinite; } +/* New animation utilities */ +.animate-shimmer { animation: shimmer 1.5s ease-in-out infinite; background-size: 200% 100%; } +.animate-slide-down { animation: slideDown 250ms var(--ease-smooth) forwards; } +.animate-slide-up-enter { animation: slideUp 250ms var(--ease-smooth) forwards; } +.animate-scale-up { animation: scaleUp 200ms var(--ease-spring) forwards; } +.animate-fade-out { animation: fadeOut 150ms ease forwards; } + +/* ============================================ + Interactive State Classes + ============================================ */ + +/* Hover lift — card elevation on hover */ +.hover-lift { + transition: transform 200ms var(--ease-spring), box-shadow 200ms var(--ease-smooth); +} +.hover-lift:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-key); +} +.hover-lift:active { + transform: translateY(0) scale(0.98); + transition-duration: 100ms; +} + +/* Hover background — subtle fill on hover */ +.hover-bg { + transition: background-color 150ms var(--ease-smooth); +} +.hover-bg:hover { + background-color: var(--fill-secondary); +} +.hover-bg:active { + background-color: var(--fill-tertiary); +} + +/* Button scale — tactile press */ +.btn-scale { + transition: transform 150ms var(--ease-spring), box-shadow 150ms var(--ease-smooth); +} +.btn-scale:hover { + transform: scale(0.98); +} +.btn-scale:active { + transform: scale(0.96); +} + +/* Primary CTA — gold glow */ +.btn-primary { + background: var(--accent); + color: #000; + font-weight: var(--weight-semibold); + border: none; + cursor: pointer; + transition: all 150ms var(--ease-spring); +} +.btn-primary:hover { + transform: scale(0.98); + box-shadow: 0 0 24px rgba(245, 197, 24, 0.35); +} +.btn-primary:active { + transform: scale(0.96); +} +.btn-primary:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* Ghost button */ +.btn-ghost { + background: transparent; + color: var(--text-secondary); + border: none; + cursor: pointer; + transition: all 150ms var(--ease-smooth); +} +.btn-ghost:hover { + background: var(--fill-secondary); + color: var(--text-primary); +} + +/* Focus ring — keyboard only */ +.focus-ring:focus-visible { + outline: 2px solid var(--system-blue); + outline-offset: 2px; +} + +/* Nav item */ +.nav-item { + transition: background-color 150ms var(--ease-smooth), color 150ms var(--ease-smooth); + border-radius: var(--radius-sm); +} +.nav-item:hover { + background: var(--fill-secondary); +} +.nav-item.active { + background: var(--fill-secondary); + color: var(--accent); +} + /* ============================================ React Flow Overrides (theme-aware) ============================================ */ @@ -418,7 +623,7 @@ body { [data-theme="light"] .apple-card { background: #ffffff !important; - border: 1px solid rgba(60,60,67,0.15) !important; + border: 1px solid rgba(60,60,67,0.12) !important; box-shadow: var(--shadow-card) !important; } @@ -437,7 +642,7 @@ body { .msg-user code { background: rgba(0,0,0,0.12) !important; color: #000 !important; } /* ============================================ - Accessibility: Reduced Motion + Reduced Motion ============================================ */ @media (prefers-reduced-motion: reduce) { @@ -445,5 +650,6 @@ body { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; + scroll-behavior: auto !important; } } diff --git a/app/layout.tsx b/app/layout.tsx index dbc1df2..6080263 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,93 +1,33 @@ -import type { Metadata } from "next"; -import "./globals.css"; -import { NavLinks } from "@/components/NavLinks"; -import { ThemeProvider } from "./providers"; -import { ThemeToggle } from "@/components/ThemeToggle"; -import { MobileSidebar } from "@/components/MobileSidebar"; +import type { Metadata } from 'next'; +import './globals.css'; +import { ThemeProvider } from './providers'; +import { Sidebar } from '@/components/Sidebar'; export const metadata: Metadata = { - title: "Manor — Command Centre", - description: "AI Agent Management Dashboard", + title: 'Manor -- Command Centre', + description: 'AI Agent Management Dashboard', }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { return ( -
- {/* Desktop sidebar — hidden on mobile */} - - - {/* Mobile sidebar */} - +
+ {/* Client-side shell handles both desktop sidebar + mobile */} + + {/* Main content */}
- {/* Glass background orbs — only visible in glass theme */} -
diff --git a/app/memory/page.tsx b/app/memory/page.tsx index 64d36f0..890e80e 100644 --- a/app/memory/page.tsx +++ b/app/memory/page.tsx @@ -1,8 +1,12 @@ "use client"; -import { useEffect, useState, useRef, useCallback } from "react"; + +import { useCallback, useEffect, useRef, useState } from "react"; import type { MemoryFile } from "@/lib/types"; import { renderMarkdown, colorizeJson } from "@/lib/sanitize"; import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorState } from "@/components/ErrorState"; + +/* ─── Helpers ───────────────────────────────────────────────────── */ function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); @@ -15,109 +19,266 @@ function timeAgo(dateStr: string): string { return `${days}d ago`; } +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(1)}KB`; + return `${(kb / 1024).toFixed(1)}MB`; +} + function wordCount(text: string): number { return text.trim().split(/\s+/).filter(Boolean).length; } +function isJsonFile(file: MemoryFile): boolean { + return file.label.includes("JSON") || file.path.endsWith(".json"); +} + +/* ─── Icons ─────────────────────────────────────────────────────── */ + +function FileIcon({ isJson }: { isJson: boolean }) { + return ( + + {isJson ? ( + /* clipboard icon for JSON */ + <> + + + + + + + ) : ( + /* document icon for MD */ + <> + + + + + + )} + + ); +} + +function FolderIcon() { + return ( + + + + ); +} + +function BackArrow() { + return ( + + + + ); +} + +/* ─── Component ─────────────────────────────────────────────────── */ + export default function MemoryPage() { const [files, setFiles] = useState([]); const [selected, setSelected] = useState(null); const [loading, setLoading] = useState(true); - const contentRef = useRef(null); - const fileListRef = useRef(null); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [copied, setCopied] = useState(false); + const [mobileShowContent, setMobileShowContent] = useState(false); - function refresh() { + const listRef = useRef(null); + const searchRef = useRef(null); + + const refresh = useCallback(() => { + setLoading(true); + setError(null); fetch("/api/memory") - .then((r) => r.json()) + .then((r) => { + if (!r.ok) throw new Error("Failed to load memory files"); + return r.json(); + }) .then((data: MemoryFile[]) => { setFiles(data); if (data.length > 0 && !selected) setSelected(data[0]); setLoading(false); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : "Unknown error"); + setLoading(false); }); - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { refresh(); - }, []); + }, [refresh]); - // ESC key to deselect file - useEffect(() => { - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape" && selected) { - setSelected(null); - } - } - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [selected]); - - // Arrow key navigation in file list - const handleFileListKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (files.length === 0) return; - const currentIndex = selected - ? files.findIndex((f) => f.path === selected.path) - : -1; - - if (e.key === "ArrowDown") { - e.preventDefault(); - const nextIndex = currentIndex < files.length - 1 ? currentIndex + 1 : 0; - setSelected(files[nextIndex]); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - const prevIndex = currentIndex > 0 ? currentIndex - 1 : files.length - 1; - setSelected(files[prevIndex]); - } - }, - [files, selected] + /* Filtered files by search */ + const filteredFiles = files.filter((f) => + f.label.toLowerCase().includes(search.toLowerCase()) || + f.path.toLowerCase().includes(search.toLowerCase()) ); - // Auto-focus content area when file selected - useEffect(() => { - if (selected && contentRef.current) { - contentRef.current.focus(); + /* Keyboard navigation in file list */ + function handleListKeyDown(e: React.KeyboardEvent) { + const items = listRef.current?.querySelectorAll('[role="option"]'); + if (!items || items.length === 0) return; + + const currentIdx = Array.from(items).findIndex( + (el) => el.getAttribute("aria-selected") === "true" + ); + + let nextIdx = currentIdx; + + if (e.key === "ArrowDown") { + e.preventDefault(); + nextIdx = Math.min(currentIdx + 1, items.length - 1); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + nextIdx = Math.max(currentIdx - 1, 0); + } else if (e.key === "Enter") { + e.preventDefault(); + if (currentIdx >= 0) { + items[currentIdx].click(); + setMobileShowContent(true); + } + return; + } else if (e.key === "Escape") { + e.preventDefault(); + searchRef.current?.focus(); + return; } - }, [selected]); - const isJSON = - selected?.label.includes("JSON") || selected?.path.endsWith(".json"); + if (nextIdx !== currentIdx && nextIdx >= 0) { + items[nextIdx].click(); + items[nextIdx].focus(); + } + } + /* Copy content */ + function copyContent() { + if (!selected) return; + navigator.clipboard.writeText(selected.content).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + + /* Download content */ + function downloadContent() { + if (!selected) return; + const blob = new Blob([selected.content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = selected.path.split("/").pop() || "file.md"; + a.click(); + URL.revokeObjectURL(url); + } + + /* Select file and show content on mobile */ + function selectFile(file: MemoryFile) { + setSelected(file); + setMobileShowContent(true); + } + + /* Computed */ + const isJson = selected ? isJsonFile(selected) : false; + const lineCount = selected ? selected.content.split("\n").length : 0; + const words = selected ? wordCount(selected.content) : 0; + const sizeBytes = selected ? new Blob([selected.content]).size : 0; + + /* Breadcrumb from path */ + const breadcrumb = selected?.path.replace(/^\//, "").split("/") ?? []; + + /* Error state */ + if (error && files.length === 0) { + return ; + } + + /* ─── Rendered content ────────────────────────────────────────── */ let renderedContent: React.ReactNode = null; if (selected) { - if (isJSON) { + if (isJson) { try { const pretty = JSON.stringify(JSON.parse(selected.content), null, 2); const lines = pretty.split("\n"); renderedContent = (
{/* Line numbers */}
{lines.map((_, i) => (
{i + 1}
))}
- {/* Syntax highlighted content */} + {/* JSON content */}
             
               {selected.content}
             
@@ -146,8 +313,11 @@ export default function MemoryPage() { } else { renderedContent = (
${renderMarkdown(selected.content)}

`, }} @@ -156,175 +326,366 @@ export default function MemoryPage() { } } - const lineCount = selected ? selected.content.split("\n").length : 0; - const words = selected ? wordCount(selected.content) : 0; - return ( -
- {/* Sidebar */} -
+ {/* ── File list sidebar ──────────────────────────────────── */} + - {/* Main content */} -
{selected ? ( <> - {/* Content area */} + {/* Content header (sticky) */}
-
- {/* File title */} -

- {selected.label} -

+ {/* Mobile back button */} + - {/* Meta */} -
- {isJSON ? ( - <> - {lineCount} lines · Modified{" "} - {timeAgo(selected.lastModified)} - - ) : ( - <> - {words.toLocaleString()} words ·{" "} - {lineCount} lines · Modified{" "} - {timeAgo(selected.lastModified)} - - )} +
+
+ {/* Breadcrumb */} +
+ {breadcrumb.map((part, i) => ( + + {i > 0 && ( + + / + + )} + + {part} + + + ))} +
+ + {/* Metadata */} +
+ {lineCount} line{lineCount !== 1 ? "s" : ""} + {!isJson && <> {"\u00b7"} {words.toLocaleString()} words} + {" \u00b7 "} + {formatBytes(sizeBytes)} + {" \u00b7 "} + {timeAgo(selected.lastModified)} +
- {/* Content */} + {/* Action buttons */} +
+ + +
+
+
+ + {/* Scrollable content area */} +
+
{renderedContent}
) : ( -
+ /* ── Empty state (no file selected) ──────────────────── */ +
+ - Select a file from the sidebar + Select a file + + + Choose a file from the sidebar to view its contents
)} -
+
); } diff --git a/app/page.tsx b/app/page.tsx index db1c3b0..cafcaeb 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,418 +1,637 @@ -"use client"; -import { useEffect, useState, useRef, useCallback } from "react"; -import { useRouter } from "next/navigation"; -import dynamic from "next/dynamic"; -import type { Agent, CronJob } from "@/lib/types"; -import { Skeleton } from "@/components/ui/skeleton"; -import { ErrorState } from "@/components/ErrorState"; +"use client" +import { useEffect, useState, useRef, useCallback } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import dynamic from "next/dynamic" +import type { Agent, CronJob } from "@/lib/types" +import { Skeleton } from "@/components/ui/skeleton" +import { ErrorState } from "@/components/ErrorState" const ManorMap = dynamic( () => import("@/components/ManorMap").then((m) => ({ default: m.ManorMap })), { ssr: false, loading: () => ( -
-
-
- {/* Skeleton org chart: 3 rows of rectangles */} -
- -
-
- - - -
-
- - - - -
-
+
+
+ + +
), - } -); + }, +) const TOOL_ICONS: Record = { - web_search: "\uD83D\uDD0D", read: "\uD83D\uDCC1", write: "\u270F\uFE0F", exec: "\uD83D\uDCBB", - web_fetch: "\uD83C\uDF10", message: "\uD83D\uDD14", tts: "\uD83D\uDCAC", -}; + web_search: "\uD83D\uDD0D", + read: "\uD83D\uDCC1", + write: "\u270F\uFE0F", + exec: "\uD83D\uDCBB", + web_fetch: "\uD83C\uDF10", + message: "\uD83D\uDD14", + tts: "\uD83D\uDCAC", + edit: "\u2702\uFE0F", + sessions_spawn: "\uD83D\uDD04", + memory_search: "\uD83E\udDE0", +} function StatusDot({ status }: { status: CronJob["status"] }) { return ( - - ); + + ) } -export default function ManorPage() { - const router = useRouter(); - const [agents, setAgents] = useState([]); - const [crons, setCrons] = useState([]); - const [selected, setSelected] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const closeBtnRef = useRef(null); +/* ────────────────────────────────────────────── + Loading skeleton for the map area + ────────────────────────────────────────────── */ +function MapSkeleton() { + return ( +
+ {/* Fake root node */} + + {/* Fake second row */} +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ {/* Fake third row */} +
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
+
+ ) +} - const fetchData = useCallback(() => { - setLoading(true); - setError(null); +/* ────────────────────────────────────────────── + Main page + ────────────────────────────────────────────── */ +export default function ManorPage() { + const router = useRouter() + const [agents, setAgents] = useState([]) + const [crons, setCrons] = useState([]) + const [selected, setSelected] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const closeRef = useRef(null) + + const loadData = useCallback(() => { + setLoading(true) + setError(null) Promise.all([ - fetch("/api/agents").then((r) => r.json()), - fetch("/api/crons").then((r) => r.json()), + fetch("/api/agents").then((r) => { + if (!r.ok) throw new Error("Failed to fetch agents") + return r.json() + }), + fetch("/api/crons").then((r) => { + if (!r.ok) throw new Error("Failed to fetch crons") + return r.json() + }), ]) .then(([a, c]) => { - setAgents(Array.isArray(a) ? a : []); - setCrons(Array.isArray(c) ? c : []); + setAgents(a) + setCrons(c) }) .catch((e) => setError(e.message)) - .finally(() => setLoading(false)); - }, []); + .finally(() => setLoading(false)) + }, []) useEffect(() => { - fetchData(); - }, [fetchData]); + loadData() + }, [loadData]) - // ESC key to close detail panel + // Focus close button when panel opens + useEffect(() => { + if (selected && closeRef.current) { + closeRef.current.focus() + } + }, [selected]) + + // Keyboard: ESC closes panel useEffect(() => { - if (!selected) return; function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - setSelected(null); + if (e.key === "Escape" && selected) { + setSelected(null) } } - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [selected]); + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [selected]) - // Auto-focus close button when detail panel opens - useEffect(() => { - if (selected && closeBtnRef.current) { - closeBtnRef.current.focus(); - } - }, [selected]); + const agentCrons = selected ? crons.filter((c) => c.agentId === selected.id) : [] - const agentCrons = selected ? crons.filter((c) => c.agentId === selected.id) : []; + // Find hierarchy info for the detail panel + const parentAgent = selected?.reportsTo + ? agents.find((a) => a.id === selected.reportsTo) + : null + const childAgents = selected + ? selected.directReports + .map((cid) => agents.find((a) => a.id === cid)) + .filter(Boolean) as Agent[] + : [] if (error) { - return ; + return } return ( -
- {/* Map */} -
+
+ {/* ── Map area ── */} +
{loading ? ( -
-
-
-
- -
-
- - - -
-
- - - - -
-
-
-
+ ) : ( - - )} -
- - {/* Detail panel */} - {selected ? ( - <> - {/* Mobile backdrop */} -
setSelected(null)} - aria-hidden="true" + -
- -
- {/* Color strip */} -
+ )} - {/* Close */} -
- -
- - {/* Header */} -
- {/* Emoji on squircle */} -
- {selected.emoji} -
- -

- {selected.name} -

- -

- {selected.title} -

- - {/* Color badge */} - - {selected.color} - -
- - {/* Description */} -
-

- {selected.description} -

-
- - {/* Tools */} -
-
- Tools -
-
- {selected.tools.map((t) => ( - - {TOOL_ICONS[t] && {TOOL_ICONS[t]}} - {t} - - ))} -
-
- - {/* Crons */} - {agentCrons.length > 0 && ( -
-
- Crons -
-
- {agentCrons.map((c, idx) => ( -
0 ? '1px solid var(--separator)' : undefined, - }}> - - - {c.name} - - - {c.schedule} - -
- ))} -
-
- )} - - {/* CTA */} -
- -
-
-
- - ) : ( - /* Empty state — hidden on mobile */ + {/* Legend -- top right */}
+ + + Healthy + + + + Errors + + + + No crons + +
+
+ + {/* ── Mobile backdrop ── */} + {selected && ( +
setSelected(null)} + /> + )} + + {/* ── Detail panel ── */} + {selected ? ( +
+
+ {/* Color strip */} +
+ + {/* Close button */} +
+ +
+ + {/* Header */} +
+ {/* Emoji on squircle */} +
+ {selected.emoji} +
+ +

+ {selected.name} +

+ +

+ {selected.title} +

+ +
+
+ + {/* ABOUT */} +
+
+ About +
+

+ {selected.description} +

+
+ + {/* TOOLS */} +
+
+ Tools +
+
+ {selected.tools.map((t) => ( + + {TOOL_ICONS[t] && ( + {TOOL_ICONS[t]} + )} + {t} + + ))} +
+
+ + {/* HIERARCHY */} + {(parentAgent || childAgents.length > 0) && ( +
+
+ Hierarchy +
+ {parentAgent && ( +
+ + Reports to + + +
+ )} + {childAgents.length > 0 && ( +
+ + Direct reports + +
+ {childAgents.map((c) => ( + + ))} +
+
+ )} +
+ )} + + {/* CRONS */} + {agentCrons.length > 0 && ( +
+
+ Crons +
+
+ {agentCrons.map((c, idx) => ( +
0 ? "1px solid var(--separator)" : undefined, + }} + > + + + {c.name} + + + {c.schedule} + +
+ ))} +
+
+ )} + + {/* CTAs -- pushed to bottom */} +
+ + + View Profile + +
+
+
+ ) : ( + /* Empty state -- hidden on mobile */ +
-
-
{"\uD83D\uDD75\uFE0F"}
-
+ alignItems: "center", + justifyContent: "center", + background: "var(--material-regular)", + backdropFilter: "var(--sidebar-backdrop)", + WebkitBackdropFilter: "var(--sidebar-backdrop)", + boxShadow: "var(--shadow-overlay)", + }} + > +
+
+ {"\uD83D\uDDFA\uFE0F"} +
+
Select an agent
-
+
Click any node on the map to inspect
+
+ Tip: Press ESC to close the panel +
)}
- ); + ) } diff --git a/components/AgentNode.tsx b/components/AgentNode.tsx index eeb4dc5..9cc28b6 100644 --- a/components/AgentNode.tsx +++ b/components/AgentNode.tsx @@ -1,111 +1,99 @@ -"use client"; -import { Handle, Position } from "@xyflow/react"; -import type { Agent } from "@/lib/types"; +"use client" +import { Handle, Position, type NodeProps } from "@xyflow/react" +import type { Agent, CronJob } from "@/lib/types" -interface AgentNodeProps { - data: Agent & Record; -} +type AgentNodeData = Agent & { crons: CronJob[] } & Record -export function AgentNode({ data }: AgentNodeProps) { - const hasCrons = data.crons && data.crons.length > 0; - const hasError = hasCrons && data.crons.some(c => c.status === 'error'); - const hasOk = hasCrons && data.crons.some(c => c.status === 'ok'); +export function AgentNode({ data, selected }: NodeProps) { + const agent = data as AgentNodeData + const hasCrons = agent.crons && agent.crons.length > 0 + const hasErrors = hasCrons && agent.crons.some((c: CronJob) => c.status === "error") return ( - <> - +
+ {/* Status dot -- top right */} + {hasCrons && ( +
+ )} + + {/* Emoji on tinted squircle */}
{ - e.currentTarget.style.borderColor = 'rgba(255,255,255,0.18)'; - e.currentTarget.style.boxShadow = 'var(--shadow-overlay)'; - e.currentTarget.style.transform = 'translateY(-1px)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.borderColor = 'rgba(255,255,255,0.10)'; - e.currentTarget.style.boxShadow = 'var(--shadow-card)'; - e.currentTarget.style.transform = 'translateY(0)'; + fontSize: 24, + marginBottom: "var(--space-1)", + width: 36, + height: 36, + borderRadius: 8, + background: `${agent.color}20`, + display: "flex", + alignItems: "center", + justifyContent: "center", }} > - {/* Top: emoji + status dot */} -
- {data.emoji} - -
- - {/* Name */} -
- {data.name} -
- - {/* Title */} -
- {data.title} -
- - {/* Cron pill */} - {hasCrons && ( -
- {data.crons.length} cron{data.crons.length > 1 ? 's' : ''} -
- )} + {agent.emoji}
- - - ); + + {/* Name */} +
+ {agent.name} +
+ + {/* Title */} +
+ {agent.title} +
+ + {/* Handles - invisible */} + + +
+ ) } -export const nodeTypes = { agentNode: AgentNode }; +export const nodeTypes = { agentNode: AgentNode } diff --git a/components/Breadcrumbs.tsx b/components/Breadcrumbs.tsx new file mode 100644 index 0000000..ccaceaa --- /dev/null +++ b/components/Breadcrumbs.tsx @@ -0,0 +1,126 @@ +'use client'; + +import Link from 'next/link'; +import { ChevronRight } from 'lucide-react'; + +export interface BreadcrumbItem { + label: string; + href?: string; + icon?: React.ReactNode; +} + +export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) { + if (items.length === 0) return null; + + return ( + + ); +} diff --git a/components/GlobalSearch.tsx b/components/GlobalSearch.tsx new file mode 100644 index 0000000..59e8058 --- /dev/null +++ b/components/GlobalSearch.tsx @@ -0,0 +1,569 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { + Search, + Map, + MessageSquare, + Clock, + Brain, + Bot, + Timer, +} from 'lucide-react'; +import type { Agent, CronJob } from '@/lib/types'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface SearchResult { + id: string; + label: string; + subtitle?: string; + icon: React.ReactNode; + href: string; + category: 'Agents' | 'Pages' | 'Crons'; +} + +// --------------------------------------------------------------------------- +// Static pages +// --------------------------------------------------------------------------- + +const STATIC_PAGES: SearchResult[] = [ + { id: 'page-map', label: 'Map', icon: , href: '/', category: 'Pages' }, + { id: 'page-messages', label: 'Messages', icon: , href: '/chat', category: 'Pages' }, + { id: 'page-crons', label: 'Crons', icon: , href: '/crons', category: 'Pages' }, + { id: 'page-memory', label: 'Memory', icon: , href: '/memory', category: 'Pages' }, +]; + +// --------------------------------------------------------------------------- +// Simple fuzzy match — case-insensitive substring +// --------------------------------------------------------------------------- + +function fuzzyMatch(query: string, target: string): boolean { + const q = query.toLowerCase(); + const t = target.toLowerCase(); + // Substring match + if (t.includes(q)) return true; + // Check if all characters appear in order (fuzzy) + let qi = 0; + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) qi++; + } + return qi === q.length; +} + +// --------------------------------------------------------------------------- +// Search trigger button (used in sidebar) +// --------------------------------------------------------------------------- + +export function SearchTrigger({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// GlobalSearch modal +// --------------------------------------------------------------------------- + +export function GlobalSearch() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const [agents, setAgents] = useState([]); + const [crons, setCrons] = useState([]); + const inputRef = useRef(null); + const listRef = useRef(null); + const router = useRouter(); + + // ----------------------------------------------------------------------- + // Keyboard shortcut: Cmd+K / Ctrl+K + // ----------------------------------------------------------------------- + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setOpen((prev) => !prev); + } + } + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + + // ----------------------------------------------------------------------- + // Custom event: open search from sidebar trigger buttons + // ----------------------------------------------------------------------- + useEffect(() => { + function handleOpenSearch() { + setOpen(true); + } + window.addEventListener('manor:open-search', handleOpenSearch); + return () => window.removeEventListener('manor:open-search', handleOpenSearch); + }, []); + + // ----------------------------------------------------------------------- + // Fetch data when modal opens + // ----------------------------------------------------------------------- + useEffect(() => { + if (!open) return; + // Reset state + setQuery(''); + setActiveIndex(0); + // Fetch agents + fetch('/api/agents') + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }) + .then((data: unknown) => { + if (Array.isArray(data)) setAgents(data as Agent[]); + }) + .catch(() => setAgents([])); + // Fetch crons + fetch('/api/crons') + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }) + .then((data: unknown) => { + if (Array.isArray(data)) setCrons(data as CronJob[]); + }) + .catch(() => setCrons([])); + }, [open]); + + // ----------------------------------------------------------------------- + // Focus input when opened + // ----------------------------------------------------------------------- + useEffect(() => { + if (open) { + // Small delay to ensure the input is mounted + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + } + }, [open]); + + // ----------------------------------------------------------------------- + // Prevent body scroll + // ----------------------------------------------------------------------- + useEffect(() => { + if (open) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [open]); + + // ----------------------------------------------------------------------- + // Build filtered results + // ----------------------------------------------------------------------- + const results = useMemo(() => { + const all: SearchResult[] = []; + + // Agents + agents.forEach((a) => { + all.push({ + id: `agent-${a.id}`, + label: a.name, + subtitle: a.title, + icon: , + href: `/chat?agent=${a.id}`, + category: 'Agents', + }); + }); + + // Static pages + all.push(...STATIC_PAGES); + + // Crons + crons.forEach((c) => { + all.push({ + id: `cron-${c.id}`, + label: c.name, + subtitle: c.schedule, + icon: , + href: '/crons', + category: 'Crons', + }); + }); + + if (!query.trim()) return all; + + return all.filter( + (r) => + fuzzyMatch(query, r.label) || + (r.subtitle && fuzzyMatch(query, r.subtitle)) + ); + }, [query, agents, crons]); + + // ----------------------------------------------------------------------- + // Group results by category + // ----------------------------------------------------------------------- + const grouped = useMemo(() => { + const groups: { category: string; items: SearchResult[] }[] = []; + const categoryOrder = ['Agents', 'Pages', 'Crons']; + for (const cat of categoryOrder) { + const items = results.filter((r) => r.category === cat); + if (items.length > 0) { + groups.push({ category: cat, items }); + } + } + return groups; + }, [results]); + + // Flat list for keyboard nav + const flatResults = useMemo(() => grouped.flatMap((g) => g.items), [grouped]); + + // ----------------------------------------------------------------------- + // Navigation + // ----------------------------------------------------------------------- + const navigate = useCallback( + (result: SearchResult) => { + setOpen(false); + router.push(result.href); + }, + [router] + ); + + // ----------------------------------------------------------------------- + // Keyboard handling inside the modal + // ----------------------------------------------------------------------- + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + setOpen(false); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((prev) => Math.min(prev + 1, flatResults.length - 1)); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((prev) => Math.max(prev - 1, 0)); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + if (flatResults[activeIndex]) { + navigate(flatResults[activeIndex]); + } + return; + } + }, + [activeIndex, flatResults, navigate] + ); + + // Reset active index when results change + useEffect(() => { + setActiveIndex(0); + }, [query]); + + // Scroll active item into view + useEffect(() => { + if (!listRef.current) return; + const activeEl = listRef.current.querySelector('[data-active="true"]'); + if (activeEl) { + activeEl.scrollIntoView({ block: 'nearest' }); + } + }, [activeIndex]); + + if (!open) return null; + + let flatIndex = 0; + + return ( +
+ {/* Backdrop */} +
setOpen(false)} + aria-hidden="true" + /> + + {/* Modal */} +
+ {/* Search input */} +
+
+ + {/* Results */} +
+ {flatResults.length === 0 && query.trim() && ( +
+ No results for ‘{query}’ +
+ )} + + {grouped.map((group) => ( +
+ {/* Category header */} +
+ {group.category} +
+ + {/* Items */} + {group.items.map((item) => { + const currentIndex = flatIndex++; + const isActive = currentIndex === activeIndex; + + return ( + + ); + })} +
+ ))} +
+ + {/* Footer with keyboard hints */} +
+ + {'\u2191\u2193'} Navigate + + + {'\u21B5'} Open + + + esc Close + +
+
+
+ ); +} diff --git a/components/ManorMap.tsx b/components/ManorMap.tsx index 44c98fe..b4b3df8 100644 --- a/components/ManorMap.tsx +++ b/components/ManorMap.tsx @@ -1,108 +1,137 @@ -"use client"; +"use client" import { ReactFlow, Controls, - MiniMap, useNodesState, useEdgesState, type Node, type Edge, -} from "@xyflow/react"; -import { useEffect } from "react"; -import type { Agent, CronJob } from "@/lib/types"; -import { nodeTypes } from "@/components/AgentNode"; + ConnectionLineType, +} from "@xyflow/react" +import { useCallback, useEffect } from "react" +import type { Agent, CronJob } from "@/lib/types" +import { nodeTypes } from "@/components/AgentNode" interface ManorMapProps { - agents: Agent[]; - crons: CronJob[]; - onNodeClick: (agent: Agent) => void; + agents: Agent[] + crons: CronJob[] + selectedId: string | null + onNodeClick: (agent: Agent) => void } -function buildLayout(agents: Agent[], crons: CronJob[]): { nodes: Node[]; edges: Edge[] } { - const agentMap = new Map(agents.map((a) => [a.id, a])); +function buildLayout( + agents: Agent[], + crons: CronJob[], + selectedId: string | null, +): { nodes: Node[]; edges: Edge[] } { + const agentMap = new Map(agents.map((a) => [a.id, a])) const withCrons = agents.map((a) => ({ ...a, crons: crons.filter((c) => c.agentId === a.id), - })); - const agentMapWithCrons = new Map(withCrons.map((a) => [a.id, a])); + })) + const agentMapWithCrons = new Map(withCrons.map((a) => [a.id, a])) - const levels: string[][] = []; - const visited = new Set(); - const root = agents.find((a) => a.reportsTo === null); - if (!root) return { nodes: [], edges: [] }; + // BFS to determine levels + const levels: string[][] = [] + const visited = new Set() + const root = agents.find((a) => a.reportsTo === null) + if (!root) return { nodes: [], edges: [] } - let queue = [root.id]; + let queue = [root.id] while (queue.length > 0) { - levels.push([...queue]); - queue.forEach((id) => visited.add(id)); - const nextQueue: string[] = []; + levels.push([...queue]) + queue.forEach((id) => visited.add(id)) + const nextQueue: string[] = [] for (const id of queue) { - const agent = agentMap.get(id); - if (!agent) continue; + const agent = agentMap.get(id) + if (!agent) continue for (const childId of agent.directReports) { - if (!visited.has(childId)) nextQueue.push(childId); + if (!visited.has(childId)) nextQueue.push(childId) } } - queue = nextQueue; + queue = nextQueue } - const disconnected = agents.filter((a) => !visited.has(a.id)); - if (disconnected.length > 0) levels.push(disconnected.map((a) => a.id)); + // Pick up disconnected agents + const disconnected = agents.filter((a) => !visited.has(a.id)) + if (disconnected.length > 0) levels.push(disconnected.map((a) => a.id)) - const LEVEL_HEIGHT = 200; - const nodes: Node[] = []; + const LEVEL_HEIGHT = 200 + const nodes: Node[] = [] for (let level = 0; level < levels.length; level++) { - const ids = levels[level]; - const spacing = Math.max(160, Math.min(220, 1400 / Math.max(ids.length, 1))); - const totalWidth = ids.length * spacing; - const startX = 600 - totalWidth / 2 + spacing / 2; + const ids = levels[level] + const spacing = Math.max(160, Math.min(220, 1400 / Math.max(ids.length, 1))) + const totalWidth = ids.length * spacing + const startX = 600 - totalWidth / 2 + spacing / 2 ids.forEach((id, i) => { - const agent = agentMapWithCrons.get(id); - if (!agent) return; + const agent = agentMapWithCrons.get(id) + if (!agent) return nodes.push({ id, type: "agentNode", data: agent as unknown as Record, position: { x: startX + i * spacing - spacing / 2, y: level * LEVEL_HEIGHT + 20 }, - }); - }); + selected: id === selectedId, + }) + }) } - const edges: Edge[] = []; + // Build edges -- selected agent's edges get accent color + const selectedAgentIds = new Set() + if (selectedId) { + selectedAgentIds.add(selectedId) + const selectedAgent = agentMap.get(selectedId) + if (selectedAgent) { + if (selectedAgent.reportsTo) selectedAgentIds.add(selectedAgent.reportsTo) + selectedAgent.directReports.forEach((id) => selectedAgentIds.add(id)) + } + } + + const edges: Edge[] = [] for (const agent of agents) { - const parentAgent = agentMap.get(agent.id); - if (!parentAgent) continue; - for (const childId of parentAgent.directReports) { + for (const childId of agent.directReports) { + const isHighlighted = + selectedId && selectedAgentIds.has(agent.id) && selectedAgentIds.has(childId) + edges.push({ id: `${agent.id}-${childId}`, source: agent.id, target: childId, - animated: true, - style: { stroke: 'var(--accent)', strokeWidth: 1.5, opacity: 0.7 }, - }); + type: "smoothstep", + style: { + stroke: isHighlighted ? "var(--accent)" : "var(--separator)", + strokeWidth: isHighlighted ? 2 : 1.5, + opacity: isHighlighted ? 1 : 0.6, + strokeDasharray: isHighlighted ? undefined : "6 4", + }, + animated: !!isHighlighted, + }) } } - return { nodes, edges }; + return { nodes, edges } } -export function ManorMap({ agents, crons, onNodeClick }: ManorMapProps) { - const { nodes: initialNodes, edges: initialEdges } = buildLayout(agents, crons); - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); +export function ManorMap({ agents, crons, selectedId, onNodeClick }: ManorMapProps) { + const { nodes: initialNodes, edges: initialEdges } = buildLayout(agents, crons, selectedId) + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes) + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges) useEffect(() => { - const { nodes: n, edges: e } = buildLayout(agents, crons); - setNodes(n); - setEdges(e); - }, [agents, crons]); + const { nodes: n, edges: e } = buildLayout(agents, crons, selectedId) + setNodes(n) + setEdges(e) + }, [agents, crons, selectedId, setNodes, setEdges]) - const handleNodeClick = (_: React.MouseEvent, node: Node) => { - const agent = agents.find((a) => a.id === node.id); - if (agent) onNodeClick(agent); - }; + const handleNodeClick = useCallback( + (_: React.MouseEvent, node: Node) => { + const agent = agents.find((a) => a.id === node.id) + if (agent) onNodeClick(agent) + }, + [agents, onNodeClick], + ) return ( - - (n.data as unknown as Agent).color || "rgba(84,84,88,0.4)"} - maskColor="rgba(0,0,0,0.8)" + - ); + ) } diff --git a/components/MobileSidebar.tsx b/components/MobileSidebar.tsx index d94a602..8f5dac8 100644 --- a/components/MobileSidebar.tsx +++ b/components/MobileSidebar.tsx @@ -1,10 +1,17 @@ 'use client'; + import { useState, useEffect, useCallback } from 'react'; +import { usePathname } from 'next/navigation'; +import { Menu, X } from 'lucide-react'; import { NavLinks } from '@/components/NavLinks'; import { ThemeToggle } from '@/components/ThemeToggle'; -import { usePathname } from 'next/navigation'; +import { SearchTrigger } from '@/components/GlobalSearch'; -export function MobileSidebar() { +export function MobileSidebar({ + onOpenSearch, +}: { + onOpenSearch?: () => void; +}) { const [open, setOpen] = useState(false); const pathname = usePathname(); @@ -30,92 +37,129 @@ export function MobileSidebar() { } else { document.body.style.overflow = ''; } - return () => { document.body.style.overflow = ''; }; + return () => { + document.body.style.overflow = ''; + }; }, [open]); - const toggle = useCallback(() => setOpen(prev => !prev), []); + const toggle = useCallback(() => setOpen((prev) => !prev), []); + + const handleSearchClick = useCallback(() => { + setOpen(false); + onOpenSearch?.(); + }, [onOpenSearch]); return ( <> - {/* Hamburger button — visible only on mobile */} - + > + {open ? : } + + + {/* App title */} +
+ + {'\ud83c\udff0'} + + + Manor Command Centre + +
+ {/* Backdrop */} - {open && ( -
setOpen(false)} - aria-hidden="true" - /> - )} - - {/* Slide-out sidebar */} -