mirror of
https://github.com/JohnRiceML/clawport-ui.git
synced 2026-08-14 00:47:50 +00:00
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:
co-authored by
Claude Opus 4.6
parent
28c4269539
commit
32f5b403b0
+683
-227
@@ -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)",
|
||||
}}
|
||||
>
|
||||
← 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",
|
||||
}}
|
||||
>
|
||||
← 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 →
|
||||
</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)" }}>→</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)" }}>→</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 →
|
||||
</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
@@ -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 }}>🏰</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
@@ -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"
|
||||
>
|
||||
↻
|
||||
</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"
|
||||
>
|
||||
›
|
||||
</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
@@ -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
@@ -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
@@ -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,
|
||||
}}
|
||||
>
|
||||
↻
|
||||
<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 · Modified{" "}
|
||||
{timeAgo(selected.lastModified)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{words.toLocaleString()} words ·{" "}
|
||||
{lineCount} lines · 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
File diff suppressed because it is too large
Load Diff
+88
-100
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ‘{query}’
|
||||
</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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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">🔍</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 ‘{search.trim()}’
|
||||
</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 ‘{search.trim()}’
|
||||
</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' })
|
||||
}
|
||||
|
||||
@@ -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 }}>•</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 }}>▌</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 }}>
|
||||
🎵 {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">
|
||||
📎
|
||||
<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"
|
||||
>
|
||||
↑
|
||||
</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 · Shift+Enter for newline
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+10
-10
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user