feat: complete Apple-quality UI/UX remake

Design System:
- Apple HIG typography scale (caption2 → large-title)
- 4px spacing grid (space-1 → space-16)
- 7 new CSS tokens across all 5 themes
- Interactive state CSS classes (hover-lift, btn-primary, focus-ring, etc.)
- Shimmer skeleton, slideDown, bounce-dot animations
- Light theme polish: deeper shadows, better accent contrast

Navigation:
- Lucide icons replacing emoji in sidebar
- Cmd+K global search command palette (agents, pages, crons)
- Breadcrumbs component for page context
- Sidebar wrapper orchestrating desktop + mobile
- Mobile hamburger menu with spring-animated slide-out
- Cron error dot badge on nav item

Manor Map + Agent Detail:
- Polished agent nodes: tinted squircle emoji, status dots with border ring
- Smooth bezier edges, selected agent edge highlighting
- Map legend (top-right), skeleton loading state
- Detail panel: hierarchy navigation, cron status, dual CTAs
- Agent detail: single-column scrollable layout, card sections
- SOUL.md viewer with copy button, voice ID copy

Chat:
- iMessage-style agent list with gradient avatars, online dots
- Desktop + mobile variants with responsive master/detail
- Sticky header with agent info, profile link, clear button
- Time gap timestamps between messages
- Typing indicator (bouncing dots) before first stream token
- Thin blinking cursor replacing block cursor
- Code blocks with copy button, URL auto-linking
- Rounded input capsule with SVG icons

Cron + Memory:
- Sticky header with summary counts, spinning refresh, auto-update timer
- Filter pills with ARIA tablist, keyboard cycling
- Expandable cron rows with colored borders, error tinting, copy
- Memory: 260px sidebar with search, file icons, arrow key nav
- Content header with breadcrumb path, copy + download buttons
- Mobile master/detail pattern with back button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JohnRiceML
2026-02-27 14:08:52 -06:00
co-authored by Claude Opus 4.6
parent 28c4269539
commit 32f5b403b0
20 changed files with 4933 additions and 1823 deletions
+683 -227
View File
@@ -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<string, { text: string; bg: string }> = {
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<string, string> = {
web_search: "🔍",
read: "📁",
write: "✏️",
exec: "💻",
web_fetch: "🌐",
message: "🔔",
tts: "💬",
};
function SoulViewer({ content }: { content: string }) {
const lines = content.split("\n");
return (
<div className="rounded-apple max-h-96 overflow-y-auto flex" style={{ background: 'var(--bg)' }}>
<div className="flex-shrink-0 px-3 py-4 select-none" style={{ borderRight: '1px solid var(--border-light)' }}>
{lines.map((_, i) => (
<div key={i} className="font-mono text-[11px] leading-relaxed text-right min-w-[2ch]" style={{ color: 'var(--text-tertiary)' }}>
{i + 1}
</div>
))}
</div>
<pre className="font-mono text-[12px] whitespace-pre-wrap leading-relaxed p-4 flex-1" style={{ color: 'var(--text-secondary)' }}>
{content}
</pre>
</div>
);
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<Agent | null>(null);
const [allAgents, setAllAgents] = useState<Agent[]>([]);
const [crons, setCrons] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(true);
function StatusDot({ status }: { status: CronJob["status"] }) {
return (
<span
className={status === "error" ? "animate-error-pulse" : ""}
style={{
display: "inline-block",
width: 6,
height: 6,
borderRadius: "50%",
flexShrink: 0,
background:
status === "ok"
? "var(--system-green)"
: status === "error"
? "var(--system-red)"
: "var(--text-tertiary)",
}}
/>
)
}
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 <div className="flex items-center justify-center h-full text-[15px] animate-pulse" style={{ color: 'var(--accent)' }}>Loading agent...</div>;
if (!agent) return <div className="flex items-center justify-center h-full text-[15px]" style={{ color: 'var(--text-secondary)' }}>Agent not found. <Link href="/" className="ml-1" style={{ color: 'var(--blue)' }}> Back</Link></div>;
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 (
<div className="h-full overflow-y-auto" style={{ background: 'var(--bg)' }}>
{/* Header */}
<div
style={{
background: "var(--bg)",
borderRadius: "var(--radius-md)",
overflow: "hidden",
position: "relative",
}}
>
<pre
style={{
fontFamily: "var(--font-mono)",
fontSize: "var(--text-caption1)",
whiteSpace: "pre-wrap",
lineHeight: 1.6,
padding: "var(--space-4)",
color: "var(--text-secondary)",
margin: 0,
maxHeight: 400,
overflowY: "auto",
}}
>
{content}
</pre>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: "var(--space-2)",
padding: "var(--space-2) var(--space-3)",
borderTop: "1px solid var(--separator)",
}}
>
<button
onClick={handleCopy}
className="focus-ring"
aria-label="Copy SOUL.md content"
style={{
background: "var(--fill-tertiary)",
color: "var(--text-secondary)",
border: "none",
borderRadius: "var(--radius-sm)",
padding: "var(--space-1) var(--space-3)",
fontSize: "var(--text-caption2)",
fontWeight: "var(--weight-medium)",
cursor: "pointer",
transition: "all 150ms var(--ease-spring)",
}}
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
)
}
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 (
<button
onClick={handleCopy}
className="focus-ring"
aria-label={label}
style={{
background: "var(--fill-tertiary)",
color: "var(--text-secondary)",
border: "none",
borderRadius: "var(--radius-sm)",
padding: "var(--space-1) var(--space-2)",
fontSize: "var(--text-caption2)",
fontWeight: "var(--weight-medium)",
cursor: "pointer",
transition: "all 150ms var(--ease-spring)",
flexShrink: 0,
}}
>
{copied ? "Copied" : "Copy"}
</button>
)
}
/* ──────────────────────────────────────────────
Card wrapper with consistent styling
────────────────────────────────────────────── */
function Card({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<div
className={className}
style={{
background: "var(--material-regular)",
border: "1px solid var(--separator)",
borderRadius: "var(--radius-lg)",
padding: "var(--space-5)",
boxShadow: "var(--shadow-card)",
}}
>
{children}
</div>
)
}
/* ──────────────────────────────────────────────
Loading skeleton for the detail page
────────────────────────────────────────────── */
function DetailSkeleton() {
return (
<div className="h-full overflow-y-auto" style={{ background: "var(--bg)" }}>
{/* Header skeleton */}
<div
className="sticky top-0 z-10 px-6 py-4 flex items-center justify-between"
style={{
background: 'var(--bg-elevated)',
borderTop: `3px solid ${agent.color}`,
boxShadow: `0 1px 0 var(--border)`,
background: "var(--material-regular)",
borderBottom: "1px solid var(--separator)",
}}
>
<Skeleton width={80} height={16} />
<Skeleton width={100} height={36} style={{ borderRadius: "var(--radius-md)" }} />
</div>
<div
style={{
maxWidth: 720,
margin: "0 auto",
padding: "var(--space-8) var(--space-6)",
display: "flex",
flexDirection: "column",
gap: "var(--space-5)",
}}
>
{/* Hero skeleton */}
<div className="flex items-center gap-4">
<Link href="/" className="hover:opacity-80 text-[15px] transition-opacity" style={{ color: 'var(--blue)' }}> Map</Link>
<div className="flex items-center gap-3">
<span className="text-[28px]">{agent.emoji}</span>
<div>
<span className="font-bold text-[20px] tracking-tight" style={{ color: 'var(--text-primary)' }}>{agent.name}</span>
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>{agent.title}</div>
</div>
<Skeleton
width={64}
height={64}
style={{ borderRadius: 16 }}
/>
<div className="flex flex-col gap-2">
<Skeleton width={140} height={22} />
<Skeleton width={200} height={14} />
</div>
</div>
<button
onClick={() => router.push(`/chat/${agent.id}`)}
className="font-semibold text-[15px] px-5 py-2.5 rounded-xl transition-colors"
style={{ background: 'var(--accent)', color: '#000' }}
{/* Card skeletons */}
{[1, 2, 3].map((i) => (
<Skeleton
key={i}
height={120}
style={{
width: "100%",
borderRadius: "var(--radius-lg)",
}}
/>
))}
</div>
</div>
)
}
/* ──────────────────────────────────────────────
Agent Detail Page
────────────────────────────────────────────── */
export default function AgentDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = use(params)
const router = useRouter()
const [agent, setAgent] = useState<Agent | null>(null)
const [allAgents, setAllAgents] = useState<Agent[]>([])
const [crons, setCrons] = useState<CronJob[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 <DetailSkeleton />
if (error) return <ErrorState message={error} onRetry={loadData} />
if (!agent) {
return (
<div
className="flex flex-col items-center justify-center h-full gap-3"
style={{ background: "var(--bg)" }}
>
<div
style={{
fontSize: "var(--text-headline)",
color: "var(--text-secondary)",
}}
>
Open Chat
</button>
Agent not found
</div>
<Link
href="/"
className="focus-ring"
style={{
color: "var(--system-blue)",
fontSize: "var(--text-body)",
}}
>
&larr; Back to Map
</Link>
</div>
)
}
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 (
<div className="h-full overflow-y-auto" style={{ background: "var(--bg)" }}>
{/* ── Sticky header ── */}
<div
className="sticky top-0 z-10"
style={{
background: "var(--material-regular)",
backdropFilter: "blur(20px) saturate(180%)",
WebkitBackdropFilter: "blur(20px) saturate(180%)",
borderBottom: "1px solid var(--separator)",
}}
>
{/* Color strip */}
<div style={{ height: 3, background: agent.color }} />
<div
className="flex items-center justify-between"
style={{ padding: "var(--space-3) var(--space-6)" }}
>
<Link
href="/"
className="focus-ring"
style={{
color: "var(--system-blue)",
fontSize: "var(--text-body)",
fontWeight: "var(--weight-medium)",
textDecoration: "none",
}}
>
&larr; Back to Map
</Link>
<button
onClick={() => router.push(`/chat/${agent.id}`)}
className="focus-ring"
aria-label={`Open chat with ${agent.name}`}
style={{
background: "var(--accent)",
color: "#000",
border: "none",
borderRadius: "var(--radius-md)",
padding: "var(--space-2) var(--space-5)",
fontSize: "var(--text-body)",
fontWeight: "var(--weight-semibold)",
cursor: "pointer",
transition: "all 150ms var(--ease-spring)",
}}
>
Open Chat &rarr;
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-5 p-6">
{/* Left column */}
<div className="col-span-1 space-y-4">
{/* About */}
{/* ── Content ── */}
<div
style={{
maxWidth: 720,
margin: "0 auto",
padding: "var(--space-8) var(--space-6)",
display: "flex",
flexDirection: "column",
gap: "var(--space-5)",
}}
>
{/* ── Hero section ── */}
<div className="flex items-start gap-4">
<div
className="relative overflow-hidden glass-card"
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--sidebar-border)',
borderRadius: 'var(--radius)',
padding: '1rem',
boxShadow: 'var(--shadow-sm), var(--inset-shine)',
width: 64,
height: 64,
borderRadius: 16,
background: `${agent.color}26`,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 32,
flexShrink: 0,
}}
>
<span
className="absolute -bottom-2 -right-1 text-[48px] opacity-[0.04] select-none pointer-events-none"
aria-hidden="true"
>
{agent.emoji}
</span>
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] mb-2" style={{ color: 'var(--text-tertiary)' }}>About</div>
<p className="text-[14px] leading-[1.6] relative" style={{ color: 'var(--text-secondary)' }}>{agent.description}</p>
{agent.emoji}
</div>
<div>
<h1
style={{
fontSize: "var(--text-title1)",
fontWeight: "var(--weight-bold)",
letterSpacing: "-0.5px",
color: "var(--text-primary)",
margin: 0,
lineHeight: 1.2,
}}
>
{agent.name}
</h1>
<p
style={{
fontSize: "var(--text-subheadline)",
color: "var(--text-secondary)",
margin: "2px 0 0",
}}
>
{agent.title}
</p>
{/* Color swatch */}
<div
style={{
display: "inline-block",
marginTop: "var(--space-2)",
width: 40,
height: 3,
borderRadius: 2,
background: agent.color,
}}
/>
</div>
</div>
{/* Tools */}
<div
className="glass-card"
{/* ── About card ── */}
<Card>
<div className="section-header" style={{ marginBottom: "var(--space-3)" }}>
About
</div>
<p
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--sidebar-border)',
borderRadius: 'var(--radius)',
padding: '1rem',
boxShadow: 'var(--shadow-sm), var(--inset-shine)',
fontSize: "var(--text-body)",
lineHeight: 1.65,
color: "var(--text-secondary)",
margin: 0,
}}
>
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] mb-2.5" style={{ color: 'var(--text-tertiary)' }}>Tools</div>
<div className="grid grid-cols-2 gap-1.5">
{agent.description}
</p>
</Card>
{/* ── Two-column: Tools + Hierarchy ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Tools card */}
<Card>
<div className="section-header" style={{ marginBottom: "var(--space-3)" }}>
Tools
</div>
<div className="flex flex-wrap gap-2">
{agent.tools.map((t) => (
<span
key={t}
className="inline-flex items-center gap-1.5 text-[11px] font-mono px-2.5 py-1 rounded-full"
style={{ background: 'var(--bg-fill-2)', color: 'var(--text-secondary)' }}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
background: "var(--fill-secondary)",
borderRadius: 8,
padding: "6px 12px",
fontSize: "var(--text-caption1)",
fontFamily: "var(--font-mono)",
color: "var(--text-secondary)",
}}
>
{TOOL_ICONS[t] && <span className="text-[10px]">{TOOL_ICONS[t]}</span>}
{TOOL_ICONS[t] && (
<span style={{ fontSize: "var(--text-caption2)" }}>
{TOOL_ICONS[t]}
</span>
)}
{t}
</span>
))}
</div>
</div>
</Card>
{/* Voice */}
<div
className="glass-card"
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--sidebar-border)',
borderRadius: 'var(--radius)',
padding: '1rem',
boxShadow: 'var(--shadow-sm), var(--inset-shine)',
}}
>
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] mb-2" style={{ color: 'var(--text-tertiary)' }}>Voice</div>
{agent.voiceId ? (
<div>
<span className="inline-block text-[12px] px-2.5 py-0.5 rounded-full mb-1" style={{ background: 'rgba(191,90,242,0.1)', color: 'var(--purple)', border: '1px solid rgba(191,90,242,0.2)' }}>ElevenLabs</span>
<div className="font-mono text-[11px] mt-1 break-all" style={{ color: 'var(--text-tertiary)' }}>{agent.voiceId}</div>
</div>
) : (
<span className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>No voice configured</span>
)}
</div>
{/* Hierarchy */}
<div
className="glass-card"
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--sidebar-border)',
borderRadius: 'var(--radius)',
padding: '1rem',
boxShadow: 'var(--shadow-sm), var(--inset-shine)',
}}
>
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] mb-2" style={{ color: 'var(--text-tertiary)' }}>Hierarchy</div>
{/* Hierarchy card */}
<Card>
<div className="section-header" style={{ marginBottom: "var(--space-3)" }}>
Hierarchy
</div>
{parent && (
<div className="mb-3">
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>Reports to</div>
<Link href={`/agents/${parent.id}`} className="flex items-center gap-2 text-[14px] transition-colors" style={{ color: 'var(--text-primary)' }}>
<div style={{ marginBottom: "var(--space-3)" }}>
<div
style={{
fontSize: "var(--text-caption2)",
color: "var(--text-tertiary)",
marginBottom: 2,
}}
>
Reports to
</div>
<Link
href={`/agents/${parent.id}`}
className="focus-ring"
style={{
display: "inline-flex",
alignItems: "center",
gap: "var(--space-2)",
fontSize: "var(--text-body)",
fontWeight: "var(--weight-medium)",
color: "var(--system-blue)",
textDecoration: "none",
}}
>
<span>{parent.emoji}</span>
<span className="font-medium">{parent.name}</span>
<span>{parent.name}</span>
<span style={{ color: "var(--text-tertiary)" }}>&rarr;</span>
</Link>
</div>
)}
{children.length > 0 && (
<div>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>Direct reports ({children.length})</div>
<div className="space-y-1">
<div
style={{
fontSize: "var(--text-caption2)",
color: "var(--text-tertiary)",
marginBottom: 2,
}}
>
Direct reports ({children.length})
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
{children.map((c) => (
<Link key={c.id} href={`/agents/${c.id}`} className="flex items-center gap-2 text-[14px] transition-colors" style={{ color: 'var(--text-primary)' }}>
<Link
key={c.id}
href={`/agents/${c.id}`}
className="focus-ring"
style={{
display: "inline-flex",
alignItems: "center",
gap: "var(--space-2)",
fontSize: "var(--text-body)",
fontWeight: "var(--weight-medium)",
color: "var(--system-blue)",
textDecoration: "none",
padding: "2px 0",
}}
>
<span>{c.emoji}</span>
<span className="font-medium">{c.name}</span>
<span>{c.name}</span>
<span style={{ color: "var(--text-tertiary)" }}>&rarr;</span>
</Link>
))}
</div>
</div>
)}
</div>
</div>
{/* Right column */}
<div className="col-span-2 space-y-4">
{/* SOUL.md */}
{agent.soul && (
<div
className="glass-card"
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--sidebar-border)',
borderRadius: 'var(--radius)',
padding: '1rem',
boxShadow: 'var(--shadow-sm), var(--inset-shine)',
}}
>
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] mb-3" style={{ color: 'var(--text-tertiary)' }}>SOUL.md</div>
<SoulViewer content={agent.soul} />
</div>
)}
{/* Crons */}
<div
className="glass-card"
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--sidebar-border)',
borderRadius: 'var(--radius)',
padding: '1rem',
boxShadow: 'var(--shadow-sm), var(--inset-shine)',
}}
>
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] mb-3" style={{ color: 'var(--text-tertiary)' }}>
Associated Crons {crons.length > 0 && `(${crons.length})`}
</div>
{crons.length === 0 ? (
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>No crons associated with this agent</div>
) : (
<div className="rounded-apple overflow-hidden">
{crons.map((c, i) => (
<div
key={c.id}
className="flex items-center px-4 py-3"
style={{
borderBottom: i < crons.length - 1 ? '1px solid var(--border-light)' : undefined,
background: c.status === "error" ? 'rgba(255,69,58,0.06)' : undefined,
}}
>
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${c.status === "ok" ? "bg-[#30d158]" : c.status === "error" ? "bg-[#ff453a] animate-error-pulse" : ""}`}
style={c.status === "idle" ? { background: 'var(--text-tertiary)' } : undefined}
/>
<span className="text-[14px] font-mono ml-3" style={{ color: 'var(--text-primary)' }}>{c.name}</span>
<span className="ml-auto text-[12px] font-mono" style={{ color: 'var(--text-secondary)' }}>{c.schedule}</span>
<span
className="ml-3 px-2 py-0.5 rounded-full text-[11px]"
style={{ color: statusColors[c.status]?.text, background: statusColors[c.status]?.bg }}
>
{c.status}
</span>
<span className="ml-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{timeAgo(c.nextRun)}</span>
</div>
))}
{!parent && children.length === 0 && (
<div
style={{
fontSize: "var(--text-footnote)",
color: "var(--text-tertiary)",
}}
>
No hierarchy connections
</div>
)}
</div>
</Card>
</div>
{/* ── SOUL.md card ── */}
{agent.soul && (
<Card>
<div className="section-header" style={{ marginBottom: "var(--space-3)" }}>
SOUL.md
</div>
<SoulViewer content={agent.soul} />
</Card>
)}
{/* ── Crons card ── */}
<Card>
<div
className="section-header"
style={{
marginBottom: "var(--space-3)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span>Crons {crons.length > 0 && `(${crons.length})`}</span>
</div>
{crons.length === 0 ? (
<div
style={{
fontSize: "var(--text-footnote)",
color: "var(--text-tertiary)",
}}
>
No crons associated with this agent
</div>
) : (
<div
style={{
borderRadius: "var(--radius-md)",
overflow: "hidden",
border: "1px solid var(--separator)",
}}
>
{crons.map((c, idx) => (
<div
key={c.id}
style={{
display: "flex",
alignItems: "center",
gap: "var(--space-2)",
minHeight: 44,
padding: "0 var(--space-3)",
borderTop: idx > 0 ? "1px solid var(--separator)" : undefined,
background:
c.status === "error" ? "rgba(255,69,58,0.06)" : undefined,
}}
>
<StatusDot status={c.status} />
<span
style={{
fontSize: "var(--text-body)",
fontFamily: "var(--font-mono)",
fontWeight: "var(--weight-medium)",
color: "var(--text-primary)",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{c.name}
</span>
<span
style={{
fontSize: "var(--text-caption1)",
fontFamily: "var(--font-mono)",
color: "var(--text-tertiary)",
flexShrink: 0,
}}
>
{c.schedule}
</span>
<span
style={{
fontSize: "var(--text-caption2)",
fontWeight: "var(--weight-medium)",
padding: "2px 8px",
borderRadius: 20,
flexShrink: 0,
background:
c.status === "ok"
? "rgba(48,209,88,0.1)"
: c.status === "error"
? "rgba(255,69,58,0.1)"
: "rgba(120,120,128,0.1)",
color:
c.status === "ok"
? "var(--system-green)"
: c.status === "error"
? "var(--system-red)"
: "var(--text-secondary)",
}}
>
{c.status}
</span>
</div>
))}
</div>
)}
{crons.length > 0 && (
<div style={{ textAlign: "right", marginTop: "var(--space-3)" }}>
<Link
href="/crons"
className="focus-ring"
style={{
fontSize: "var(--text-footnote)",
color: "var(--system-blue)",
textDecoration: "none",
fontWeight: "var(--weight-medium)",
}}
>
View all crons &rarr;
</Link>
</div>
)}
</Card>
{/* ── Voice card ── */}
<Card>
<div className="section-header" style={{ marginBottom: "var(--space-3)" }}>
Voice
</div>
{agent.voiceId ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: "var(--space-3)",
}}
>
<span
style={{
display: "inline-block",
padding: "2px 10px",
borderRadius: 20,
fontSize: "var(--text-caption1)",
fontWeight: "var(--weight-medium)",
background: "rgba(191,90,242,0.1)",
color: "var(--system-purple)",
border: "1px solid rgba(191,90,242,0.2)",
flexShrink: 0,
}}
>
ElevenLabs
</span>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: "var(--text-caption2)",
color: "var(--text-tertiary)",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{agent.voiceId}
</span>
<CopyButton text={agent.voiceId} label="Copy voice ID" />
</div>
) : (
<div
style={{
fontSize: "var(--text-footnote)",
color: "var(--text-tertiary)",
}}
>
No voice configured
</div>
)}
</Card>
</div>
</div>
);
)
}
+111 -22
View File
@@ -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<ConversationStore>({})
const [activeAgentId, setActiveAgentId] = useState<string | null>(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 (
<div style={{ display: 'flex', height: '100%', background: 'var(--bg)' }}>
{/* Desktop sidebar — always visible on md+ */}
<AgentList
agents={agents}
conversations={conversations}
@@ -80,32 +90,111 @@ function MessengerApp() {
loading={loading}
/>
{activeAgent && conversations[activeAgent.id] ? (
<ConversationView
key={activeAgent.id}
agent={activeAgent}
conversation={conversations[activeAgent.id]}
onUpdate={handleConversationUpdate}
/>
) : (
<div style={{
{/* Mobile agent list — shown when no conversation selected */}
<div
className="md:hidden"
style={{
display: mobileShowConversation ? 'none' : 'flex',
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg)',
gap: 12,
}}>
<div style={{ fontSize: 48 }}>&#127984;</div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.3px' }}>Manor Messages</div>
<div style={{ fontSize: 15, color: 'var(--text-secondary)' }}>Select an agent to start chatting</div>
height: '100%',
}}
>
<AgentListMobile
agents={agents}
conversations={conversations}
onSelect={handleSelectAgent}
loading={loading}
/>
</div>
{/* Desktop conversation view — visible when agent selected on md+ */}
<div
className="hidden md:flex"
style={{ flex: 1, flexDirection: 'column', height: '100%' }}
>
{activeAgent && conversations[activeAgent.id] ? (
<ConversationView
key={activeAgent.id}
agent={activeAgent}
conversation={conversations[activeAgent.id]}
onUpdate={handleConversationUpdate}
/>
) : (
<EmptyState />
)}
</div>
{/* Mobile conversation view — shown full width when agent selected */}
{mobileShowConversation && activeAgent && conversations[activeAgent.id] && (
<div
className="md:hidden"
style={{
position: 'fixed',
inset: 0,
zIndex: 20,
display: 'flex',
flexDirection: 'column',
background: 'var(--bg)',
}}
>
<ConversationView
key={activeAgent.id}
agent={activeAgent}
conversation={conversations[activeAgent.id]}
onUpdate={handleConversationUpdate}
onBack={handleMobileBack}
/>
</div>
)}
</div>
)
}
function EmptyState() {
return (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg)',
gap: 'var(--space-3)',
padding: 'var(--space-8)',
}}>
<div style={{ fontSize: 48, marginBottom: 'var(--space-2)' }}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</div>
<div style={{
fontSize: 'var(--text-title3)',
fontWeight: 'var(--weight-bold)',
color: 'var(--text-primary)',
letterSpacing: '-0.3px',
}}>
Manor Messages
</div>
<div style={{
fontSize: 'var(--text-subheadline)',
color: 'var(--text-secondary)',
textAlign: 'center',
lineHeight: 'var(--leading-relaxed)',
}}>
Select an agent from the sidebar to start chatting
</div>
<div style={{
fontSize: 'var(--text-caption1)',
color: 'var(--text-quaternary)',
marginTop: 'var(--space-2)',
}}>
Press Cmd+K to search agents
</div>
</div>
)
}
export default function ChatPage() {
return (
<Suspense>
+514 -216
View File
@@ -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<string, string> = {
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<CronJob[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
@@ -51,39 +71,57 @@ export default function CronsPage() {
const [expanded, setExpanded] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [updatedAgo, setUpdatedAgo] = useState("just now");
const [copiedId, setCopiedId] = useState<string | null>(null);
/* Filter pill keyboard navigation */
const pillsRef = useRef<HTMLDivElement>(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<string, number> = { 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<HTMLButtonElement>('[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 <ErrorState message={`Failed to load crons: ${error}`} onRetry={refresh} />;
return <ErrorState message={error} onRetry={refresh} />;
}
return (
<div
className="h-full flex flex-col overflow-hidden"
className="h-full flex flex-col overflow-hidden animate-fade-in"
style={{ background: "var(--bg)" }}
>
{/* Header */}
<div
className="sticky top-0 z-10 flex-shrink-0 px-6 flex items-center justify-between"
{/* ── Sticky header ──────────────────────────────────────── */}
<header
className="sticky top-0 z-10 flex-shrink-0"
style={{
height: 64,
background: "var(--material-regular)",
backdropFilter: "blur(40px) saturate(180%)",
WebkitBackdropFilter: "blur(40px) saturate(180%)",
borderBottom: "1px solid var(--separator)",
}}
>
<div className="flex items-center gap-3">
<h1
className="text-[28px] font-bold"
style={{
color: "var(--text-primary)",
letterSpacing: "-0.5px",
}}
>
Cron Monitor
</h1>
<span
className="text-[13px] font-medium rounded-full px-2.5 py-0.5"
style={{
background: "var(--fill-secondary)",
color: "var(--text-secondary)",
}}
>
{crons.length}
</span>
</div>
<div className="flex items-center gap-3">
<span
className="text-[12px]"
style={{ color: "var(--text-tertiary)" }}
>
Updated {timeAgo(lastRefresh.toISOString())}
</span>
<button
onClick={refresh}
className="hover:opacity-80 transition-opacity text-[16px]"
style={{ color: "var(--text-tertiary)", background: "none", border: "none", cursor: "pointer" }}
aria-label="Refresh cron data"
>
&#8635;
</button>
</div>
</div>
{/* Filter pills */}
<div className="px-6 py-3 flex items-center gap-2 overflow-x-auto flex-shrink-0" role="tablist" aria-label="Filter cron jobs by status">
{pills.map((pill) => {
const isActive = filter === pill.key;
return (
<button
key={pill.key}
onClick={() => setFilter(pill.key)}
role="tab"
aria-selected={isActive}
className="flex items-center gap-2 flex-shrink-0"
<div
className="flex items-center justify-between"
style={{ padding: "var(--space-4) var(--space-6)" }}
>
{/* Left: title + summary */}
<div>
<h1
style={{
borderRadius: 20,
padding: "6px 14px",
fontSize: 13,
fontWeight: 500,
border: "none",
cursor: "pointer",
transition: "all 200ms var(--ease-smooth)",
...(isActive
? {
background: "var(--accent-fill)",
color: "var(--accent)",
boxShadow: "0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent)",
}
: {
background: "var(--fill-secondary)",
color: "var(--text-primary)",
}),
fontSize: "var(--text-title1)",
fontWeight: "var(--weight-bold)",
color: "var(--text-primary)",
letterSpacing: "-0.5px",
lineHeight: "var(--leading-tight)",
}}
>
<span
className={`w-[6px] h-[6px] rounded-full flex-shrink-0 ${
pill.key === "error" && counts.error > 0
? "animate-error-pulse"
: ""
}`}
style={{ background: pill.dotColor }}
/>
<span>{pill.label}</span>
<span
className="font-semibold"
Cron Monitor
</h1>
{!loading && (
<p
style={{
color: isActive ? "var(--accent)" : "var(--text-secondary)",
fontSize: "var(--text-footnote)",
color: "var(--text-secondary)",
marginTop: "var(--space-1)",
}}
>
{counts[pill.key]}
</span>
</button>
);
})}
</div>
{counts.all} job{counts.all !== 1 ? "s" : ""}
{counts.error > 0 && (
<span style={{ color: "var(--system-red)" }}>
{" \u00b7 "}{counts.error} error{counts.error !== 1 ? "s" : ""}
</span>
)}
{" \u00b7 "}{counts.ok} ok
</p>
)}
</div>
{/* Cron list */}
<div className="flex-1 overflow-y-auto px-6 pb-6">
{/* Right: updated label + refresh */}
<div className="flex items-center" style={{ gap: "var(--space-3)" }}>
<span
style={{
fontSize: "var(--text-caption1)",
color: "var(--text-tertiary)",
}}
>
Updated {updatedAgo}
</span>
<button
onClick={refresh}
className="focus-ring"
aria-label="Refresh cron data"
style={{
width: 32,
height: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "var(--radius-sm)",
border: "none",
background: "transparent",
color: "var(--text-tertiary)",
cursor: "pointer",
transition: "color 150ms var(--ease-smooth)",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={refreshing ? "animate-spin" : ""}
>
<path d="M1.5 8a6.5 6.5 0 0 1 11.48-4.17" />
<path d="M14.5 8a6.5 6.5 0 0 1-11.48 4.17" />
<polyline points="1.5 1.5 1.5 4 4 4" />
<polyline points="14.5 14.5 14.5 12 12 12" />
</svg>
</button>
</div>
</div>
{/* ── Filter pills ─────────────────────────────────────── */}
<div
ref={pillsRef}
role="tablist"
aria-label="Filter cron jobs by status"
onKeyDown={handlePillKeyDown}
className="flex items-center overflow-x-auto flex-shrink-0"
style={{
padding: "0 var(--space-6) var(--space-3)",
gap: "var(--space-2)",
}}
>
{PILLS.map((pill) => {
const isActive = filter === pill.key;
return (
<button
key={pill.key}
role="tab"
aria-selected={isActive}
tabIndex={isActive ? 0 : -1}
onClick={() => setFilter(pill.key)}
className="focus-ring flex items-center flex-shrink-0"
style={{
borderRadius: 20,
padding: "6px 14px",
fontSize: "var(--text-footnote)",
fontWeight: "var(--weight-medium)",
border: "none",
cursor: "pointer",
gap: "var(--space-2)",
transition: "all 200ms var(--ease-smooth)",
...(isActive
? {
background: "var(--accent-fill)",
color: "var(--accent)",
boxShadow:
"0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent)",
}
: {
background: "var(--fill-secondary)",
color: "var(--text-primary)",
}),
}}
>
<span
className={`flex-shrink-0 rounded-full ${
pill.key === "error" && counts.error > 0
? "animate-error-pulse"
: ""
}`}
style={{
width: 6,
height: 6,
background: pill.dotColor,
}}
/>
<span>{pill.label}</span>
<span
style={{
fontWeight: "var(--weight-semibold)",
color: isActive ? "var(--accent)" : "var(--text-secondary)",
}}
>
{counts[pill.key]}
</span>
</button>
);
})}
</div>
</header>
{/* ── Cron list ──────────────────────────────────────────── */}
<div
className="flex-1 overflow-y-auto"
style={{ padding: "var(--space-4) var(--space-6) var(--space-6)" }}
>
{loading ? (
<div role="status" aria-label="Loading cron jobs" style={{
borderRadius: "var(--radius-md)",
overflow: "hidden",
background: "var(--material-regular)",
padding: "8px 16px",
}}>
/* ── Loading skeleton ─────────────────────────────────── */
<div
style={{
borderRadius: "var(--radius-md)",
overflow: "hidden",
background: "var(--material-regular)",
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-3" style={{
minHeight: 44,
borderTop: i > 1 ? "1px solid var(--separator)" : undefined,
padding: "8px 0",
}}>
<Skeleton className="rounded-full" style={{ width: 8, height: 8, flexShrink: 0 }} />
<Skeleton style={{ width: "35%", height: 14 }} />
<div className="ml-auto flex items-center gap-3">
<Skeleton style={{ width: 60, height: 12 }} />
<Skeleton style={{ width: 70, height: 12 }} />
<div
key={i}
className="flex items-center"
style={{
padding: "var(--space-3) var(--space-4)",
borderBottom:
i < 5 ? "1px solid var(--separator)" : undefined,
gap: "var(--space-3)",
}}
>
<Skeleton
className="flex-shrink-0"
style={{ width: 8, height: 8, borderRadius: "50%" }}
/>
<Skeleton style={{ width: 180, height: 14 }} />
<div className="ml-auto flex items-center" style={{ gap: "var(--space-3)" }}>
<Skeleton style={{ width: 48, height: 12 }} />
<Skeleton style={{ width: 64, height: 12 }} />
</div>
</div>
))}
</div>
) : filtered.length === 0 ? (
/* ── Empty state ──────────────────────────────────────── */
<div
className="flex items-center justify-center h-32 text-[15px]"
style={{ color: "var(--text-secondary)" }}
className="flex flex-col items-center justify-center"
style={{
height: 200,
color: "var(--text-secondary)",
gap: "var(--space-2)",
}}
>
No crons match this filter
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--text-tertiary)", marginBottom: "var(--space-2)" }}
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span style={{ fontSize: "var(--text-subheadline)", fontWeight: "var(--weight-medium)" }}>
{crons.length === 0
? "No cron jobs found"
: "No crons match this filter"}
</span>
<span style={{ fontSize: "var(--text-footnote)", color: "var(--text-tertiary)" }}>
{crons.length === 0
? "Cron jobs will appear here once configured"
: "Try selecting a different status filter"}
</span>
</div>
) : (
/* ── Cron rows ───────────────────────────────────────── */
<div
style={{
borderRadius: "var(--radius-md)",
@@ -267,120 +430,157 @@ export default function CronsPage() {
: null;
const isExpanded = expanded === cron.id;
const isError = cron.status === "error";
const isFirst = idx === 0;
const isOverdue =
cron.nextRun && nextRunLabel(cron.nextRun) === "overdue";
return (
<div key={cron.id}>
{/* Separator between rows (not on first) */}
{!isFirst && (
{/* Separator */}
{idx > 0 && (
<div
style={{
height: 1,
background: "var(--separator)",
marginLeft: 16,
marginRight: 16,
marginLeft: "var(--space-4)",
marginRight: "var(--space-4)",
}}
/>
)}
{/* Row */}
{/* Collapsed row */}
<div
role="button"
tabIndex={0}
aria-expanded={isExpanded}
aria-label={`${cron.name}, status ${cron.status}${
agent ? `, agent ${agent.name}` : ""
}`}
onClick={() =>
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 */}
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${
cron.status === "error" && counts.error > 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 */}
<span
className="text-[15px] font-medium ml-3 truncate"
style={{ color: "var(--text-primary)" }}
{/* Name + agent (mobile stacked) */}
<div
className="ml-3 min-w-0 flex-1"
style={{ display: "flex", flexDirection: "column" }}
>
{cron.name}
</span>
<span
className="truncate"
style={{
fontSize: "var(--text-footnote)",
fontWeight: "var(--weight-semibold)",
color: "var(--text-primary)",
}}
>
{cron.name}
</span>
{/* Agent name under cron name on mobile */}
{agent && (
<Link
href={`/chat/${agent.id}`}
onClick={(e) => 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}
</Link>
)}
</div>
{/* Right side: agent link, schedule, chevron */}
<div className="ml-auto flex items-center gap-3 flex-shrink-0">
{/* Right side: agent, schedule, chevron */}
<div
className="ml-auto flex items-center flex-shrink-0"
style={{ gap: "var(--space-3)" }}
>
{/* Agent (desktop) */}
{agent ? (
<Link
href={`/chat/${agent.id}`}
onClick={(e) => 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}
</Link>
) : (
<span
className="text-[13px]"
style={{ color: "var(--text-tertiary)" }}
className="hidden md:inline"
style={{
fontSize: "var(--text-caption1)",
color: "var(--text-tertiary)",
}}
>
{"\u2014"}
</span>
)}
{/* Schedule */}
{/* Schedule (hidden on mobile) */}
<span
className="text-[12px] font-mono"
style={{ color: "var(--text-secondary)" }}
className="hidden md:inline font-mono"
style={{
fontSize: "var(--text-caption1)",
color: "var(--text-tertiary)",
}}
>
{cron.schedule}
</span>
{/* Chevron */}
<span
className="text-[13px] transition-transform"
aria-hidden="true"
style={{
fontSize: "var(--text-footnote)",
color: "var(--text-tertiary)",
transition: "transform 200ms var(--ease-smooth)",
transform: isExpanded
? "rotate(90deg)"
: "rotate(0deg)",
display: "inline-block",
}}
aria-hidden="true"
>
&#8250;
</span>
@@ -389,51 +589,149 @@ export default function CronsPage() {
{/* Expanded detail */}
{isExpanded && (
<div style={{ padding: "0 16px 12px 16px" }}>
<div
className="animate-slide-down"
style={{
padding: "0 var(--space-4) var(--space-4) var(--space-4)",
marginLeft: 3, /* align with border-left offset */
}}
>
{/* Detail grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "auto 1fr",
gap: "var(--space-1) var(--space-4)",
marginTop: "var(--space-2)",
marginBottom: "var(--space-3)",
}}
>
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>
Last run
</span>
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-secondary)" }}>
{timeAgo(cron.lastRun)}
</span>
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>
Next run
</span>
<span
style={{
fontSize: "var(--text-caption1)",
color: isOverdue
? "var(--system-orange)"
: "var(--text-secondary)",
fontWeight: isOverdue ? "var(--weight-semibold)" : undefined,
}}
>
{nextRunLabel(cron.nextRun)}
</span>
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>
Status
</span>
<span
style={{
fontSize: "var(--text-caption1)",
color:
cron.status === "error"
? "var(--system-red)"
: cron.status === "ok"
? "var(--system-green)"
: "var(--text-secondary)",
fontWeight: "var(--weight-medium)",
textTransform: "capitalize",
}}
>
{cron.status}
</span>
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>
Schedule
</span>
<span
className="font-mono"
style={{ fontSize: "var(--text-caption1)", color: "var(--text-secondary)" }}
>
{cron.schedule}
</span>
</div>
{/* Error box */}
{cron.lastError && (
<div
className="mt-2 px-4 py-3"
role="alert"
style={{
borderRadius: "var(--radius-sm)",
background: "rgba(255,69,58,0.06)",
borderLeft:
"3px solid var(--system-red)",
background: "var(--code-bg)",
border: "1px solid var(--code-border)",
padding: "var(--space-3)",
marginBottom: "var(--space-3)",
}}
>
<pre
className="text-[13px] font-mono whitespace-pre-wrap"
style={{ color: "var(--system-red)" }}
<div
className="flex items-start justify-between"
style={{ gap: "var(--space-2)" }}
>
{cron.lastError}
</pre>
<pre
className="font-mono"
style={{
fontSize: "var(--text-caption1)",
color: "var(--system-red)",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
margin: 0,
flex: 1,
lineHeight: "var(--leading-relaxed)",
}}
>
{cron.lastError}
</pre>
<button
onClick={(e) => {
e.stopPropagation();
copyError(cron.id, cron.lastError!);
}}
className="btn-ghost focus-ring flex-shrink-0"
aria-label="Copy error text"
style={{
padding: "4px 10px",
borderRadius: "var(--radius-sm)",
fontSize: "var(--text-caption2)",
fontWeight: "var(--weight-medium)",
}}
>
{copiedId === cron.id ? "Copied" : "Copy"}
</button>
</div>
</div>
)}
<div className="mt-2 flex flex-wrap gap-x-6 gap-y-1">
<span
className="text-[12px]"
style={{
color: "var(--text-tertiary)",
}}
>
Last run: {timeAgo(cron.lastRun)}
</span>
<span
className="text-[12px]"
style={{
color: "var(--text-tertiary)",
}}
>
Next run: {nextRunLabel(cron.nextRun)}
</span>
<span
className="text-[12px] font-mono"
style={{
color: "var(--text-tertiary)",
}}
>
ID: {cron.id}
</span>
{/* Actions */}
<div className="flex items-center" style={{ gap: "var(--space-2)" }}>
{agent && (
<Link
href={`/chat/${agent.id}`}
className="btn-ghost focus-ring"
aria-label={`Chat with ${agent.name}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "var(--space-1)",
padding: "6px 12px",
borderRadius: "var(--radius-sm)",
fontSize: "var(--text-caption1)",
fontWeight: "var(--weight-medium)",
textDecoration: "none",
color: "var(--system-blue)",
}}
>
Chat with {agent.name}
<span aria-hidden="true" style={{ fontSize: "var(--text-caption1)" }}>
{"\u2192"}
</span>
</Link>
)}
</div>
</div>
)}
+218 -12
View File
@@ -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;
}
}
+20 -80
View File
@@ -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 (
<html lang="en" data-theme="dark" suppressHydrationWarning>
<body>
<ThemeProvider>
<div className="flex h-screen overflow-hidden" style={{ background: 'var(--bg)' }}>
{/* Desktop sidebar — hidden on mobile */}
<aside
className="hidden md:flex w-[220px] flex-shrink-0 flex-col"
style={{
background: 'var(--sidebar-bg)',
backdropFilter: 'var(--sidebar-backdrop)',
WebkitBackdropFilter: 'var(--sidebar-backdrop)',
borderRight: '1px solid var(--separator)',
}}
>
{/* App icon + title */}
<div className="px-4 pt-5 pb-3">
<div className="flex items-center gap-3">
<div
className="w-9 h-9 flex items-center justify-center text-lg"
style={{
borderRadius: '10px',
background: 'linear-gradient(135deg, #f5c518, #e8b800)',
boxShadow: 'var(--shadow-card)',
}}
>
🏰
</div>
<div>
<div style={{
fontSize: '17px',
fontWeight: 600,
letterSpacing: '-0.3px',
color: 'var(--text-primary)',
}}>
Manor
</div>
<div style={{
fontSize: '12px',
color: 'var(--text-secondary)',
letterSpacing: '0.01em',
}}>
Command Centre
</div>
</div>
</div>
</div>
<NavLinks />
<ThemeToggle />
</aside>
{/* Mobile sidebar */}
<MobileSidebar />
<div
className="flex h-screen overflow-hidden"
style={{ background: 'var(--bg)' }}
>
{/* Client-side shell handles both desktop sidebar + mobile */}
<Sidebar />
{/* Main content */}
<main className="flex-1 overflow-hidden relative">
{/* Glass background orbs — only visible in glass theme */}
<div className="pointer-events-none fixed inset-0 overflow-hidden glass-orbs" aria-hidden="true">
<div style={{
position: 'absolute', top: '15%', left: '20%',
width: 400, height: 400, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(139,92,246,0.12) 0%, transparent 70%)',
filter: 'blur(40px)',
}} />
<div style={{
position: 'absolute', top: '55%', right: '15%',
width: 320, height: 320, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(245,197,24,0.08) 0%, transparent 70%)',
filter: 'blur(40px)',
}} />
<div style={{
position: 'absolute', bottom: '20%', left: '40%',
width: 280, height: 280, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(59,158,255,0.09) 0%, transparent 70%)',
filter: 'blur(40px)',
}} />
</div>
{/* Mobile spacer for fixed header */}
<div className="md:hidden" style={{ height: '48px', flexShrink: 0 }} />
{children}
</main>
</div>
+514 -153
View File
@@ -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 (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: isJson ? "var(--system-blue)" : "var(--text-tertiary)", flexShrink: 0 }}
>
{isJson ? (
/* clipboard icon for JSON */
<>
<rect x="4" y="2" width="8" height="12" rx="1.5" />
<path d="M6 2V1.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V2" />
<line x1="6.5" y1="6" x2="9.5" y2="6" />
<line x1="6.5" y1="8.5" x2="9.5" y2="8.5" />
<line x1="6.5" y1="11" x2="8" y2="11" />
</>
) : (
/* document icon for MD */
<>
<path d="M4 1.5h5.5L12 4v9.5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-12a1 1 0 0 1 1-1z" />
<polyline points="9.5 1.5 9.5 4.5 12 4.5" />
<line x1="5.5" y1="7.5" x2="10.5" y2="7.5" />
<line x1="5.5" y1="10" x2="10.5" y2="10" />
</>
)}
</svg>
);
}
function FolderIcon() {
return (
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--text-tertiary)" }}
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
);
}
function BackArrow() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="10 3 5 8 10 13" />
</svg>
);
}
/* ─── Component ─────────────────────────────────────────────────── */
export default function MemoryPage() {
const [files, setFiles] = useState<MemoryFile[]>([]);
const [selected, setSelected] = useState<MemoryFile | null>(null);
const [loading, setLoading] = useState(true);
const contentRef = useRef<HTMLDivElement>(null);
const fileListRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [copied, setCopied] = useState(false);
const [mobileShowContent, setMobileShowContent] = useState(false);
function refresh() {
const listRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(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<HTMLButtonElement>('[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 <ErrorState message={error} onRetry={refresh} />;
}
/* ─── 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 = (
<div
style={{
background: "var(--fill-secondary)",
background: "var(--code-bg)",
border: "1px solid var(--code-border)",
borderRadius: "var(--radius-md)",
padding: 16,
padding: "var(--space-4)",
overflow: "auto",
}}
>
<div className="flex">
{/* Line numbers */}
<div
className="flex-shrink-0 pr-4 mr-4 select-none"
className="flex-shrink-0 select-none"
style={{
paddingRight: "var(--space-4)",
marginRight: "var(--space-4)",
borderRight: "1px solid var(--separator)",
}}
>
{lines.map((_, i) => (
<div
key={i}
className="font-mono text-[11px] leading-relaxed text-right min-w-[2.5ch]"
style={{ color: "var(--text-tertiary)" }}
className="font-mono text-right"
style={{
fontSize: "var(--text-caption2)",
lineHeight: "var(--leading-relaxed)",
color: "var(--text-tertiary)",
minWidth: "2.5ch",
}}
>
{i + 1}
</div>
))}
</div>
{/* Syntax highlighted content */}
{/* JSON content */}
<pre
className="font-mono text-[13px] whitespace-pre-wrap leading-relaxed flex-1"
style={{ color: "var(--text-secondary)" }}
className="font-mono flex-1"
style={{
fontSize: "var(--text-footnote)",
lineHeight: "var(--leading-relaxed)",
color: "var(--code-text)",
whiteSpace: "pre-wrap",
margin: 0,
}}
dangerouslySetInnerHTML={{
__html: colorizeJson(pretty),
}}
@@ -129,14 +290,20 @@ export default function MemoryPage() {
renderedContent = (
<div
style={{
background: "var(--fill-secondary)",
background: "var(--code-bg)",
border: "1px solid var(--code-border)",
borderRadius: "var(--radius-md)",
padding: 16,
padding: "var(--space-4)",
}}
>
<pre
className="font-mono text-[13px] whitespace-pre-wrap"
style={{ color: "var(--system-red)" }}
className="font-mono"
style={{
fontSize: "var(--text-footnote)",
color: "var(--system-red)",
whiteSpace: "pre-wrap",
margin: 0,
}}
>
{selected.content}
</pre>
@@ -146,8 +313,11 @@ export default function MemoryPage() {
} else {
renderedContent = (
<div
className="text-[15px] leading-[1.7]"
style={{ color: "var(--text-secondary)" }}
style={{
fontSize: "var(--text-subheadline)",
lineHeight: "var(--leading-relaxed)",
color: "var(--text-secondary)",
}}
dangerouslySetInnerHTML={{
__html: `<p class="mb-3" style="color:var(--text-secondary)">${renderMarkdown(selected.content)}</p>`,
}}
@@ -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 (
<div className="flex h-full" style={{ background: "var(--bg)" }}>
{/* Sidebar */}
<div
className="w-[240px] flex-shrink-0 flex flex-col"
<div
className="flex h-full animate-fade-in"
style={{ background: "var(--bg)" }}
>
{/* ── File list sidebar ──────────────────────────────────── */}
<aside
className={`flex-shrink-0 flex flex-col ${
mobileShowContent && selected ? "hidden md:flex" : "flex"
}`}
style={{
width: "100%",
maxWidth: "100%",
background: "var(--material-regular)",
backdropFilter: "var(--sidebar-backdrop)",
WebkitBackdropFilter: "var(--sidebar-backdrop)",
borderRight: "1px solid var(--separator)",
}}
>
<style>{`@media (min-width: 768px) { aside { width: 260px !important; min-width: 260px !important; } }`}</style>
{/* Sidebar header */}
<div
className="flex items-center justify-between flex-shrink-0"
style={{
padding: "12px 16px",
padding: "var(--space-3) var(--space-4)",
borderBottom: "1px solid var(--separator)",
}}
>
<span
className="text-[17px] font-semibold"
style={{ color: "var(--text-primary)" }}
style={{
fontSize: "var(--text-body)",
fontWeight: "var(--weight-semibold)",
color: "var(--text-primary)",
}}
>
Memory
</span>
<button
onClick={refresh}
className="hover:opacity-80 transition-opacity text-[16px]"
style={{ color: "var(--text-tertiary)", background: "none", border: "none", cursor: "pointer" }}
aria-label="Refresh memory files"
className="btn-ghost focus-ring"
aria-label="Refresh file list"
style={{
width: 28,
height: 28,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "var(--radius-sm)",
padding: 0,
}}
>
&#8635;
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--text-tertiary)" }}
>
<path d="M1.5 8a6.5 6.5 0 0 1 11.48-4.17" />
<path d="M14.5 8a6.5 6.5 0 0 1-11.48 4.17" />
<polyline points="1.5 1.5 1.5 4 4 4" />
<polyline points="14.5 14.5 14.5 12 12 12" />
</svg>
</button>
</div>
{/* Search */}
<div style={{ padding: "var(--space-2) var(--space-3)" }}>
<input
ref={searchRef}
type="search"
placeholder="Search files..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="apple-input focus-ring"
aria-label="Search memory files"
style={{
width: "100%",
height: 32,
fontSize: "var(--text-footnote)",
padding: "0 var(--space-3)",
borderRadius: "var(--radius-sm)",
}}
/>
</div>
{/* File list */}
<div
ref={fileListRef}
className="flex-1 overflow-y-auto"
ref={listRef}
role="listbox"
aria-label="Memory files"
tabIndex={0}
onKeyDown={handleFileListKeyDown}
onKeyDown={handleListKeyDown}
className="flex-1 overflow-y-auto"
>
{loading ? (
<div className="p-4 flex flex-col gap-2" role="status" aria-label="Loading files">
/* Skeleton rows */
<div style={{ padding: "var(--space-2) var(--space-3)" }}>
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex flex-col gap-1.5" style={{ padding: "4px 0" }}>
<Skeleton style={{ width: "75%", height: 14 }} />
<Skeleton style={{ width: "40%", height: 10 }} />
<div
key={i}
style={{
padding: "var(--space-3) var(--space-3)",
display: "flex",
alignItems: "flex-start",
gap: "var(--space-2)",
}}
>
<Skeleton
className="flex-shrink-0"
style={{ width: 16, height: 16, borderRadius: 4 }}
/>
<div style={{ flex: 1 }}>
<Skeleton style={{ width: "80%", height: 13, marginBottom: 6 }} />
<Skeleton style={{ width: "50%", height: 10 }} />
</div>
</div>
))}
</div>
) : filteredFiles.length === 0 ? (
<div
className="flex items-center justify-center"
style={{
height: 120,
fontSize: "var(--text-footnote)",
color: "var(--text-tertiary)",
}}
>
No files match
</div>
) : (
files.map((file) => {
filteredFiles.map((file) => {
const isActive = selected?.path === file.path;
const json = isJsonFile(file);
return (
<button
key={file.path}
onClick={() => setSelected(file)}
role="option"
aria-selected={isActive}
className="w-full text-left transition-colors"
onClick={() => selectFile(file)}
className="w-full text-left hover-bg focus-ring"
style={{
height: 52,
padding: "12px 16px",
display: "flex",
alignItems: "flex-start",
gap: "var(--space-2)",
padding: "var(--space-3) var(--space-3)",
border: "none",
cursor: "pointer",
background: isActive
? "var(--fill-secondary)"
: undefined,
: "transparent",
borderLeft: isActive
? "3px solid var(--accent)"
: "3px solid transparent",
border: "none",
cursor: "pointer",
}}
onMouseEnter={(e) => {
if (!isActive)
e.currentTarget.style.background =
"var(--material-ultra-thin)";
}}
onMouseLeave={(e) => {
if (!isActive)
e.currentTarget.style.background = "";
}}
>
<div
className="text-[14px] font-medium truncate"
style={{ color: "var(--text-primary)" }}
>
{file.label}
</div>
<div
className="text-[11px] mt-0.5"
style={{ color: "var(--text-tertiary)" }}
>
{timeAgo(file.lastModified)}
<FileIcon isJson={json} />
<div className="min-w-0 flex-1">
<div
className="truncate"
style={{
fontSize: "var(--text-footnote)",
fontWeight: "var(--weight-semibold)",
color: "var(--text-primary)",
lineHeight: "var(--leading-snug)",
}}
>
{file.label}
</div>
<div
style={{
fontSize: "var(--text-caption2)",
color: "var(--text-tertiary)",
marginTop: 2,
}}
>
{formatBytes(new Blob([file.content]).size)} {"\u00b7"}{" "}
{timeAgo(file.lastModified)}
</div>
</div>
</button>
);
})
)}
</div>
</div>
</aside>
{/* Main content */}
<div
ref={contentRef}
tabIndex={-1}
className="flex-1 flex flex-col overflow-hidden"
style={{ background: "var(--bg)", outline: "none" }}
{/* ── Content view ───────────────────────────────────────── */}
<main
className={`flex-1 flex flex-col overflow-hidden ${
!mobileShowContent || !selected ? "hidden md:flex" : "flex"
}`}
style={{ background: "var(--bg)" }}
>
{selected ? (
<>
{/* Content area */}
{/* Content header (sticky) */}
<div
className="flex-1 overflow-y-auto"
style={{ padding: "32px 40px" }}
className="flex-shrink-0"
style={{
padding: "var(--space-3) var(--space-6)",
borderBottom: "1px solid var(--separator)",
background: "var(--material-regular)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
}}
>
<div style={{ maxWidth: 760, margin: "0 auto" }}>
{/* File title */}
<h1
className="text-[28px] font-bold"
style={{
color: "var(--text-primary)",
letterSpacing: "-0.5px",
}}
>
{selected.label}
</h1>
{/* Mobile back button */}
<button
onClick={() => setMobileShowContent(false)}
className="md:hidden btn-ghost focus-ring"
aria-label="Back to file list"
style={{
display: "inline-flex",
alignItems: "center",
gap: "var(--space-1)",
padding: "4px 8px",
borderRadius: "var(--radius-sm)",
fontSize: "var(--text-footnote)",
color: "var(--system-blue)",
marginBottom: "var(--space-2)",
marginLeft: "-8px",
}}
>
<BackArrow />
Files
</button>
{/* Meta */}
<div
className="text-[12px] mt-1 mb-6"
style={{ color: "var(--text-tertiary)" }}
>
{isJSON ? (
<>
{lineCount} lines &middot; Modified{" "}
{timeAgo(selected.lastModified)}
</>
) : (
<>
{words.toLocaleString()} words &middot;{" "}
{lineCount} lines &middot; Modified{" "}
{timeAgo(selected.lastModified)}
</>
)}
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
{/* Breadcrumb */}
<div
className="truncate"
style={{
fontSize: "var(--text-footnote)",
fontWeight: "var(--weight-semibold)",
color: "var(--text-primary)",
}}
>
{breadcrumb.map((part, i) => (
<span key={i}>
{i > 0 && (
<span
style={{
color: "var(--text-tertiary)",
margin: "0 4px",
}}
>
/
</span>
)}
<span
style={{
color:
i === breadcrumb.length - 1
? "var(--text-primary)"
: "var(--text-tertiary)",
}}
>
{part}
</span>
</span>
))}
</div>
{/* Metadata */}
<div
style={{
fontSize: "var(--text-caption2)",
color: "var(--text-tertiary)",
marginTop: 2,
}}
>
{lineCount} line{lineCount !== 1 ? "s" : ""}
{!isJson && <> {"\u00b7"} {words.toLocaleString()} words</>}
{" \u00b7 "}
{formatBytes(sizeBytes)}
{" \u00b7 "}
{timeAgo(selected.lastModified)}
</div>
</div>
{/* Content */}
{/* Action buttons */}
<div className="flex items-center flex-shrink-0" style={{ gap: "var(--space-2)" }}>
<button
onClick={copyContent}
className="btn-ghost focus-ring"
aria-label="Copy file content"
style={{
padding: "6px 12px",
borderRadius: "var(--radius-sm)",
fontSize: "var(--text-caption1)",
fontWeight: "var(--weight-medium)",
}}
>
{copied ? "Copied" : "Copy"}
</button>
<button
onClick={downloadContent}
className="btn-ghost focus-ring"
aria-label="Download file"
style={{
padding: "6px 12px",
borderRadius: "var(--radius-sm)",
fontSize: "var(--text-caption1)",
fontWeight: "var(--weight-medium)",
}}
>
Download
</button>
</div>
</div>
</div>
{/* Scrollable content area */}
<div
className="flex-1 overflow-y-auto"
style={{
padding: "var(--space-8) var(--space-10)",
}}
>
<div style={{ maxWidth: 760, margin: "0 auto" }}>
{renderedContent}
</div>
</div>
</>
) : (
<div className="flex items-center justify-center h-full">
/* ── Empty state (no file selected) ──────────────────── */
<div
className="flex flex-col items-center justify-center h-full"
style={{ gap: "var(--space-3)" }}
>
<FolderIcon />
<span
className="text-[15px]"
style={{ color: "var(--text-secondary)" }}
style={{
fontSize: "var(--text-subheadline)",
fontWeight: "var(--weight-medium)",
color: "var(--text-secondary)",
marginTop: "var(--space-2)",
}}
>
Select a file from the sidebar
Select a file
</span>
<span
style={{
fontSize: "var(--text-footnote)",
color: "var(--text-tertiary)",
textAlign: "center",
maxWidth: 240,
}}
>
Choose a file from the sidebar to view its contents
</span>
</div>
)}
</div>
</main>
</div>
);
}
+584 -365
View File
File diff suppressed because it is too large Load Diff
+88 -100
View File
@@ -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<string, unknown>;
}
type AgentNodeData = Agent & { crons: CronJob[] } & Record<string, unknown>
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 (
<>
<Handle
type="target"
position={Position.Top}
style={{ background: "transparent", border: "none", width: 6, height: 6 }}
/>
<div
className={`hover-lift focus-ring${selected ? " node-selected" : ""}`}
title={agent.title}
style={{
background: "var(--material-regular)",
backdropFilter: "blur(20px) saturate(180%)",
WebkitBackdropFilter: "blur(20px) saturate(180%)",
borderRadius: "var(--radius-md)",
border: `1px solid ${selected ? "var(--accent)" : "var(--separator)"}`,
padding: "var(--space-3) var(--space-4)",
minWidth: 140,
maxWidth: 180,
cursor: "pointer",
position: "relative",
boxShadow: selected ? "0 0 0 1px var(--accent), var(--shadow-card)" : "var(--shadow-card)",
}}
>
{/* Status dot -- top right */}
{hasCrons && (
<div
className={hasErrors ? "animate-error-pulse" : ""}
style={{
position: "absolute",
top: -3,
right: -3,
width: 8,
height: 8,
borderRadius: "50%",
background: hasErrors ? "var(--system-red)" : "var(--system-green)",
border: "2px solid var(--bg)",
}}
/>
)}
{/* Emoji on tinted squircle */}
<div
style={{
width: '164px',
padding: '14px',
borderRadius: '18px',
background: 'var(--material-thin)',
border: '1px solid rgba(255,255,255,0.10)',
boxShadow: 'var(--shadow-card)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
cursor: 'pointer',
userSelect: 'none',
transition: 'all 200ms var(--ease-spring)',
}}
onMouseEnter={(e) => {
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 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '18px', lineHeight: 1 }}>{data.emoji}</span>
<span style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: hasError ? 'var(--system-red)' : hasOk ? 'var(--system-green)' : 'var(--text-tertiary)',
flexShrink: 0,
}} />
</div>
{/* Name */}
<div style={{
fontSize: '13px',
fontWeight: 600,
letterSpacing: '-0.2px',
color: 'var(--text-primary)',
marginTop: '8px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{data.name}
</div>
{/* Title */}
<div style={{
fontSize: '11px',
fontWeight: 400,
color: 'var(--text-secondary)',
letterSpacing: '0.01em',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginTop: '2px',
}}>
{data.title}
</div>
{/* Cron pill */}
{hasCrons && (
<div style={{
marginTop: '8px',
display: 'inline-block',
background: 'var(--fill-tertiary)',
borderRadius: '6px',
padding: '2px 8px',
fontSize: '10px',
fontWeight: 500,
color: 'var(--text-secondary)',
}}>
{data.crons.length} cron{data.crons.length > 1 ? 's' : ''}
</div>
)}
{agent.emoji}
</div>
<Handle
type="source"
position={Position.Bottom}
style={{ background: "transparent", border: "none", width: 6, height: 6 }}
/>
</>
);
{/* Name */}
<div
style={{
fontSize: "var(--text-footnote)",
fontWeight: "var(--weight-semibold)",
color: "var(--text-primary)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{agent.name}
</div>
{/* Title */}
<div
style={{
fontSize: "var(--text-caption2)",
color: "var(--text-tertiary)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
marginTop: 1,
}}
>
{agent.title}
</div>
{/* Handles - invisible */}
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
</div>
)
}
export const nodeTypes = { agentNode: AgentNode };
export const nodeTypes = { agentNode: AgentNode }
+126
View File
@@ -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 (
<nav
aria-label="Breadcrumb"
className="animate-fade-in"
style={{
height: '32px',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '12px',
lineHeight: 1,
fontWeight: 500,
}}
>
<ol
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
listStyle: 'none',
margin: 0,
padding: 0,
}}
>
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li
key={item.label}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
minWidth: 0,
}}
>
{index > 0 && (
<ChevronRight
size={12}
style={{
color: 'var(--text-quaternary)',
flexShrink: 0,
}}
aria-hidden="true"
/>
)}
{isLast || !item.href ? (
<span
style={{
color: 'var(--text-primary)',
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '200px',
}}
aria-current="page"
>
{item.icon && (
<span
style={{
display: 'inline-flex',
verticalAlign: 'middle',
marginRight: '4px',
}}
>
{item.icon}
</span>
)}
{item.label}
</span>
) : (
<Link
href={item.href}
className="breadcrumb-link focus-ring"
style={{
color: 'var(--text-secondary)',
textDecoration: 'none',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '200px',
borderRadius: '4px',
padding: '2px 4px',
margin: '-2px -4px',
transition: 'color 100ms var(--ease-smooth)',
}}
aria-label={item.label}
>
{item.icon && (
<span
style={{
display: 'inline-flex',
verticalAlign: 'middle',
marginRight: '4px',
}}
>
{item.icon}
</span>
)}
{item.label}
</Link>
)}
</li>
);
})}
</ol>
</nav>
);
}
+569
View File
@@ -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: <Map size={16} />, href: '/', category: 'Pages' },
{ id: 'page-messages', label: 'Messages', icon: <MessageSquare size={16} />, href: '/chat', category: 'Pages' },
{ id: 'page-crons', label: 'Crons', icon: <Clock size={16} />, href: '/crons', category: 'Pages' },
{ id: 'page-memory', label: 'Memory', icon: <Brain size={16} />, 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 (
<button
onClick={onClick}
className="nav-item focus-ring"
aria-label="Open search (Cmd+K)"
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: '8px',
height: '36px',
padding: '0 12px',
borderRadius: '8px',
border: '1px solid var(--separator)',
background: 'var(--fill-quaternary)',
color: 'var(--text-tertiary)',
fontSize: '13px',
cursor: 'pointer',
transition: 'all 100ms var(--ease-smooth)',
}}
>
<Search size={14} style={{ flexShrink: 0, opacity: 0.7 }} />
<span style={{ flex: 1, textAlign: 'left' }}>Search...</span>
<kbd
style={{
fontSize: '11px',
fontFamily: 'var(--font-mono)',
padding: '1px 5px',
borderRadius: '4px',
background: 'var(--fill-tertiary)',
color: 'var(--text-quaternary)',
border: '1px solid var(--separator)',
lineHeight: '16px',
}}
>
{'\u2318'}K
</kbd>
</button>
);
}
// ---------------------------------------------------------------------------
// GlobalSearch modal
// ---------------------------------------------------------------------------
export function GlobalSearch() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const [agents, setAgents] = useState<Agent[]>([]);
const [crons, setCrons] = useState<CronJob[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(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: <Bot size={16} style={{ color: a.color }} />,
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: <Timer size={16} />,
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 (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 100,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
paddingTop: '20vh',
}}
>
{/* Backdrop */}
<div
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
onClick={() => setOpen(false)}
aria-hidden="true"
/>
{/* Modal */}
<div
role="dialog"
aria-modal="true"
aria-label="Search Manor"
className="animate-scale-in"
onKeyDown={handleKeyDown}
style={{
position: 'relative',
width: '100%',
maxWidth: '560px',
margin: '0 16px',
borderRadius: 'var(--radius-xl)',
background: 'var(--material-regular)',
border: '1px solid var(--separator)',
boxShadow: 'var(--shadow-overlay)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
maxHeight: '480px',
}}
>
{/* Search input */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '14px 16px',
borderBottom: '1px solid var(--separator)',
}}
>
<Search
size={18}
style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}
aria-hidden="true"
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search Manor..."
aria-label="Search Manor"
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
fontSize: '15px',
color: 'var(--text-primary)',
fontFamily: 'inherit',
}}
/>
<kbd
style={{
fontSize: '11px',
fontFamily: 'var(--font-mono)',
padding: '2px 6px',
borderRadius: '4px',
background: 'var(--fill-quaternary)',
color: 'var(--text-quaternary)',
border: '1px solid var(--separator)',
lineHeight: '16px',
}}
>
esc
</kbd>
</div>
{/* Results */}
<div
ref={listRef}
role="listbox"
aria-label="Search results"
style={{
flex: 1,
overflowY: 'auto',
padding: '8px',
}}
>
{flatResults.length === 0 && query.trim() && (
<div
style={{
padding: '24px 16px',
textAlign: 'center',
color: 'var(--text-tertiary)',
fontSize: '13px',
}}
>
No results for &lsquo;{query}&rsquo;
</div>
)}
{grouped.map((group) => (
<div key={group.category} style={{ marginBottom: '4px' }}>
{/* Category header */}
<div
style={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: 'var(--text-tertiary)',
padding: '6px 8px 4px',
}}
>
{group.category}
</div>
{/* Items */}
{group.items.map((item) => {
const currentIndex = flatIndex++;
const isActive = currentIndex === activeIndex;
return (
<button
key={item.id}
role="option"
aria-selected={isActive}
data-active={isActive}
onClick={() => navigate(item)}
onMouseEnter={() => setActiveIndex(currentIndex)}
className="focus-ring"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
width: '100%',
minHeight: '44px',
padding: '8px 10px',
borderRadius: '8px',
border: 'none',
background: isActive
? 'var(--accent-fill)'
: 'transparent',
cursor: 'pointer',
textAlign: 'left',
transition: 'background 80ms var(--ease-smooth)',
outline: 'none',
}}
aria-label={
item.subtitle
? `${item.label} - ${item.subtitle}`
: item.label
}
>
<span
style={{
width: '28px',
height: '28px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '6px',
background: 'var(--fill-quaternary)',
flexShrink: 0,
color: 'var(--text-secondary)',
}}
>
{item.icon}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '13px',
fontWeight: 500,
color: isActive
? 'var(--text-primary)'
: 'var(--text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.label}
</div>
{item.subtitle && (
<div
style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.subtitle}
</div>
)}
</div>
</button>
);
})}
</div>
))}
</div>
{/* Footer with keyboard hints */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '16px',
padding: '8px 16px',
borderTop: '1px solid var(--separator)',
fontSize: '11px',
color: 'var(--text-quaternary)',
}}
>
<span>
<kbd style={{ fontFamily: 'var(--font-mono)' }}>{'\u2191\u2193'}</kbd> Navigate
</span>
<span>
<kbd style={{ fontFamily: 'var(--font-mono)' }}>{'\u21B5'}</kbd> Open
</span>
<span>
<kbd style={{ fontFamily: 'var(--font-mono)' }}>esc</kbd> Close
</span>
</div>
</div>
</div>
);
}
+92 -62
View File
@@ -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<string>();
const root = agents.find((a) => a.reportsTo === null);
if (!root) return { nodes: [], edges: [] };
// BFS to determine levels
const levels: string[][] = []
const visited = new Set<string>()
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<string, unknown>,
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<string>()
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 (
<ReactFlow
@@ -112,16 +141,17 @@ export function ManorMap({ agents, crons, onNodeClick }: ManorMapProps) {
onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
nodeTypes={nodeTypes}
connectionLineType={ConnectionLineType.SmoothStep}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.2}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<Controls />
<MiniMap
nodeColor={(n) => (n.data as unknown as Agent).color || "rgba(84,84,88,0.4)"}
maskColor="rgba(0,0,0,0.8)"
<Controls
position="bottom-left"
style={{ left: 16, bottom: 16 }}
/>
</ReactFlow>
);
)
}
+140 -81
View File
@@ -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 */}
<button
onClick={toggle}
className="md:hidden fixed top-3 left-3 z-[60]"
aria-label={open ? 'Close navigation menu' : 'Open navigation menu'}
aria-expanded={open}
{/* Mobile header bar */}
<header
className="md:hidden"
style={{
width: 36,
height: 36,
borderRadius: 8,
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 60,
height: '48px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
background: 'var(--material-regular)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
border: '1px solid var(--separator)',
cursor: 'pointer',
boxShadow: 'var(--shadow-card)',
gap: '12px',
padding: '0 12px',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
borderBottom: '1px solid var(--separator)',
}}
>
<span
{/* Hamburger / close toggle */}
<button
onClick={toggle}
className="btn-ghost focus-ring"
aria-label={open ? 'Close navigation menu' : 'Open navigation menu'}
aria-expanded={open}
style={{
display: 'block',
width: 16,
height: 1.5,
borderRadius: 1,
background: 'var(--text-secondary)',
transition: 'transform 200ms ease, opacity 200ms ease',
transform: open ? 'translateY(2.75px) rotate(45deg)' : 'none',
width: '36px',
height: '36px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: 'var(--text-secondary)',
transition: 'color 100ms var(--ease-smooth)',
}}
/>
<span
style={{
display: 'block',
width: 16,
height: 1.5,
borderRadius: 1,
background: 'var(--text-secondary)',
transition: 'transform 200ms ease, opacity 200ms ease',
opacity: open ? 0 : 1,
}}
/>
<span
style={{
display: 'block',
width: 16,
height: 1.5,
borderRadius: 1,
background: 'var(--text-secondary)',
transition: 'transform 200ms ease, opacity 200ms ease',
transform: open ? 'translateY(-2.75px) rotate(-45deg)' : 'none',
}}
/>
</button>
>
{open ? <X size={20} /> : <Menu size={20} />}
</button>
{/* App title */}
<div className="flex items-center gap-2" style={{ flex: 1 }}>
<span
style={{
width: '24px',
height: '24px',
borderRadius: '6px',
background: 'linear-gradient(135deg, #f5c518, #e8b800)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '13px',
flexShrink: 0,
}}
>
{'\ud83c\udff0'}
</span>
<span
style={{
fontSize: '15px',
fontWeight: 600,
color: 'var(--text-primary)',
letterSpacing: '-0.2px',
}}
>
Manor Command Centre
</span>
</div>
</header>
{/* Backdrop */}
{open && (
<div
className="md:hidden fixed inset-0 z-[50]"
style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={() => setOpen(false)}
aria-hidden="true"
/>
)}
{/* Slide-out sidebar */}
<aside
className="md:hidden fixed top-0 left-0 bottom-0 z-[55] flex flex-col"
<div
className="md:hidden"
style={{
width: 260,
position: 'fixed',
inset: 0,
zIndex: 50,
background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(2px)',
WebkitBackdropFilter: 'blur(2px)',
opacity: open ? 1 : 0,
pointerEvents: open ? 'auto' : 'none',
transition: 'opacity 200ms var(--ease-smooth)',
}}
onClick={() => setOpen(false)}
aria-hidden="true"
/>
{/* Slide-out sidebar panel */}
<aside
className="md:hidden"
style={{
position: 'fixed',
top: 0,
left: 0,
bottom: 0,
zIndex: 55,
width: '280px',
display: 'flex',
flexDirection: 'column',
background: 'var(--sidebar-bg)',
backdropFilter: 'var(--sidebar-backdrop)',
WebkitBackdropFilter: 'var(--sidebar-backdrop)',
borderRight: '1px solid var(--separator)',
transform: open ? 'translateX(0)' : 'translateX(-100%)',
transition: 'transform 250ms var(--ease-smooth)',
transition: 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)',
boxShadow: open ? 'var(--shadow-overlay)' : 'none',
}}
aria-hidden={!open}
@@ -124,35 +168,50 @@ export function MobileSidebar() {
<div className="px-4 pt-5 pb-3">
<div className="flex items-center gap-3">
<div
className="w-9 h-9 flex items-center justify-center text-lg"
style={{
width: '36px',
height: '36px',
borderRadius: '10px',
background: 'linear-gradient(135deg, #f5c518, #e8b800)',
boxShadow: 'var(--shadow-card)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
flexShrink: 0,
}}
>
🏰
{'\ud83c\udff0'}
</div>
<div>
<div style={{
fontSize: '17px',
fontWeight: 600,
letterSpacing: '-0.3px',
color: 'var(--text-primary)',
}}>
<div
style={{
fontSize: '17px',
fontWeight: 600,
letterSpacing: '-0.3px',
color: 'var(--text-primary)',
}}
>
Manor
</div>
<div style={{
fontSize: '12px',
color: 'var(--text-secondary)',
letterSpacing: '0.01em',
}}>
<div
style={{
fontSize: '12px',
color: 'var(--text-secondary)',
letterSpacing: '0.01em',
}}
>
Command Centre
</div>
</div>
</div>
</div>
{/* Search trigger */}
<div className="px-3 pb-2">
<SearchTrigger onClick={handleSearchClick} />
</div>
<NavLinks />
<ThemeToggle />
</aside>
+161 -119
View File
@@ -1,23 +1,42 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { useTheme } from "@/app/providers";
'use client';
const NAV_ITEMS = [
{ href: "/", icon: "🗺️", label: "Manor Map" },
{ href: "/chat", icon: "💬", label: "Messages" },
{ href: "/crons", icon: "⏰", label: "Cron Monitor" },
{ href: "/memory", icon: "🧠", label: "Memory" },
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Map, MessageSquare, Clock, Brain } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import type { CronJob } from '@/lib/types';
// ---------------------------------------------------------------------------
// Nav item definition
// ---------------------------------------------------------------------------
interface NavItem {
href: string;
label: string;
icon: LucideIcon;
badge?: 'agents' | 'unread' | 'errors';
}
const NAV_ITEMS: NavItem[] = [
{ href: '/', label: 'Map', icon: Map, badge: 'agents' },
{ href: '/chat', label: 'Messages', icon: MessageSquare, badge: 'unread' },
{ href: '/crons', label: 'Crons', icon: Clock, badge: 'errors' },
{ href: '/memory', label: 'Memory', icon: Brain },
];
// ---------------------------------------------------------------------------
// NavLinks component
// ---------------------------------------------------------------------------
export function NavLinks() {
const pathname = usePathname();
const { theme } = useTheme();
const [agentCount, setAgentCount] = useState<number | null>(null);
const [cronErrorCount, setCronErrorCount] = useState<number | null>(null);
// Fetch agent count
useEffect(() => {
fetch("/api/agents")
fetch('/api/agents')
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
@@ -26,116 +45,130 @@ export function NavLinks() {
if (Array.isArray(data)) {
setAgentCount(data.length);
}
// If the response is an error object, leave agentCount as null
// so the badge simply won't render.
})
.catch(() => {
// On failure, ensure we don't show a broken badge.
// agentCount stays null, so the count badge is hidden.
setAgentCount(null);
});
}, []);
function getActiveStyle() {
if (theme === "light") {
return {
background: 'rgba(0,122,255,0.10)',
color: '#007AFF',
boxShadow: 'inset 2px 0 0 #007AFF',
};
// Fetch cron error count
useEffect(() => {
fetch('/api/crons')
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((data: unknown) => {
if (Array.isArray(data)) {
const errors = (data as CronJob[]).filter((c) => c.status === 'error');
setCronErrorCount(errors.length);
}
})
.catch(() => {
setCronErrorCount(null);
});
}, []);
// Resolve badge content per nav item
function getBadge(item: NavItem): React.ReactNode {
if (item.badge === 'agents' && agentCount !== null) {
return (
<span
className="nav-badge"
style={{
marginLeft: 'auto',
fontSize: '10px',
fontFamily: 'var(--font-mono)',
padding: '1px 6px',
borderRadius: 'var(--radius-sm)',
background: 'var(--fill-quaternary)',
color: 'var(--text-tertiary)',
lineHeight: '16px',
}}
>
{agentCount}
</span>
);
}
if (theme === "color") {
return {
background: 'rgba(139,92,246,0.18)',
color: '#C084FC',
boxShadow: 'inset 2px 0 0 #C084FC',
};
if (item.badge === 'errors' && cronErrorCount !== null && cronErrorCount > 0) {
return (
<span
className="nav-badge-error"
aria-label={`${cronErrorCount} cron error${cronErrorCount > 1 ? 's' : ''}`}
style={{
marginLeft: 'auto',
width: '8px',
height: '8px',
borderRadius: '50%',
background: 'var(--system-red)',
flexShrink: 0,
animation: 'pulse-red 1.5s ease-in-out infinite',
}}
/>
);
}
return {
background: 'rgba(255,255,255,0.12)',
color: '#FFFFFF',
boxShadow: 'inset 2px 0 0 var(--accent)',
};
return null;
}
return (
<nav className="flex-1 flex flex-col">
<nav className="flex-1 flex flex-col" aria-label="Main navigation">
<div className="px-3 pt-2 pb-3">
{/* Section header */}
<div style={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.06em',
color: 'var(--text-tertiary)',
textTransform: 'uppercase' as const,
padding: '0 8px',
marginBottom: '4px',
}}>
WORKSPACE
<div
style={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.06em',
color: 'var(--text-tertiary)',
textTransform: 'uppercase',
padding: '0 8px',
marginBottom: '4px',
}}
>
Workspace
</div>
<div className="flex flex-col gap-0.5">
{NAV_ITEMS.map((item) => {
const isActive =
item.href === "/"
? pathname === "/"
item.href === '/'
? pathname === '/'
: pathname.startsWith(item.href);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-2.5 no-underline"
className={`nav-item focus-ring ${isActive ? 'nav-item-active' : ''}`}
aria-label={item.label}
aria-current={isActive ? "page" : undefined}
aria-current={isActive ? 'page' : undefined}
style={{
height: '34px',
padding: '0 8px 0 12px',
display: 'flex',
alignItems: 'center',
gap: '10px',
minHeight: '44px',
padding: '0 10px 0 12px',
borderRadius: '8px',
fontSize: '13px',
fontWeight: isActive ? 600 : 500,
color: isActive ? getActiveStyle().color : 'var(--text-secondary)',
background: isActive ? getActiveStyle().background : 'transparent',
boxShadow: isActive ? getActiveStyle().boxShadow : 'none',
transition: 'all 100ms var(--ease-spring)',
color: isActive ? 'var(--accent)' : 'var(--text-secondary)',
background: isActive ? 'var(--accent-fill)' : 'transparent',
textDecoration: 'none',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.background = 'var(--material-ultra-thin)';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.background = 'transparent';
}
transition: 'all 100ms var(--ease-smooth)',
}}
>
<span style={{
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
flexShrink: 0,
}}>
{item.icon}
</span>
<span>{item.label}</span>
{item.href === "/" && agentCount !== null && (
<span style={{
marginLeft: 'auto',
fontSize: '10px',
fontFamily: 'var(--font-mono)',
padding: '1px 6px',
borderRadius: 'var(--radius-sm)',
background: 'var(--fill-quaternary)',
color: 'var(--text-tertiary)',
}}>
{agentCount}
</span>
)}
<Icon
size={18}
style={{
flexShrink: 0,
color: isActive ? 'var(--accent)' : 'var(--text-tertiary)',
transition: 'color 100ms var(--ease-smooth)',
}}
/>
<span style={{ flex: 1 }}>{item.label}</span>
{getBadge(item)}
</Link>
);
})}
@@ -145,41 +178,50 @@ export function NavLinks() {
<div className="flex-1" />
{/* User footer */}
<div style={{
borderTop: '1px solid var(--separator)',
padding: '10px 16px',
}}>
<div
style={{
borderTop: '1px solid var(--separator)',
padding: '10px 16px',
}}
>
<div className="flex items-center gap-2.5">
<div style={{
width: '28px',
height: '28px',
borderRadius: '7px',
background: 'var(--fill-primary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 600,
color: 'var(--text-secondary)',
flexShrink: 0,
}}>
<div
style={{
width: '28px',
height: '28px',
borderRadius: '7px',
background: 'var(--accent-fill)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
fontWeight: 700,
color: 'var(--accent)',
flexShrink: 0,
letterSpacing: '-0.02em',
}}
>
JR
</div>
<div style={{ minWidth: 0 }}>
<div style={{
fontSize: '13px',
fontWeight: 500,
color: 'var(--text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
<div
style={{
fontSize: '13px',
fontWeight: 500,
color: 'var(--text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
John Rice
</div>
<div style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
}}>
<div
style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
}}
>
Owner
</div>
</div>
+95
View File
@@ -0,0 +1,95 @@
'use client';
import { useCallback } from 'react';
import { NavLinks } from '@/components/NavLinks';
import { ThemeToggle } from '@/components/ThemeToggle';
import { MobileSidebar } from '@/components/MobileSidebar';
import { GlobalSearch, SearchTrigger } from '@/components/GlobalSearch';
/**
* Sidebar -- client wrapper that coordinates desktop sidebar, mobile sidebar,
* and the Cmd+K search palette. Rendered inside layout.tsx.
*/
export function Sidebar() {
const openSearch = useCallback(() => {
// We trigger the search modal by simulating Cmd+K.
// Instead, we expose a controlled open state via a custom event.
// The GlobalSearch component listens for this.
window.dispatchEvent(new CustomEvent('manor:open-search'));
}, []);
return (
<>
{/* Desktop sidebar — hidden on mobile */}
<aside
className="hidden md:flex"
style={{
width: '220px',
flexShrink: 0,
flexDirection: 'column',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
borderRight: '1px solid var(--separator)',
}}
>
{/* App icon + title */}
<div className="px-4 pt-5 pb-3">
<div className="flex items-center gap-3">
<div
style={{
width: '36px',
height: '36px',
borderRadius: '10px',
background: 'linear-gradient(135deg, #f5c518, #e8b800)',
boxShadow: 'var(--shadow-card)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
flexShrink: 0,
}}
>
{'\ud83c\udff0'}
</div>
<div>
<div
style={{
fontSize: '17px',
fontWeight: 600,
letterSpacing: '-0.3px',
color: 'var(--text-primary)',
}}
>
Manor
</div>
<div
style={{
fontSize: '12px',
color: 'var(--text-secondary)',
letterSpacing: '0.01em',
}}
>
Command Centre
</div>
</div>
</div>
</div>
{/* Search trigger */}
<div className="px-3 pb-2">
<SearchTrigger onClick={openSearch} />
</div>
<NavLinks />
<ThemeToggle />
</aside>
{/* Mobile sidebar */}
<MobileSidebar onOpenSearch={openSearch} />
{/* Global search modal (Cmd+K) */}
<GlobalSearch />
</>
);
}
+61 -46
View File
@@ -1,4 +1,5 @@
'use client';
import { useRef, useCallback } from 'react';
import { THEMES } from '@/lib/themes';
import { useTheme } from '@/app/providers';
@@ -7,46 +8,53 @@ export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const containerRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
const buttons = containerRef.current?.querySelectorAll<HTMLButtonElement>('button');
if (!buttons || buttons.length === 0) return;
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const buttons =
containerRef.current?.querySelectorAll<HTMLButtonElement>('button');
if (!buttons || buttons.length === 0) return;
const currentIndex = THEMES.findIndex(t => t.id === theme);
const currentIndex = THEMES.findIndex((t) => t.id === theme);
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
const nextIndex = (currentIndex + 1) % THEMES.length;
setTheme(THEMES[nextIndex].id);
buttons[nextIndex].focus();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
const prevIndex = (currentIndex - 1 + THEMES.length) % THEMES.length;
setTheme(THEMES[prevIndex].id);
buttons[prevIndex].focus();
}
}, [theme, setTheme]);
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
const nextIndex = (currentIndex + 1) % THEMES.length;
setTheme(THEMES[nextIndex].id);
buttons[nextIndex].focus();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
const prevIndex =
(currentIndex - 1 + THEMES.length) % THEMES.length;
setTheme(THEMES[prevIndex].id);
buttons[prevIndex].focus();
}
},
[theme, setTheme]
);
return (
<div style={{ padding: '8px 16px 12px' }}>
<div style={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.06em',
color: 'var(--text-tertiary)',
textTransform: 'uppercase' as const,
marginBottom: '6px',
paddingLeft: '4px',
}}>
THEME
<div
style={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.06em',
color: 'var(--text-tertiary)',
textTransform: 'uppercase',
marginBottom: '6px',
paddingLeft: '4px',
}}
>
Theme
</div>
<div
ref={containerRef}
className="flex gap-1.5"
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label="Theme selection"
onKeyDown={handleKeyDown}
>
{THEMES.map(t => {
{THEMES.map((t) => {
const isActive = theme === t.id;
return (
<button
@@ -57,33 +65,40 @@ export function ThemeToggle() {
aria-checked={isActive}
aria-label={`${t.label} theme`}
tabIndex={isActive ? 0 : -1}
className="focus-ring"
style={{
width: '28px',
height: '28px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '13px',
gap: '4px',
height: '28px',
padding: isActive ? '0 10px' : '0 6px',
borderRadius: '14px',
fontSize: '12px',
fontWeight: isActive ? 600 : 500,
border: 'none',
cursor: 'pointer',
transition: 'all 150ms var(--ease-spring)',
background: isActive ? 'var(--accent-fill)' : 'var(--fill-quaternary)',
boxShadow: isActive ? '0 0 0 1.5px var(--accent)' : 'none',
background: isActive
? 'var(--accent-fill)'
: 'var(--fill-quaternary)',
color: isActive ? 'var(--accent)' : 'var(--text-tertiary)',
outline: 'none',
}}
onFocus={(e) => {
e.currentTarget.style.boxShadow = isActive
? '0 0 0 1.5px var(--accent), 0 0 0 3px var(--system-blue)'
: '0 0 0 2px var(--system-blue)';
}}
onBlur={(e) => {
e.currentTarget.style.boxShadow = isActive
? '0 0 0 1.5px var(--accent)'
: 'none';
}}
>
{t.emoji}
<span style={{ fontSize: '13px', lineHeight: 1 }}>
{t.emoji}
</span>
{isActive && (
<span
style={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '-0.01em',
}}
>
{t.label}
</span>
)}
</button>
);
})}
+382 -73
View File
@@ -32,48 +32,349 @@ export function AgentList({ agents, conversations, activeId, onSelect, loading }
})
return (
<div style={{
width: 280,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
background: 'var(--sidebar-bg)',
backdropFilter: 'var(--sidebar-backdrop)',
WebkitBackdropFilter: 'var(--sidebar-backdrop)',
borderRight: '1px solid var(--separator)',
height: '100%',
}}>
<div
className="hidden md:flex"
style={{
width: 300,
flexShrink: 0,
flexDirection: 'column',
background: 'var(--sidebar-bg)',
backdropFilter: 'var(--sidebar-backdrop)',
WebkitBackdropFilter: 'var(--sidebar-backdrop)',
borderRight: '1px solid var(--separator)',
height: '100%',
}}
>
{/* Header */}
<div style={{
padding: '16px 16px 12px',
padding: 'var(--space-4) var(--space-4) var(--space-3)',
borderBottom: '1px solid var(--separator)',
background: 'var(--material-regular)',
backdropFilter: 'blur(40px)',
WebkitBackdropFilter: 'blur(40px)',
background: 'var(--material-thick)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
flexShrink: 0,
}}>
<h2 style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.5px', color: 'var(--text-primary)', margin: 0 }}>
<h2 style={{
fontSize: 'var(--text-title2)',
fontWeight: 'var(--weight-bold)',
letterSpacing: '-0.5px',
color: 'var(--text-primary)',
margin: 0,
}}>
Messages
</h2>
{/* Search */}
<div style={{
marginTop: 10,
marginTop: 'var(--space-3)',
background: 'var(--fill-tertiary)',
borderRadius: 12,
padding: '7px 12px',
borderRadius: 'var(--radius-md)',
padding: '7px var(--space-3)',
display: 'flex',
alignItems: 'center',
gap: 8,
gap: 'var(--space-2)',
}}>
<span style={{ fontSize: 14, color: 'var(--text-tertiary)', flexShrink: 0 }} aria-hidden="true">&#128269;</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0 }}
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search agents..."
aria-label="Search agents"
className="focus-ring"
style={{
flex: 1,
fontSize: 14,
fontSize: 'var(--text-footnote)',
color: 'var(--text-primary)',
background: 'transparent',
border: 'none',
outline: 'none',
padding: 0,
margin: 0,
lineHeight: 1.4,
}}
/>
{search.trim() && (
<button
className="btn-ghost focus-ring"
onClick={() => setSearch('')}
aria-label="Clear search"
style={{
padding: 2,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
</div>
{/* Agent list */}
<div style={{ flex: 1, overflowY: 'auto', padding: 'var(--space-1) 0' }} role="listbox" aria-label="Agent list">
{loading ? (
<div style={{ padding: 'var(--space-1) 0' }} role="status" aria-label="Loading agents">
{[1, 2, 3, 4].map((i) => (
<div key={i} style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3)',
padding: 'var(--space-3) var(--space-4)',
}}>
<Skeleton className="rounded-full" style={{ width: 40, height: 40, flexShrink: 0 }} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<Skeleton style={{ width: '55%', height: 14 }} />
<Skeleton style={{ width: '80%', height: 11 }} />
</div>
</div>
))}
</div>
) : sorted.length === 0 && search.trim() ? (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 'var(--space-8) var(--space-4)',
textAlign: 'center',
}}>
<div style={{
fontSize: 'var(--text-footnote)',
color: 'var(--text-tertiary)',
lineHeight: 'var(--leading-relaxed)',
}}>
No agents match &lsquo;{search.trim()}&rsquo;
</div>
</div>
) : (
sorted.map(agent => {
const conv = conversations[agent.id]
const lastMsg = conv?.messages[conv.messages.length - 1]
const unread = conv?.unread || 0
const isActive = agent.id === activeId
const preview = lastMsg
? (lastMsg.role === 'user' ? 'You: ' : '') +
lastMsg.content.replace(/[#*`]/g, '').slice(0, 50) +
(lastMsg.content.length > 50 ? '\u2026' : '')
: agent.description?.slice(0, 50) || 'Start a conversation'
const timeLabel = lastMsg ? formatTime(lastMsg.timestamp) : ''
return (
<button
key={agent.id}
onClick={() => onSelect(agent)}
role="option"
aria-selected={isActive}
className="hover-bg focus-ring"
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3)',
padding: 'var(--space-3) var(--space-4)',
background: isActive ? 'var(--fill-secondary)' : 'transparent',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
}}
>
{/* Avatar */}
<div style={{ position: 'relative', flexShrink: 0 }}>
<div style={{
width: 40,
height: 40,
borderRadius: '50%',
background: `linear-gradient(135deg, ${agent.color}cc, ${agent.color}55)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 18,
border: `1.5px solid ${agent.color}44`,
}}>
{agent.emoji}
</div>
{/* Online dot */}
<div style={{
position: 'absolute',
bottom: 0,
right: 0,
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--system-green)',
border: '1.5px solid var(--bg)',
}} />
</div>
{/* Text content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
marginBottom: 2,
}}>
<span style={{
fontSize: 'var(--text-footnote)',
fontWeight: unread > 0 ? 'var(--weight-bold)' : 'var(--weight-semibold)',
color: 'var(--text-primary)',
letterSpacing: '-0.2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 140,
}}>
{agent.name}
</span>
<span style={{
fontSize: 'var(--text-caption2)',
color: unread > 0 ? 'var(--accent)' : 'var(--text-tertiary)',
flexShrink: 0,
marginLeft: 'var(--space-1)',
}}>
{timeLabel}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{
fontSize: 'var(--text-caption1)',
color: unread > 0 ? 'var(--text-secondary)' : 'var(--text-tertiary)',
fontWeight: unread > 0 ? 'var(--weight-medium)' : 'var(--weight-regular)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}>
{preview}
</span>
{unread > 0 && (
<div style={{
flexShrink: 0,
marginLeft: 'var(--space-2)',
background: 'var(--accent)',
color: '#000',
borderRadius: 10,
minWidth: 20,
height: 20,
padding: '0 6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 'var(--text-caption2)',
fontWeight: 'var(--weight-bold)',
}}>
{unread > 9 ? '9+' : unread}
</div>
)}
</div>
</div>
</button>
)
})
)}
</div>
</div>
)
}
/* Mobile agent list — shown full width on small screens */
export function AgentListMobile({
agents,
conversations,
onSelect,
loading,
}: Omit<AgentListProps, 'activeId'>) {
const [search, setSearch] = useState('')
const filtered = search.trim()
? agents.filter(a => {
const q = search.toLowerCase()
return a.name.toLowerCase().includes(q) || a.title.toLowerCase().includes(q)
})
: agents
const sorted = [...filtered].sort((a, b) => {
const ca = conversations[a.id]
const cb = conversations[b.id]
if (ca && cb) return cb.lastActivity - ca.lastActivity
if (ca) return -1
if (cb) return 1
return a.name.localeCompare(b.name)
})
return (
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
background: 'var(--bg)',
}}>
{/* Header */}
<div style={{
padding: 'var(--space-4) var(--space-4) var(--space-3)',
borderBottom: '1px solid var(--separator)',
background: 'var(--material-thick)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
flexShrink: 0,
}}>
<h2 style={{
fontSize: 'var(--text-title1)',
fontWeight: 'var(--weight-bold)',
letterSpacing: '-0.5px',
color: 'var(--text-primary)',
margin: 0,
}}>
Messages
</h2>
{/* Search */}
<div style={{
marginTop: 'var(--space-3)',
background: 'var(--fill-tertiary)',
borderRadius: 'var(--radius-md)',
padding: '10px var(--space-3)',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
}}>
<svg
width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0 }}
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search agents..."
aria-label="Search agents"
className="focus-ring"
style={{
flex: 1,
fontSize: 'var(--text-subheadline)',
color: 'var(--text-primary)',
background: 'transparent',
border: 'none',
@@ -87,34 +388,37 @@ export function AgentList({ agents, conversations, activeId, onSelect, loading }
</div>
{/* Agent list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }} role="listbox" aria-label="Agent list">
<div style={{ flex: 1, overflowY: 'auto', padding: 'var(--space-1) 0' }} role="listbox" aria-label="Agent list">
{loading ? (
/* Skeleton loaders while agents load */
<div style={{ padding: '4px 0' }} role="status" aria-label="Loading agents">
<div style={{ padding: 'var(--space-1) 0' }} role="status" aria-label="Loading agents">
{[1, 2, 3, 4].map((i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px' }}>
<Skeleton className="rounded-full" style={{ width: 46, height: 46, flexShrink: 0 }} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
<Skeleton style={{ width: '60%', height: 14 }} />
<Skeleton style={{ width: '85%', height: 11 }} />
<div key={i} style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3)',
padding: 'var(--space-3) var(--space-4)',
}}>
<Skeleton className="rounded-full" style={{ width: 44, height: 44, flexShrink: 0 }} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<Skeleton style={{ width: '55%', height: 15 }} />
<Skeleton style={{ width: '80%', height: 12 }} />
</div>
</div>
))}
</div>
) : sorted.length === 0 && search.trim() ? (
/* Empty state for no search results */
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '32px 16px',
padding: 'var(--space-8) var(--space-4)',
textAlign: 'center',
}}>
<div style={{
fontSize: 13,
fontSize: 'var(--text-subheadline)',
color: 'var(--text-tertiary)',
lineHeight: 1.5,
lineHeight: 'var(--leading-relaxed)',
}}>
No agents match &lsquo;{search.trim()}&rsquo;
</div>
@@ -124,11 +428,12 @@ export function AgentList({ agents, conversations, activeId, onSelect, loading }
const conv = conversations[agent.id]
const lastMsg = conv?.messages[conv.messages.length - 1]
const unread = conv?.unread || 0
const isActive = agent.id === activeId
const preview = lastMsg
? lastMsg.content.replace(/[#*`]/g, '').slice(0, 55) + (lastMsg.content.length > 55 ? '\u2026' : '')
: agent.description?.slice(0, 55) || 'Start a conversation'
? (lastMsg.role === 'user' ? 'You: ' : '') +
lastMsg.content.replace(/[#*`]/g, '').slice(0, 60) +
(lastMsg.content.length > 60 ? '\u2026' : '')
: agent.description?.slice(0, 60) || 'Start a conversation'
const timeLabel = lastMsg ? formatTime(lastMsg.timestamp) : ''
@@ -137,96 +442,100 @@ export function AgentList({ agents, conversations, activeId, onSelect, loading }
key={agent.id}
onClick={() => onSelect(agent)}
role="option"
aria-selected={isActive}
aria-selected={false}
className="hover-bg focus-ring"
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 14px',
background: isActive ? 'var(--accent-fill, rgba(255,255,255,0.12))' : 'transparent',
gap: 'var(--space-3)',
padding: 'var(--space-3) var(--space-4)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
transition: 'background 100ms ease',
borderLeft: isActive ? '3px solid var(--accent)' : '3px solid transparent',
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'var(--fill-secondary, rgba(255,255,255,0.06))' }}
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'transparent' }}
>
{/* Avatar */}
<div style={{ position: 'relative', flexShrink: 0 }}>
<div style={{
width: 46,
height: 46,
width: 44,
height: 44,
borderRadius: '50%',
background: `linear-gradient(135deg, ${agent.color}cc, ${agent.color}55)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
boxShadow: isActive ? `0 0 0 2px var(--accent)` : 'none',
border: `2px solid ${agent.color}44`,
border: `1.5px solid ${agent.color}44`,
}}>
{agent.emoji}
</div>
<div style={{
position: 'absolute',
bottom: 1,
right: 1,
width: 12,
height: 12,
bottom: 0,
right: 0,
width: 10,
height: 10,
borderRadius: '50%',
background: 'var(--system-green, #30d158)',
border: '2px solid var(--bg, #000)',
background: 'var(--system-green)',
border: '2px solid var(--bg)',
}} />
</div>
{/* Text content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 2 }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
marginBottom: 2,
}}>
<span style={{
fontSize: 15,
fontWeight: unread > 0 ? 700 : 600,
fontSize: 'var(--text-subheadline)',
fontWeight: unread > 0 ? 'var(--weight-bold)' : 'var(--weight-semibold)',
color: 'var(--text-primary)',
letterSpacing: '-0.2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 130,
}}>
{agent.name}
</span>
<span style={{ fontSize: 11, color: unread > 0 ? 'var(--accent)' : 'var(--text-tertiary)', flexShrink: 0, marginLeft: 4 }}>
<span style={{
fontSize: 'var(--text-caption1)',
color: unread > 0 ? 'var(--accent)' : 'var(--text-tertiary)',
flexShrink: 0,
marginLeft: 'var(--space-1)',
}}>
{timeLabel}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{
fontSize: 13,
fontSize: 'var(--text-footnote)',
color: unread > 0 ? 'var(--text-secondary)' : 'var(--text-tertiary)',
fontWeight: unread > 0 ? 500 : 400,
fontWeight: unread > 0 ? 'var(--weight-medium)' : 'var(--weight-regular)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 160,
flex: 1,
minWidth: 0,
}}>
{lastMsg?.role === 'user' ? 'You: ' : ''}{preview}
{preview}
</span>
{unread > 0 && (
<div style={{
flexShrink: 0,
marginLeft: 6,
marginLeft: 'var(--space-2)',
background: 'var(--accent)',
color: '#000',
borderRadius: '50%',
width: 20,
borderRadius: 10,
minWidth: 20,
height: 20,
padding: '0 6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
fontWeight: 700,
fontSize: 'var(--text-caption2)',
fontWeight: 'var(--weight-bold)',
}}>
{unread > 9 ? '9+' : unread}
</div>
@@ -246,7 +555,7 @@ function formatTime(ts: number): string {
const now = Date.now()
const diff = now - ts
if (diff < 60000) return 'now'
if (diff < 3600000) return `${Math.floor(diff/60000)}m`
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`
if (diff < 86400000) return new Date(ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
+524 -254
View File
@@ -1,5 +1,6 @@
'use client'
import React, { useEffect, useRef, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import type { Agent } from '@/lib/types'
import type { Conversation, ConversationStore, Message, MediaAttachment } from '@/lib/conversations'
import { parseMedia, addMessage, updateLastMessage } from '@/lib/conversations'
@@ -8,33 +9,52 @@ interface ConversationViewProps {
agent: Agent
conversation: Conversation
onUpdate: (agentId: string, updater: (prev: ConversationStore) => ConversationStore) => void
onBack?: () => void
}
/* ── Markdown formatting (from existing chat) ────────────── */
/* ── Markdown rendering ──────────────────────────────────── */
function inlineFormat(text: string): React.ReactNode {
const parts: React.ReactNode[] = []
const regex = /(\*\*(.+?)\*\*|`([^`]+)`|\*([^*]+)\*)/g
// Match URLs, bold, inline code, italic — in priority order
const regex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)}\]'"])|(\*\*(.+?)\*\*)|(`([^`]+)`)|\*([^*]+)\*/g
let last = 0
let match
while ((match = regex.exec(text)) !== null) {
if (match.index > last) parts.push(text.slice(last, match.index))
if (match[0].startsWith('**')) {
parts.push(<strong key={match.index} style={{ fontWeight: 700 }}>{match[2]}</strong>)
} else if (match[0].startsWith('`')) {
if (match[1]) {
// URL
parts.push(
<a
key={match.index}
href={match[1]}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--system-blue)', textDecoration: 'underline', textUnderlineOffset: 2 }}
>
{match[1]}
</a>
)
} else if (match[2]) {
// Bold
parts.push(<strong key={match.index} style={{ fontWeight: 'var(--weight-bold)' }}>{match[3]}</strong>)
} else if (match[4]) {
// Inline code
parts.push(
<code key={match.index} style={{
background: 'rgba(0,0,0,0.35)',
border: '1px solid rgba(255,255,255,0.12)',
background: 'var(--code-bg)',
border: '1px solid var(--code-border)',
borderRadius: 5,
padding: '1px 5px',
fontSize: '0.88em',
fontFamily: 'SF Mono, Menlo, monospace',
}}>{match[3]}</code>
fontFamily: '"SF Mono", Menlo, monospace',
color: 'var(--code-text)',
}}>{match[5]}</code>
)
} else if (match[0].startsWith('*')) {
parts.push(<em key={match.index} style={{ fontStyle: 'italic', opacity: 0.85 }}>{match[4]}</em>)
} else if (match[6]) {
// Italic
parts.push(<em key={match.index} style={{ fontStyle: 'italic', opacity: 0.85 }}>{match[6]}</em>)
}
last = match.index + match[0].length
}
@@ -42,6 +62,30 @@ function inlineFormat(text: string): React.ReactNode {
return parts.length === 1 ? parts[0] : <>{parts}</>
}
function CodeBlock({ code, keyProp }: { code: string; keyProp: number }) {
const [copied, setCopied] = useState(false)
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}
return (
<div key={keyProp} className="code-block-wrapper">
<button
className="code-copy-btn focus-ring"
onClick={handleCopy}
aria-label="Copy code"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<pre><code>{code}</code></pre>
</div>
)
}
function formatMessage(content: string): React.ReactNode {
if (!content) return null
const lines = content.split('\n')
@@ -57,22 +101,7 @@ function formatMessage(content: string): React.ReactNode {
codeLines = []
} else {
inCodeBlock = false
result.push(
<pre key={i} style={{
background: 'rgba(0,0,0,0.4)',
border: '1px solid rgba(255,255,255,0.10)',
borderRadius: 10,
padding: '10px 14px',
fontSize: 12,
fontFamily: 'SF Mono, Menlo, monospace',
overflowX: 'auto',
margin: '6px 0',
color: '#e2e8f0',
lineHeight: 1.6,
}}>
<code>{codeLines.join('\n')}</code>
</pre>
)
result.push(<CodeBlock key={i} keyProp={i} code={codeLines.join('\n')} />)
codeLines = []
}
continue
@@ -81,7 +110,7 @@ function formatMessage(content: string): React.ReactNode {
if (line.trim() === '') { result.push(<div key={`space-${i}`} style={{ height: 6 }} />); continue }
if (line.match(/^[-*] /)) {
result.push(
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 2 }}>
<div key={i} style={{ display: 'flex', gap: 'var(--space-2)', marginBottom: 2 }}>
<span style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 1 }}>&bull;</span>
<span>{inlineFormat(line.slice(2))}</span>
</div>
@@ -91,22 +120,62 @@ function formatMessage(content: string): React.ReactNode {
if (line.match(/^\d+\. /)) {
const num = line.match(/^(\d+)\. /)?.[1]
result.push(
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 2 }}>
<span style={{ color: 'var(--accent)', flexShrink: 0, fontWeight: 600, minWidth: 16 }}>{num}.</span>
<div key={i} style={{ display: 'flex', gap: 'var(--space-2)', marginBottom: 2 }}>
<span style={{ color: 'var(--accent)', flexShrink: 0, fontWeight: 'var(--weight-semibold)', minWidth: 16 }}>{num}.</span>
<span>{inlineFormat(line.replace(/^\d+\. /, ''))}</span>
</div>
)
continue
}
if (line.startsWith('### ')) { result.push(<div key={i} style={{ fontWeight: 600, fontSize: 14, marginTop: 8, marginBottom: 2 }}>{inlineFormat(line.slice(4))}</div>); continue }
if (line.startsWith('## ')) { result.push(<div key={i} style={{ fontWeight: 700, fontSize: 15, marginTop: 10, marginBottom: 3 }}>{inlineFormat(line.slice(3))}</div>); continue }
if (line.startsWith('### ')) {
result.push(
<div key={i} style={{ fontWeight: 'var(--weight-semibold)', fontSize: 'var(--text-footnote)', marginTop: 'var(--space-2)', marginBottom: 2 }}>
{inlineFormat(line.slice(4))}
</div>
)
continue
}
if (line.startsWith('## ')) {
result.push(
<div key={i} style={{ fontWeight: 'var(--weight-bold)', fontSize: 'var(--text-subheadline)', marginTop: 'var(--space-3)', marginBottom: 3 }}>
{inlineFormat(line.slice(3))}
</div>
)
continue
}
if (line.startsWith('# ')) {
result.push(
<div key={i} style={{ fontWeight: 'var(--weight-bold)', fontSize: 'var(--text-body)', marginTop: 'var(--space-3)', marginBottom: 'var(--space-1)' }}>
{inlineFormat(line.slice(2))}
</div>
)
continue
}
result.push(<div key={i} style={{ marginBottom: 1 }}>{inlineFormat(line)}</div>)
}
return <>{result}</>
}
function timeStr(ts: number) {
return new Date(ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
/* ── Timestamp formatting ──────────────────────────────── */
function formatTimestamp(ts: number): string {
const now = new Date()
const date = new Date(ts)
const isToday = now.toDateString() === date.toDateString()
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
const isYesterday = yesterday.toDateString() === date.toDateString()
const time = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
if (isToday) return `Today ${time}`
if (isYesterday) return `Yesterday ${time}`
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ` ${time}`
}
function shouldShowTimestamp(messages: Message[], index: number): boolean {
if (index === 0) return true
const gap = messages[index].timestamp - messages[index - 1].timestamp
return gap > 5 * 60 * 1000 // 5 minutes
}
function shouldShowAvatar(messages: Message[], index: number): boolean {
@@ -116,11 +185,13 @@ function shouldShowAvatar(messages: Message[], index: number): boolean {
/* ── Component ──────────────────────────────────────────── */
export function ConversationView({ agent, conversation, onUpdate }: ConversationViewProps) {
export function ConversationView({ agent, conversation, onUpdate, onBack }: ConversationViewProps) {
const router = useRouter()
const [input, setInput] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const messages = conversation?.messages || []
const messagesRef = useRef(messages)
@@ -135,6 +206,11 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation
const text = input.trim()
setInput('')
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
const userMsg: Message = {
id: crypto.randomUUID(),
role: 'user',
@@ -151,7 +227,6 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation
isStreaming: true,
}
// Add both messages to store
onUpdate(agent.id, prev => {
let next = addMessage(prev, agent.id, userMsg)
next = addMessage(next, agent.id, assistantMsg)
@@ -160,8 +235,7 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation
setIsStreaming(true)
// Build message history for API (role + content only)
// Use ref to read the latest messages and avoid stale closure on concurrent sends
// Use ref to read latest messages (avoids stale closure)
const apiMessages = [...messagesRef.current, userMsg].map(m => ({ role: m.role, content: m.content }))
try {
@@ -198,7 +272,6 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation
}
}
// Mark streaming done
const finalContent = fullContent
onUpdate(agent.id, prev => updateLastMessage(prev, agent.id, assistantMsgId, finalContent, false))
} catch {
@@ -263,67 +336,149 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation
const hasInput = input.trim().length > 0
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg)' }}>
{/* Color stripe */}
<div style={{ height: 3, width: '100%', flexShrink: 0, backgroundColor: agent.color }} />
{/* Header */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
height: '100%',
background: 'var(--bg)',
}}>
{/* ── Header ─────────────────────────────────── */}
<div style={{
background: 'var(--material-regular)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
height: 52,
display: 'flex',
alignItems: 'center',
padding: '0 var(--space-4)',
borderBottom: '1px solid var(--separator)',
background: 'var(--material-thick)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
position: 'sticky',
top: 0,
zIndex: 10,
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px', height: 44 }}>
<div style={{ width: 20 }} />
{/* Mobile back button */}
{onBack && (
<button
onClick={clearChat}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-tertiary)', padding: 4 }}
title="Clear conversation"
aria-label="Clear conversation"
className="md:hidden btn-ghost focus-ring"
onClick={onBack}
aria-label="Back to agents"
style={{
padding: 'var(--space-1) var(--space-2)',
borderRadius: 'var(--radius-sm)',
marginRight: 'var(--space-2)',
fontSize: 'var(--text-subheadline)',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-1)',
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 5h14" /><path d="M8 5V3.5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1V5" />
<path d="M5 5l1 12a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2l1-12" />
<path d="M8.5 9v5" /><path d="M11.5 9v5" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
Back
</button>
</div>
)}
{/* Agent identity */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', paddingBottom: 16, gap: 8 }}>
{/* Agent info */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3)',
flex: 1,
minWidth: 0,
}}>
<div style={{
width: 64, height: 64, borderRadius: '50%',
background: `linear-gradient(135deg, ${agent.color}cc, ${agent.color}66)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 28,
boxShadow: `0 0 0 3px ${agent.color}33, 0 4px 16px rgba(0,0,0,0.4)`,
border: `2px solid ${agent.color}66`,
width: 32,
height: 32,
borderRadius: '50%',
background: `linear-gradient(135deg, ${agent.color}cc, ${agent.color}55)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 14,
flexShrink: 0,
border: `1px solid ${agent.color}44`,
}}>
{agent.emoji}
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 17, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.3px' }}>
<div style={{ minWidth: 0 }}>
<div style={{
fontSize: 'var(--text-subheadline)',
fontWeight: 'var(--weight-semibold)',
color: 'var(--text-primary)',
letterSpacing: '-0.2px',
lineHeight: 1.2,
}}>
{agent.name}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 1 }}>
<div style={{
fontSize: 'var(--text-caption2)',
color: 'var(--text-tertiary)',
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{agent.title}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 6 }}>
<div style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--system-green, #30d158)' }} />
<span style={{ fontSize: 11, color: 'var(--system-green, #30d158)' }}>Active</span>
</div>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-1)' }}>
<button
className="btn-ghost focus-ring"
aria-label="View agent profile"
onClick={() => router.push(`/agents/${agent.id}`)}
style={{
padding: 'var(--space-2)',
borderRadius: 'var(--radius-sm)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</button>
<button
className="btn-ghost focus-ring"
aria-label="Clear conversation"
onClick={clearChat}
style={{
padding: 'var(--space-2)',
borderRadius: 'var(--radius-sm)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</div>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', background: '#000', padding: '20px 16px 80px 16px' }}>
{/* ── Messages ──────────────────────────────── */}
<div style={{
flex: 1,
overflowY: 'auto',
background: 'var(--bg)',
padding: 'var(--space-5) 0 var(--space-16) 0',
}}>
{messages.map((msg, i) => {
const isUser = msg.role === 'user'
const showAvatar = shouldShowAvatar(messages, i)
const showTimestamp = shouldShowTimestamp(messages, i)
const isLastAssistant = !isUser && i === messages.length - 1 && (isStreaming || msg.isStreaming)
const showTypingDots = isLastAssistant && !msg.content
const media = msg.media || parseMedia(msg.content)
// Strip media URLs from text for display
@@ -337,212 +492,327 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation
}
return (
<div key={msg.id || i} style={{ animation: 'fadeIn 0.2s ease' }}>
{i > 0 && <div style={{ height: messages[i - 1].role !== msg.role ? 16 : 3 }} />}
<div key={msg.id || i} className="animate-fade-in">
{/* Timestamp divider */}
{showTimestamp && (
<div style={{
textAlign: 'center',
padding: 'var(--space-3) 0',
fontSize: 'var(--text-caption2)',
color: 'var(--text-tertiary)',
}}>
{formatTimestamp(msg.timestamp)}
</div>
)}
<div style={{
display: 'flex', alignItems: 'flex-end', gap: 8,
justifyContent: isUser ? 'flex-end' : 'flex-start',
}}>
{/* Assistant avatar */}
{!isUser && (
<div style={{ flexShrink: 0, width: 36 }}>
{/* Spacing between role switches */}
{!showTimestamp && i > 0 && (
<div style={{ height: messages[i - 1].role !== msg.role ? 'var(--space-4)' : 'var(--space-1)' }} />
)}
{/* User message */}
{isUser && (
<div style={{
display: 'flex',
justifyContent: 'flex-end',
padding: '0 var(--space-4)',
marginBottom: 'var(--space-1)',
}}>
<div className="msg-user" style={{
maxWidth: '75%',
padding: 'var(--space-3) var(--space-4)',
borderRadius: 'var(--radius-lg) var(--radius-lg) var(--radius-sm) var(--radius-lg)',
background: 'var(--accent)',
color: '#000',
fontSize: 'var(--text-subheadline)',
lineHeight: 'var(--leading-relaxed)',
fontWeight: 'var(--weight-medium)',
boxShadow: 'var(--shadow-subtle)',
}}>
{textContent}
</div>
</div>
)}
{/* Assistant message */}
{!isUser && (
<div style={{
display: 'flex',
justifyContent: 'flex-start',
padding: '0 var(--space-4)',
marginBottom: 'var(--space-1)',
}}>
{/* Small avatar */}
<div style={{
flexShrink: 0,
width: 28,
marginRight: 'var(--space-2)',
}}>
{showAvatar ? (
<div style={{
width: 36, height: 36, borderRadius: '50%',
background: `linear-gradient(135deg, ${agent.color}cc, ${agent.color}66)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 16, boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
border: `1.5px solid ${agent.color}55`,
width: 28,
height: 28,
borderRadius: '50%',
background: `linear-gradient(135deg, ${agent.color}cc, ${agent.color}55)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 13,
border: `1px solid ${agent.color}44`,
}}>
{agent.emoji}
</div>
) : <div style={{ width: 36 }} />}
) : <div style={{ width: 28 }} />}
</div>
)}
{/* Bubble column */}
<div style={{ display: 'flex', flexDirection: 'column', maxWidth: '72%' }}>
{showAvatar && !isUser && (
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-tertiary)', marginBottom: 3, marginLeft: 14 }}>
{agent.name}
</div>
)}
{showAvatar && isUser && (
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-tertiary)', marginBottom: 3, marginRight: 14, textAlign: 'right' }}>
You
</div>
)}
{/* Text bubble */}
{(textContent || isLastAssistant) && (
<div style={{
padding: '10px 14px',
fontSize: 15,
lineHeight: 1.45,
...(isUser
? {
background: 'var(--accent)',
color: '#000',
fontWeight: 500,
borderRadius: '20px 20px 4px 20px',
boxShadow: '0 1px 2px rgba(0,0,0,0.25)',
}
: {
background: 'rgba(255,255,255,0.08)',
border: '1px solid rgba(255,255,255,0.10)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
borderRadius: '20px 20px 20px 4px',
color: '#fff',
}),
}}>
{formatMessage(textContent)}
{isLastAssistant && (
<span style={{ color: 'var(--accent)', animation: 'blink 1s step-end infinite', marginLeft: 2 }}>&#9612;</span>
)}
</div>
)}
{/* Image attachments */}
{media.filter(m => m.type === 'image').map((m, mi) => (
<div key={mi} style={{ marginTop: 6, borderRadius: 16, overflow: 'hidden', maxWidth: 280 }}>
<img
src={m.url}
alt={m.name || 'Image'}
style={{ width: '100%', display: 'block', borderRadius: 16, cursor: 'pointer' }}
onClick={() => window.open(m.url, '_blank')}
/>
</div>
))}
{/* Audio attachments */}
{media.filter(m => m.type === 'audio').map((m, mi) => (
<div key={mi} style={{
marginTop: 6,
background: 'rgba(255,255,255,0.08)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 16,
padding: '10px 14px',
maxWidth: 280,
}}>
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 6 }}>
&#127925; {m.name || 'Audio'}
<div style={{ maxWidth: '75%', display: 'flex', flexDirection: 'column' }}>
{/* Typing indicator */}
{showTypingDots && (
<div className="msg-assistant" style={{
padding: 'var(--space-3) var(--space-4)',
borderRadius: 'var(--radius-sm) var(--radius-lg) var(--radius-lg) var(--radius-lg)',
background: 'var(--material-thin)',
border: '1px solid var(--separator)',
}}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center', height: 16 }}>
<span className="typing-dot" style={{ animationDelay: '0ms' }} />
<span className="typing-dot" style={{ animationDelay: '150ms' }} />
<span className="typing-dot" style={{ animationDelay: '300ms' }} />
</div>
</div>
<audio controls src={m.url} style={{ width: '100%', height: 32 }} />
</div>
))}
)}
{/* Timestamp */}
<span style={{
fontSize: 11, marginTop: 4,
color: 'var(--text-tertiary)',
opacity: 0,
textAlign: isUser ? 'right' : 'left',
paddingLeft: isUser ? 0 : 4,
paddingRight: isUser ? 4 : 0,
transition: 'opacity 200ms ease',
}}
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
onMouseLeave={e => (e.currentTarget.style.opacity = '0')}
>
{timeStr(msg.timestamp)}
</span>
{/* Text bubble */}
{textContent && (
<div className="msg-assistant" style={{
padding: 'var(--space-3) var(--space-4)',
borderRadius: 'var(--radius-sm) var(--radius-lg) var(--radius-lg) var(--radius-lg)',
background: 'var(--material-thin)',
border: '1px solid var(--separator)',
color: 'var(--text-primary)',
fontSize: 'var(--text-subheadline)',
lineHeight: 'var(--leading-relaxed)',
}}>
{formatMessage(textContent)}
{/* Streaming cursor */}
{isLastAssistant && textContent && (
<span style={{
display: 'inline-block',
width: 2,
height: '1.1em',
background: 'var(--accent)',
marginLeft: 2,
animation: 'blink-cursor 1s step-end infinite',
verticalAlign: 'text-bottom',
}} />
)}
</div>
)}
{/* Image attachments */}
{media.filter(m => m.type === 'image').map((m, mi) => (
<div key={mi} style={{
marginTop: 'var(--space-2)',
borderRadius: 'var(--radius-lg)',
overflow: 'hidden',
maxWidth: 280,
}}>
<img
src={m.url}
alt={m.name || 'Image'}
style={{ width: '100%', display: 'block', borderRadius: 'var(--radius-lg)', cursor: 'pointer' }}
onClick={() => window.open(m.url, '_blank')}
/>
</div>
))}
{/* Audio attachments */}
{media.filter(m => m.type === 'audio').map((m, mi) => (
<div key={mi} style={{
marginTop: 'var(--space-2)',
background: 'var(--material-thin)',
border: '1px solid var(--separator)',
borderRadius: 'var(--radius-lg)',
padding: 'var(--space-3) var(--space-4)',
maxWidth: 280,
}}>
<div style={{
fontSize: 'var(--text-caption2)',
color: 'var(--text-tertiary)',
marginBottom: 'var(--space-2)',
}}>
{m.name || 'Audio'}
</div>
<audio controls src={m.url} style={{ width: '100%', height: 32 }} />
</div>
))}
</div>
</div>
</div>
)}
{/* User-side image/audio attachments */}
{isUser && media.length > 0 && (
<div style={{
display: 'flex',
justifyContent: 'flex-end',
padding: '0 var(--space-4)',
marginBottom: 'var(--space-1)',
}}>
<div style={{ maxWidth: '75%' }}>
{media.filter(m => m.type === 'image').map((m, mi) => (
<div key={mi} style={{
marginTop: 'var(--space-2)',
borderRadius: 'var(--radius-lg)',
overflow: 'hidden',
maxWidth: 280,
}}>
<img
src={m.url}
alt={m.name || 'Image'}
style={{ width: '100%', display: 'block', borderRadius: 'var(--radius-lg)', cursor: 'pointer' }}
onClick={() => window.open(m.url, '_blank')}
/>
</div>
))}
{media.filter(m => m.type === 'audio').map((m, mi) => (
<div key={mi} style={{
marginTop: 'var(--space-2)',
background: 'var(--material-thin)',
border: '1px solid var(--separator)',
borderRadius: 'var(--radius-lg)',
padding: 'var(--space-3) var(--space-4)',
maxWidth: 280,
}}>
<div style={{
fontSize: 'var(--text-caption2)',
color: 'var(--text-tertiary)',
marginBottom: 'var(--space-2)',
}}>
{m.name || 'Audio'}
</div>
<audio controls src={m.url} style={{ width: '100%', height: 32 }} />
</div>
))}
</div>
</div>
)}
</div>
)
})}
<div ref={bottomRef} />
</div>
{/* Input area */}
{/* ── Input Area ────────────────────────────── */}
<div style={{
padding: '12px 16px 8px',
flexShrink: 0,
background: 'var(--material-regular)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
padding: 'var(--space-3) var(--space-4)',
borderTop: '1px solid var(--separator)',
background: 'var(--material-regular)',
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
<div style={{
display: 'flex',
alignItems: 'flex-end',
gap: 'var(--space-2)',
background: 'var(--fill-secondary)',
borderRadius: 'var(--radius-lg)',
padding: 'var(--space-2) var(--space-3)',
border: '1px solid var(--separator)',
}}>
{/* Attach button */}
<label style={{ cursor: 'pointer', color: 'var(--text-tertiary)', padding: 8, flexShrink: 0, fontSize: 18 }} title="Attach image" aria-label="Attach file">
&#128206;
<input
type="file"
accept="image/*,audio/*"
style={{ display: 'none' }}
onChange={handleFileAttach}
/>
</label>
<button
className="btn-ghost focus-ring"
aria-label="Attach file"
onClick={() => fileInputRef.current?.click()}
style={{
padding: 'var(--space-1)',
flexShrink: 0,
borderRadius: 'var(--radius-sm)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*,audio/*"
style={{ display: 'none' }}
onChange={handleFileAttach}
/>
{/* Text input */}
<div style={{ flex: 1 }}>
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Message ${agent.name}...`}
rows={1}
disabled={isStreaming}
style={{
width: '100%',
minHeight: 40,
maxHeight: 120,
borderRadius: 22,
background: 'var(--fill-tertiary)',
border: 'none',
color: 'var(--text-primary)',
padding: '10px 16px',
fontSize: 15,
resize: 'none',
outline: 'none',
transition: 'box-shadow 200ms ease',
opacity: isStreaming ? 0.5 : 1,
}}
onInput={e => {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = Math.min(target.scrollHeight, 120) + 'px'
}}
onFocus={e => { e.target.style.boxShadow = '0 0 0 4px rgba(10,132,255,0.25)' }}
onBlur={e => { e.target.style.boxShadow = 'none' }}
/>
</div>
{/* Textarea */}
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Message ${agent.name}...`}
rows={1}
disabled={isStreaming}
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
resize: 'none',
color: 'var(--text-primary)',
fontSize: 'var(--text-subheadline)',
lineHeight: 'var(--leading-normal)',
maxHeight: 120,
minHeight: 24,
padding: '2px 0',
opacity: isStreaming ? 0.5 : 1,
}}
onInput={e => {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = Math.min(target.scrollHeight, 120) + 'px'
}}
/>
{/* Send button */}
<div style={{
flexShrink: 0,
marginBottom: 2,
opacity: hasInput ? 1 : 0,
transform: hasInput ? 'scale(1)' : 'scale(0.6)',
pointerEvents: hasInput ? 'auto' : 'none',
transition: 'all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}>
<button
onClick={sendMessage}
disabled={isStreaming || !hasInput}
style={{
width: 36, height: 36, borderRadius: '50%',
background: 'var(--accent)', color: '#000',
border: 'none', cursor: 'pointer',
fontSize: 18, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'transform 150ms ease',
}}
title="Send message"
aria-label="Send message"
>
&#8593;
</button>
</div>
<button
className="focus-ring"
onClick={sendMessage}
disabled={!hasInput || isStreaming}
aria-label="Send message"
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: hasInput ? 'var(--accent)' : 'var(--fill-tertiary)',
color: hasInput ? '#000' : 'var(--text-quaternary)',
border: 'none',
cursor: hasInput ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 16,
fontWeight: 'var(--weight-bold)',
transition: 'all 150ms var(--ease-smooth)',
flexShrink: 0,
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</button>
</div>
<p style={{ fontSize: 11, textAlign: 'center', marginTop: 8, marginBottom: 2, color: 'var(--text-quaternary)' }}>
{/* Hint */}
<div style={{
fontSize: 'var(--text-caption2)',
color: 'var(--text-quaternary)',
textAlign: 'center',
marginTop: 'var(--space-1)',
}}>
Enter to send &middot; Shift+Enter for newline
</p>
</div>
</div>
</div>
)
+10 -10
View File
@@ -5,26 +5,26 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--system-blue)]",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-[var(--accent)] text-black font-semibold btn-scale hover:shadow-[0_0_24px_rgba(245,197,24,0.35)]",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-[var(--system-red)] text-white btn-scale",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"border border-[var(--separator)] bg-transparent shadow-[var(--shadow-subtle)] hover:bg-[var(--fill-secondary)] hover:text-[var(--text-primary)]",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
"bg-[var(--accent-fill)] text-[var(--accent)] font-semibold btn-scale",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
"bg-transparent text-[var(--text-secondary)] hover:bg-[var(--fill-secondary)] hover:text-[var(--text-primary)]",
link: "text-[var(--system-blue)] underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
default: "h-9 px-4 py-2 text-sm has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
sm: "h-8 rounded-md gap-1.5 px-3 text-sm has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 text-base has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
+17 -3
View File
@@ -1,10 +1,24 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
width?: number | string
height?: number | string
}
function Skeleton({ className, width, height, style, ...props }: SkeletonProps) {
return (
<div
className={cn("animate-pulse rounded-md", className)}
style={{ background: 'var(--fill-secondary)' }}
className={cn(
"animate-shimmer rounded-md",
className
)}
style={{
background: `linear-gradient(90deg, var(--fill-secondary) 25%, var(--fill-tertiary) 50%, var(--fill-secondary) 75%)`,
backgroundSize: '200% 100%',
width,
height,
...style,
}}
{...props}
/>
)
+24
View File
@@ -0,0 +1,24 @@
// Semantic style objects for TypeScript components
// Use when CSS classes aren't practical (dynamic styles)
export const typography = {
largeTitle: { fontSize: 'var(--text-large-title)', fontWeight: 'var(--weight-bold)', letterSpacing: 'var(--tracking-tight)', lineHeight: 'var(--leading-tight)' },
title1: { fontSize: 'var(--text-title1)', fontWeight: 'var(--weight-bold)', letterSpacing: 'var(--tracking-tight)', lineHeight: 'var(--leading-tight)' },
title2: { fontSize: 'var(--text-title2)', fontWeight: 'var(--weight-semibold)', letterSpacing: 'var(--tracking-normal)', lineHeight: 'var(--leading-snug)' },
title3: { fontSize: 'var(--text-title3)', fontWeight: 'var(--weight-semibold)', letterSpacing: 'var(--tracking-normal)', lineHeight: 'var(--leading-snug)' },
body: { fontSize: 'var(--text-body)', fontWeight: 'var(--weight-regular)', lineHeight: 'var(--leading-normal)' },
subheadline: { fontSize: 'var(--text-subheadline)', fontWeight: 'var(--weight-regular)', lineHeight: 'var(--leading-normal)' },
footnote: { fontSize: 'var(--text-footnote)', fontWeight: 'var(--weight-regular)', lineHeight: 'var(--leading-normal)' },
caption1: { fontSize: 'var(--text-caption1)', fontWeight: 'var(--weight-regular)', lineHeight: 'var(--leading-normal)' },
caption2: { fontSize: 'var(--text-caption2)', fontWeight: 'var(--weight-regular)', lineHeight: 'var(--leading-normal)' },
sectionHeader: { fontSize: 'var(--text-caption2)', fontWeight: 'var(--weight-semibold)', letterSpacing: 'var(--tracking-wide)', textTransform: 'uppercase' as const, color: 'var(--text-tertiary)' },
} as const
export const layout = {
sidebarWidth: 220,
detailPanelWidth: 360,
chatSidebarWidth: 300,
memorySidebarWidth: 260,
maxContentWidth: 1200,
headerHeight: 52,
} as const