feat(frontend): apply Africa branding and holographic UI improvements
- Migrate UI from picoclaw_v1 with 'Africa' branding (JARVIS -> Africa) - Add motion library for component animations - Complete theme overhaul: dark navy (#0a0e17) background, cyan (#00bcff) accents - 14 new CSS animations: pulse-glow, orb-rotate, scan-line, holographic-pulse - Add BackgroundOrb and scan line overlay to chat page - Redesign cockpit with 7 holographic components - Add cyan holographic styling to memory-graph, agents, skills pages - Apply glassmorphism effects throughout
This commit is contained in:
parent
30b0dfdc04
commit
1c6616366a
9 changed files with 1272 additions and 446 deletions
|
|
@ -8,7 +8,7 @@
|
|||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PicoClaw</title>
|
||||
<title>Africa</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@tabler/icons-react": "^3.43.0",
|
||||
"motion": "^12.0.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"@tanstack/react-router": "^1.169.2",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useDeferredValue, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { motion } from "motion/react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { IconPlus } from "@tabler/icons-react"
|
||||
|
||||
import { AgentCard } from "./agent-card"
|
||||
import { AgentFormModal } from "./agent-form-modal"
|
||||
|
|
@ -60,7 +62,14 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
|
|||
if (agentsQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading agents...</p>
|
||||
<div className="flex items-center gap-2 text-cyan-100/50">
|
||||
<motion.div
|
||||
className="w-2 h-2 rounded-full bg-[#00bcff]"
|
||||
animate={{ opacity: [0.3, 1, 0.3] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
/>
|
||||
Loading agents...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -68,7 +77,7 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
|
|||
if (agentsQuery.isError) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<p className="text-destructive">Failed to load agents. Please try again.</p>
|
||||
<p className="text-red-400">Failed to load agents. Please try again.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -107,7 +116,6 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
|
|||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to update agent",
|
||||
)
|
||||
// Refresh to revert the UI state
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] })
|
||||
}
|
||||
}
|
||||
|
|
@ -121,7 +129,7 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
|
|||
}
|
||||
|
||||
const mainContent = (
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
<div className="w-full">
|
||||
{/* Header Controls */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="max-w-md flex-1">
|
||||
|
|
@ -129,27 +137,28 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
|
|||
placeholder={t("common.search", "Search agents...")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="bg-background"
|
||||
className="bg-black/40 border-[#00bcff]/20 text-cyan-100 placeholder:text-cyan-100/30 focus:border-[#00bcff] focus:ring-[#00bcff]/20 backdrop-blur-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="bg-[#F27D26] hover:bg-[#F27D26]/90 text-black"
|
||||
className="bg-[#00bcff]/15 hover:bg-[#00bcff]/25 text-[#00bcff] border border-[#00bcff]/30 hover:border-[#00bcff] shadow-[0_0_15px_rgba(0,188,255,0.1)] transition-all"
|
||||
>
|
||||
{t("pages.agent.agents.create_agent", "+ Create Agent")}
|
||||
<IconPlus className="w-4 h-4 mr-1" />
|
||||
{t("pages.agent.agents.create_agent", "Create Agent")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Agent Grid */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
|
||||
{filteredAgents.length === 0 ? (
|
||||
<div className="col-span-full rounded-lg border border-dashed border-white/10 p-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
<div className="col-span-full rounded-xl border border-dashed border-[#00bcff]/15 p-12 text-center bg-black/20 backdrop-blur-sm">
|
||||
<p className="text-cyan-100/50">
|
||||
{deferredSearchQuery
|
||||
? t("pages.agent.agents.no_results", "No agents found")
|
||||
: t("pages.agent.agents.no_agents", "No agents yet")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground/60">
|
||||
<p className="mt-2 text-[11px] text-cyan-100/30">
|
||||
{deferredSearchQuery
|
||||
? t("pages.agent.agents.no_results_hint", "Try a different search")
|
||||
: t("pages.agent.agents.no_agents_hint", "Create an agent to get started")}
|
||||
|
|
@ -198,23 +207,23 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
|
|||
if (!open) setAgentToDelete(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogContent className="bg-[#0a0e27] border-[#00bcff]/20 text-cyan-100 shadow-[0_0_40px_#00bcff1a]">
|
||||
<AlertDialogTitle>
|
||||
{t("pages.agent.agents.confirm_delete", "Delete Agent?")}
|
||||
</AlertDialogTitle>
|
||||
<p className="text-muted-foreground">
|
||||
<p className="text-cyan-100/50">
|
||||
{t(
|
||||
"pages.agent.agents.confirm_delete_message",
|
||||
`Are you sure you want to delete "${agentToDelete?.name}"? This action cannot be undone.`,
|
||||
)}
|
||||
</p>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<AlertDialogCancel className="border-[#00bcff]/20 text-cyan-100/60 hover:text-cyan-100 hover:bg-[#00bcff]/5">
|
||||
{t("common.cancel", "Cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className="bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/30"
|
||||
>
|
||||
{t("common.delete", "Delete")}
|
||||
</AlertDialogAction>
|
||||
|
|
@ -234,7 +243,7 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-full flex-col">
|
||||
<div className="bg-[#0a0e27] flex h-full flex-col">
|
||||
<PageHeader title={t("navigation.agents", "Agents")} />
|
||||
<div className="flex-1 overflow-auto px-6 py-6 pb-20">
|
||||
{mainContent}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import dayjs from "dayjs"
|
||||
import { useMemo } from "react"
|
||||
import { IconArrowRight, IconLayoutDashboard, IconUsers, IconBrain, IconFlask } from "@tabler/icons-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import {
|
||||
IconLayoutDashboard, IconUsers, IconBrain, IconFlask,
|
||||
IconCircleDot, IconActivity, IconCpu, IconDatabase, IconBolt,
|
||||
IconShield, IconTerminal, IconWifi, IconLock, IconTrendingUp,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import { usePicoChat } from "@/hooks/use-pico-chat"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
|
@ -13,23 +18,439 @@ import { AgentsPage } from "../agents"
|
|||
import { SkillsPage } from "../skills"
|
||||
import { ResearchPage } from "../research/research-page"
|
||||
|
||||
function reasonLabel(reasonCode?: string) {
|
||||
switch (reasonCode) {
|
||||
case "requires_subagent":
|
||||
return "Requires subagent runtime"
|
||||
case "requires_skills":
|
||||
return "Requires skills support"
|
||||
case "requires_mcp_discovery":
|
||||
return "Requires MCP discovery"
|
||||
case "requires_linux":
|
||||
return "Linux only"
|
||||
case "requires_serial_platform":
|
||||
return "Unsupported serial platform"
|
||||
default:
|
||||
return reasonCode ?? ""
|
||||
}
|
||||
const TAB_CONFIG = [
|
||||
{ key: "tools" as const, icon: IconLayoutDashboard, label: "Tools" },
|
||||
{ key: "skills" as const, icon: IconBrain, label: "Skills" },
|
||||
{ key: "agents" as const, icon: IconUsers, label: "Agents" },
|
||||
{ key: "research" as const, icon: IconFlask, label: "Research" },
|
||||
]
|
||||
|
||||
/* ─── 3D Holographic Circle Component ─── */
|
||||
function HolographicCircle() {
|
||||
const [rotation, setRotation] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setRotation((prev) => (prev + 0.5) % 360)
|
||||
}, 50)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full flex items-center justify-center">
|
||||
{/* Outer Glow */}
|
||||
<motion.div
|
||||
animate={{ opacity: [0.3, 0.6, 0.3], scale: [1, 1.05, 1] }}
|
||||
transition={{ duration: 3, repeat: Infinity }}
|
||||
className="absolute inset-0 rounded-full bg-[#00bcff]/20 blur-3xl"
|
||||
/>
|
||||
|
||||
{/* Rotating Rings - 3D Perspective */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ transform: "perspective(1000px) rotateX(65deg)" }}
|
||||
>
|
||||
{[0, 1, 2, 3, 4].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute inset-0 rounded-full border-2 border-[#00bcff]/40 shadow-[0_0_20px_#00bcff4d]"
|
||||
style={{
|
||||
transform: `rotateZ(${rotation + index * 20}deg) scale(${1 - index * 0.15})`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Inner Core */}
|
||||
<div className="absolute inset-[35%] rounded-full bg-gradient-to-br from-[#00bcff]/40 to-blue-500/40 backdrop-blur-sm shadow-[0_0_50px_#00bcff80,inset_0_0_30px_#00bcff4d]" />
|
||||
|
||||
{/* Scanning Line */}
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `conic-gradient(from ${rotation}deg, transparent 0deg, #00bcff99 10deg, transparent 20deg)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center Display */}
|
||||
<div className="relative z-10 flex flex-col items-center justify-center" style={{ transform: "perspective(1000px) rotateX(0deg)" }}>
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.1, 1], opacity: [0.8, 1, 0.8] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-b from-cyan-300 to-blue-500"
|
||||
>
|
||||
AI
|
||||
</motion.div>
|
||||
<div className="text-[#00bcff] text-[10px] tracking-[0.3em] mt-2 uppercase">Core Online</div>
|
||||
|
||||
{/* Data Points around circle */}
|
||||
{[0, 45, 90, 135, 180, 225, 270, 315].map((angle) => (
|
||||
<motion.div
|
||||
key={angle}
|
||||
className="absolute w-1.5 h-1.5 rounded-full bg-[#00bcff]"
|
||||
style={{
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: `translate(-50%, -50%) rotate(${angle}deg) translateY(-100px)`,
|
||||
}}
|
||||
animate={{ opacity: [0.3, 1, 0.3], scale: [0.8, 1.2, 0.8] }}
|
||||
transition={{ duration: 2, repeat: Infinity, delay: angle / 360 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Holographic Scan Lines */}
|
||||
<div className="absolute inset-0 overflow-hidden rounded-full pointer-events-none">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-b from-transparent via-[#00bcff]/10 to-transparent"
|
||||
animate={{ y: ["-100%", "100%"] }}
|
||||
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Particle Field Component ─── */
|
||||
function ParticleField() {
|
||||
const particles = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 40 }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
size: Math.random() * 3 + 1,
|
||||
duration: Math.random() * 10 + 10,
|
||||
delay: Math.random() * 5,
|
||||
})),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{particles.map((p) => (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
className="absolute rounded-full bg-[#00bcff]/40"
|
||||
style={{
|
||||
left: `${p.x}%`,
|
||||
top: `${p.y}%`,
|
||||
width: p.size,
|
||||
height: p.size,
|
||||
}}
|
||||
animate={{
|
||||
y: [0, -100, 0],
|
||||
opacity: [0, 1, 0],
|
||||
scale: [0, 1, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: p.duration,
|
||||
repeat: Infinity,
|
||||
delay: p.delay,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Connection Lines */}
|
||||
<svg className="absolute inset-0 w-full h-full">
|
||||
{Array.from({ length: 15 }, (_, i) => (
|
||||
<motion.line
|
||||
key={i}
|
||||
x1={`${Math.random() * 100}%`}
|
||||
y1={`${Math.random() * 100}%`}
|
||||
x2={`${Math.random() * 100}%`}
|
||||
y2={`${Math.random() * 100}%`}
|
||||
stroke="#00bcff1a"
|
||||
strokeWidth="1"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: [0, 1, 0], opacity: [0, 0.5, 0] }}
|
||||
transition={{ duration: 3, repeat: Infinity, delay: i * 0.2 }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Metric Card Component ─── */
|
||||
function MetricCard({ icon, label, value, trend }: { icon: React.ReactNode; label: string; value: string; trend: string }) {
|
||||
const isPositive = trend.startsWith("+")
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="relative rounded-xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-4 overflow-hidden shadow-[0_0_20px_#00bcff1a]"
|
||||
>
|
||||
{/* Animated Background */}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-br from-[#00bcff]/5 to-blue-500/5"
|
||||
animate={{ opacity: [0.3, 0.5, 0.3] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="p-2 rounded-lg bg-[#00bcff]/10 text-[#00bcff]">{icon}</div>
|
||||
<motion.div
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
className={`text-xs ${isPositive ? "text-green-400" : "text-red-400"}`}
|
||||
>
|
||||
{trend}
|
||||
</motion.div>
|
||||
</div>
|
||||
<div className="text-[10px] text-cyan-300/60 tracking-wider mb-1 uppercase">{label}</div>
|
||||
<div className="text-2xl font-bold text-cyan-100">{value}</div>
|
||||
</div>
|
||||
|
||||
{/* Scan Line */}
|
||||
<motion.div
|
||||
className="absolute inset-x-0 h-px bg-gradient-to-r from-transparent via-[#00bcff]/50 to-transparent"
|
||||
animate={{ y: [0, 100] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
|
||||
{/* Corner Accent */}
|
||||
<div className="absolute bottom-0 right-0 w-16 h-16 border-r border-b border-[#00bcff]/20" />
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Data Panel Component ─── */
|
||||
function DataPanel({ title, items }: {
|
||||
title: string
|
||||
items: Array<{ label: string; status: string; progress: number }>
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="rounded-2xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-5 overflow-hidden relative shadow-[0_0_30px_#00bcff1a,inset_0_0_20px_#00bcff0d]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-cyan-300 text-sm tracking-widest font-semibold">{title}</h3>
|
||||
<div className="flex gap-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="w-1 h-3 bg-[#00bcff]/60 rounded-full"
|
||||
animate={{ height: ["12px", "20px", "12px"] }}
|
||||
transition={{ duration: 1, repeat: Infinity, delay: i * 0.2 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.label}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-cyan-100/80 text-xs">{item.label}</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
item.status === "ACTIVE" || item.status === "OPTIMAL"
|
||||
? "bg-green-400/20 text-green-400"
|
||||
: item.status === "PROCESSING"
|
||||
? "bg-yellow-400/20 text-yellow-400"
|
||||
: "bg-[#00bcff]/20 text-[#00bcff]"
|
||||
}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="relative h-1.5 bg-cyan-950/50 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${item.progress}%` }}
|
||||
transition={{ duration: 1, delay: index * 0.1 + 0.3 }}
|
||||
className="absolute inset-y-0 left-0 bg-gradient-to-r from-cyan-500 to-blue-500 rounded-full shadow-[0_0_10px_#00bcff99]"
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-y-0 left-0 right-0 bg-gradient-to-r from-transparent via-white/30 to-transparent"
|
||||
animate={{ x: ["-100%", "200%"] }}
|
||||
transition={{ duration: 2, repeat: Infinity, delay: index * 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Decorative Corner */}
|
||||
<div className="absolute top-0 right-0 w-20 h-20 border-r border-t border-[#00bcff]/20 pointer-events-none" />
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Circular Progress Component ─── */
|
||||
function CircularProgress({ icon, label, percentage, color = "cyan" }: {
|
||||
icon: React.ReactNode; label: string; percentage: number; color?: "cyan" | "blue" | "green"
|
||||
}) {
|
||||
const radius = 70
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const offset = circumference - (percentage / 100) * circumference
|
||||
|
||||
const colorMap = {
|
||||
cyan: { stroke: "#00bcff", glow: "#00bcff99", bg: "#00bcff1a" },
|
||||
blue: { stroke: "#0080ff", glow: "#0080ff99", bg: "#0080ff1a" },
|
||||
green: { stroke: "#00ff88", glow: "#00ff8899", bg: "#00ff881a" },
|
||||
}
|
||||
const colors = colorMap[color]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="relative rounded-2xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-5 flex flex-col items-center justify-center shadow-[0_0_30px_#00bcff1a]"
|
||||
>
|
||||
<div className="relative">
|
||||
<svg width="140" height="140" className="transform -rotate-90">
|
||||
<circle cx="70" cy="70" r={radius} stroke={colors.bg} strokeWidth="8" fill="none" />
|
||||
<motion.circle
|
||||
cx="70" cy="70" r={radius} stroke={colors.stroke} strokeWidth="8" fill="none"
|
||||
strokeLinecap="round" strokeDasharray={circumference}
|
||||
initial={{ strokeDashoffset: circumference }} animate={{ strokeDashoffset: offset }}
|
||||
transition={{ duration: 1.5, ease: "easeOut" }}
|
||||
style={{ filter: `drop-shadow(0 0 8px ${colors.glow})` }}
|
||||
/>
|
||||
<motion.circle
|
||||
cx="70" cy="70" r={radius} stroke={colors.stroke} strokeWidth="12" fill="none"
|
||||
strokeLinecap="round" strokeDasharray={circumference}
|
||||
initial={{ strokeDashoffset: circumference }} animate={{ strokeDashoffset: offset }}
|
||||
transition={{ duration: 1.5, ease: "easeOut" }} opacity="0.3"
|
||||
style={{ filter: "blur(8px)" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="text-[#00bcff] mb-1"
|
||||
>
|
||||
{icon}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }} animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.5, type: "spring" }}
|
||||
className="text-2xl font-bold text-cyan-100"
|
||||
>
|
||||
{percentage}%
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-cyan-300 text-xs tracking-[0.2em] uppercase">{label}</div>
|
||||
<div className="absolute top-3 left-3 right-3 h-px bg-gradient-to-r from-transparent via-[#00bcff]/30 to-transparent" />
|
||||
<div className="absolute bottom-3 left-3 right-3 h-px bg-gradient-to-r from-transparent via-[#00bcff]/30 to-transparent" />
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── System Status Grid Component ─── */
|
||||
function SystemStatusGrid() {
|
||||
const statusItems = [
|
||||
{ icon: <IconWifi className="size-4" />, label: "CONNECTION", value: "STABLE", status: "good" },
|
||||
{ icon: <IconDatabase className="size-4" />, label: "STORAGE", value: "67% FREE", status: "good" },
|
||||
{ icon: <IconLock className="size-4" />, label: "SECURITY", value: "ENCRYPTED", status: "good" },
|
||||
{ icon: <IconTrendingUp className="size-4" />, label: "PERFORMANCE", value: "OPTIMAL", status: "good" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{statusItems.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.label}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="relative rounded-xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-3 overflow-hidden group shadow-[0_0_20px_#00bcff1a]"
|
||||
>
|
||||
{/* Hover Glow */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#00bcff]/0 to-blue-500/0 group-hover:from-[#00bcff]/10 group-hover:to-blue-500/10 transition-all duration-300" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="p-1.5 rounded bg-[#00bcff]/10 text-[#00bcff]">{item.icon}</div>
|
||||
<motion.div
|
||||
className="w-2 h-2 rounded-full bg-green-400 shadow-[0_0_8px_#00ff88cc]"
|
||||
animate={{ opacity: [0.5, 1, 0.5], scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[9px] text-cyan-300/60 tracking-wider mb-1 uppercase">{item.label}</div>
|
||||
<div className="text-xs font-semibold text-cyan-100">{item.value}</div>
|
||||
</div>
|
||||
|
||||
{/* Scan Effect */}
|
||||
<motion.div
|
||||
className="absolute inset-x-0 h-px bg-gradient-to-r from-transparent via-[#00bcff]/50 to-transparent"
|
||||
animate={{ y: [0, 60] }}
|
||||
transition={{ duration: 2, repeat: Infinity, delay: index * 0.3 }}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Terminal Output Component ─── */
|
||||
function TerminalOutput() {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="flex-1 rounded-2xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-4 overflow-hidden shadow-[0_0_30px_#00bcff1a,inset_0_0_20px_#00bcff0d]"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<IconTerminal className="size-4 text-[#00bcff]" />
|
||||
<span className="text-cyan-300 text-xs tracking-wider">TERMINAL</span>
|
||||
</div>
|
||||
<div className="space-y-1 font-mono text-xs">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.7 }}
|
||||
className="text-[#00bcff]/60"
|
||||
>
|
||||
> Initializing core systems...
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.8 }}
|
||||
className="text-green-400/60"
|
||||
>
|
||||
> All systems operational
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.9 }}
|
||||
className="text-[#00bcff]/60"
|
||||
>
|
||||
> Awaiting commands...
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ opacity: [0, 1, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
className="text-[#00bcff] inline-block"
|
||||
>
|
||||
|
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
MAIN COCKPIT PAGE
|
||||
═══════════════════════════════════════════════ */
|
||||
export function CockpitPage() {
|
||||
const { activeSessionId } = usePicoChat()
|
||||
const {
|
||||
|
|
@ -48,141 +469,291 @@ export function CockpitPage() {
|
|||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-[#050505] text-[#F2F2F2] selection:bg-[#F27D26] selection:text-black font-sans relative">
|
||||
{/* Ghost Background Typography */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none select-none opacity-[0.05]">
|
||||
<span className="absolute -left-20 -top-10 text-[35vw] font-black leading-none uppercase">COCKPIT</span>
|
||||
<span className="absolute -right-20 -bottom-20 text-[25vw] font-black leading-none uppercase">SYSTEM</span>
|
||||
</div>
|
||||
<div className="relative size-full bg-[#0a0e27] overflow-hidden text-[#e0f2fe] font-sans">
|
||||
{/* ── 3D Perspective Grid Background ── */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(#00bcff08_1px,transparent_1px),linear-gradient(90deg,#00bcff08_1px,transparent_1px)] bg-[size:50px_50px] [transform:perspective(1000px)_rotateX(60deg)] origin-center opacity-40" />
|
||||
|
||||
{/* Header */}
|
||||
<header className="flex justify-between items-start border-b border-white/10 p-6 md:px-12 md:py-8 z-10">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Terminal Status</span>
|
||||
<span className="text-xs opacity-60 font-mono tracking-tighter">CONNECTED / {dayjs().format('DD.MM.YYYY')}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-right">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Runtime Epoch</span>
|
||||
<span className="text-xs opacity-60 font-mono tracking-tighter">{dayjs().format('HH:mm')} GMT+1</span>
|
||||
</div>
|
||||
</header>
|
||||
{/* ── Particle Field ── */}
|
||||
<ParticleField />
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 py-6 md:px-12 md:py-10 z-10">
|
||||
<div className="mx-auto grid w-full max-w-[1600px] gap-12 xl:grid-cols-[1fr_380px]">
|
||||
{/* ── Scanline Effect ── */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(transparent_50%,#00bcff08_50%)] bg-[size:100%_4px] pointer-events-none animate-[scan_8s_linear_infinite]" />
|
||||
|
||||
{/* Main Workspace */}
|
||||
<div className="space-y-16">
|
||||
{/* Hero Section */}
|
||||
<div className="relative">
|
||||
<h1 className="text-[6vw] leading-[0.85] font-black tracking-[-0.07em] uppercase m-0 p-0 text-[#F2F2F2]">
|
||||
AGENT INTERFACE
|
||||
</h1>
|
||||
<p className="mt-8 text-xl font-light max-w-xl opacity-60 leading-relaxed border-l-2 border-[#F27D26] pl-6">
|
||||
Active control node for autonomous agents. Managing tool surfaces, memory networks.
|
||||
</p>
|
||||
{/* ── Corner Accents ── */}
|
||||
<div className="absolute top-0 left-0 w-32 h-32 border-l-2 border-t-2 border-[#00bcff]/30 pointer-events-none" />
|
||||
<div className="absolute top-0 right-0 w-32 h-32 border-r-2 border-t-2 border-[#00bcff]/30 pointer-events-none" />
|
||||
<div className="absolute bottom-0 left-0 w-32 h-32 border-l-2 border-b-2 border-[#00bcff]/30 pointer-events-none" />
|
||||
<div className="absolute bottom-0 right-0 w-32 h-32 border-r-2 border-b-2 border-[#00bcff]/30 pointer-events-none" />
|
||||
|
||||
{/* ── Main Content ── */}
|
||||
<div className="relative z-10 size-full p-6 flex flex-col">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center justify-between mb-6"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 via-blue-400 to-cyan-300 tracking-wider">
|
||||
AFRICA
|
||||
</h1>
|
||||
<p className="text-cyan-300/60 text-xs mt-1 tracking-[0.3em]">
|
||||
ADVANCED FRAMEWORK FOR INTELLIGENT SYSTEM
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="px-5 py-2.5 rounded-full border border-[#00bcff]/30 bg-[#00bcff]/5 backdrop-blur-sm shadow-[0_0_20px_#00bcff4d]">
|
||||
<span className="text-cyan-300 text-xs tracking-wider">SYSTEMS ONLINE</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 border-b border-white/10 pb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab("tools")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs uppercase tracking-widest font-bold transition-colors",
|
||||
activeTab === "tools" ? "text-[#F27D26] border-b-2 border-[#F27D26] pb-4 -mb-4.5" : "text-white/40 hover:text-white/60"
|
||||
)}
|
||||
>
|
||||
<IconLayoutDashboard className="size-4" />
|
||||
Tools
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("skills")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs uppercase tracking-widest font-bold transition-colors",
|
||||
activeTab === "skills" ? "text-[#F27D26] border-b-2 border-[#F27D26] pb-4 -mb-4.5" : "text-white/40 hover:text-white/60"
|
||||
)}
|
||||
>
|
||||
<IconBrain className="size-4" />
|
||||
Skills
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("agents")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs uppercase tracking-widest font-bold transition-colors",
|
||||
activeTab === "agents" ? "text-[#F27D26] border-b-2 border-[#F27D26] pb-4 -mb-4.5" : "text-white/40 hover:text-white/60"
|
||||
)}
|
||||
>
|
||||
<IconUsers className="size-4" />
|
||||
Agents
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("research")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs uppercase tracking-widest font-bold transition-colors",
|
||||
activeTab === "research" ? "text-[#F27D26] border-b-2 border-[#F27D26] pb-4 -mb-4.5" : "text-white/40 hover:text-white/60"
|
||||
)}
|
||||
>
|
||||
<IconFlask className="size-4" />
|
||||
Research
|
||||
</button>
|
||||
<div className="text-[10px] font-mono text-cyan-300/40">
|
||||
{dayjs().format("DD.MM.YYYY")} / {dayjs().format("HH:mm")} UTC
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{activeTab === "skills" && <SkillsPage embedded />}
|
||||
{/* Tab Navigation */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="flex items-center gap-1 p-1 rounded-xl border border-[#00bcff]/20 bg-black/30 backdrop-blur-sm shadow-[0_0_15px_#00bcff1a] mb-6"
|
||||
>
|
||||
{TAB_CONFIG.map(({ key, icon: Icon, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveTab(key)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-5 py-2.5 rounded-lg text-[11px] uppercase tracking-widest font-bold transition-all duration-300",
|
||||
activeTab === key
|
||||
? "bg-[#00bcff]/15 text-[#00bcff] shadow-[0_0_15px_rgba(0,188,255,0.15)] border border-[#00bcff]/30"
|
||||
: "text-[#94a3b8] hover:text-[#e0f2fe] hover:bg-[#00bcff]/5 border border-transparent"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{activeTab === "agents" && <AgentsPage embedded />}
|
||||
{/* ── Main Grid Layout ── */}
|
||||
<div className="flex-1 grid grid-cols-12 gap-5 overflow-hidden">
|
||||
{/* Left Column - Holographic Display + System Status */}
|
||||
{activeTab === "tools" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="col-span-4 flex flex-col gap-5 overflow-y-auto"
|
||||
>
|
||||
{/* Main Holographic Circle */}
|
||||
<div className="relative aspect-square max-h-[320px]">
|
||||
<HolographicCircle />
|
||||
</div>
|
||||
|
||||
{activeTab === "research" && <ResearchPage />}
|
||||
{/* System Status Cards */}
|
||||
<SystemStatusGrid />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Center Column - Main Content Area */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className={cn(
|
||||
"flex flex-col gap-5 overflow-y-auto",
|
||||
activeTab === "tools" ? "col-span-5" : "col-span-12"
|
||||
)}
|
||||
>
|
||||
{/* Tools Tab Content */}
|
||||
{activeTab === "tools" && (
|
||||
<section className="grid gap-12">
|
||||
{/* Tool Grid */}
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-end justify-between border-b border-white/10 pb-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] uppercase tracking-[0.22em] font-bold text-[#F27D26]">Active Modules</span>
|
||||
<h2 className="text-4xl font-black uppercase tracking-tight">Tool Grid</h2>
|
||||
<>
|
||||
{/* Top Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<MetricCard
|
||||
icon={<IconCpu className="size-5" />}
|
||||
label="CPU LOAD"
|
||||
value="47%"
|
||||
trend="+2.3%"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<IconDatabase className="size-5" />}
|
||||
label="MEMORY"
|
||||
value="8.2 GB"
|
||||
trend="-1.1%"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<IconBolt className="size-5" />}
|
||||
label="POWER"
|
||||
value="89%"
|
||||
trend="+5.2%"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<IconActivity className="size-5" />}
|
||||
label="NETWORK"
|
||||
value="12.4 MB/s"
|
||||
trend="+8.7%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active Protocols / Tool Status */}
|
||||
<DataPanel
|
||||
title="ACTIVE PROTOCOLS"
|
||||
items={groupedTools.flatMap(([, items]) =>
|
||||
items.slice(0, 4).map((tool) => ({
|
||||
label: tool.name,
|
||||
status: tool.status === "enabled" ? "ACTIVE" : "STANDBY",
|
||||
progress: tool.status === "enabled" ? 94 : 30,
|
||||
}))
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Memory Network */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconCircleDot className="w-3.5 h-3.5 text-[#00bcff]" />
|
||||
<span className="text-cyan-300 text-sm tracking-widest font-semibold">MEMORY NETWORK</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] opacity-40 font-bold">{filteredToolCount} Visible</span>
|
||||
<a href="/agent/tools" className="text-xs font-bold uppercase tracking-widest hover:text-[#F27D26] transition-colors border-b border-white/20 pb-1">
|
||||
Configuration
|
||||
</a>
|
||||
<div className="rounded-2xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-5 overflow-hidden shadow-[0_0_30px_#00bcff1a,inset_0_0_20px_#00bcff0d]">
|
||||
<MemoryGraph
|
||||
nodes={sessionMemoryGraph?.nodes ?? []}
|
||||
edges={sessionMemoryGraph?.edges ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{groupedTools.flatMap(([, items]) =>
|
||||
items.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="group relative border border-white/10 bg-[#0A0A0A] p-4 hover:border-[#F27D26]/50 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="space-y-1 flex-1 min-w-0">
|
||||
<h4 className="font-bold text-sm tracking-tight uppercase truncate group-hover:text-[#F27D26] transition-colors">
|
||||
{tool.name}
|
||||
</h4>
|
||||
<Badge
|
||||
className={cn(
|
||||
"rounded-none text-[8px] uppercase tracking-widest font-black px-1.5 py-0.5",
|
||||
tool.status === "enabled" ? "bg-[#F27D26] text-black" : "bg-white/10 text-white/40"
|
||||
)}
|
||||
>
|
||||
{tool.status}
|
||||
</Badge>
|
||||
{/* Tool Grid - Compact */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-cyan-300 text-sm tracking-widest font-semibold">TOOL REGISTRY</span>
|
||||
<span className="text-[9px] uppercase tracking-widest text-[#94a3b8] font-bold">{filteredToolCount} Active</span>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{groupedTools.flatMap(([, items]) =>
|
||||
items.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="group relative rounded-xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-3 overflow-hidden shadow-[0_0_20px_#00bcff1a] hover:border-[#00bcff]/40 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-bold text-[11px] tracking-tight uppercase text-cyan-100 group-hover:text-[#00bcff] transition-colors truncate">
|
||||
{tool.name}
|
||||
</h4>
|
||||
</div>
|
||||
<Switch
|
||||
checked={tool.status !== "disabled"}
|
||||
disabled={pendingToolName === tool.name}
|
||||
onCheckedChange={(checked) => toggleTool(tool.name, checked)}
|
||||
className="data-[state=checked]:bg-[#00bcff] scale-75"
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={tool.status !== "disabled"}
|
||||
disabled={pendingToolName === tool.name}
|
||||
onCheckedChange={(checked) => toggleTool(tool.name, checked)}
|
||||
<p className="text-[10px] text-cyan-100/50 leading-relaxed line-clamp-2">
|
||||
{tool.description}
|
||||
</p>
|
||||
<Badge
|
||||
className={cn(
|
||||
"rounded-md text-[7px] uppercase tracking-widest font-black px-1.5 py-0.5 mt-2",
|
||||
tool.status === "enabled"
|
||||
? "bg-[#00bcff]/20 text-[#00bcff] border border-[#00bcff]/30"
|
||||
: "bg-white/5 text-[#94a3b8] border border-white/10"
|
||||
)}
|
||||
>
|
||||
{tool.status}
|
||||
</Badge>
|
||||
|
||||
{/* Scan Line */}
|
||||
<motion.div
|
||||
className="absolute inset-x-0 h-px bg-gradient-to-r from-transparent via-[#00bcff]/30 to-transparent"
|
||||
animate={{ y: [0, 80] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[#F2F2F2]/60 leading-relaxed font-light mb-4 line-clamp-2">
|
||||
{tool.description}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-[8px] font-mono uppercase tracking-widest text-white/30 pt-3 border-t border-white/5">
|
||||
<span className="truncate">{tool.config_key}</span>
|
||||
{(tool as any).reason_code && (
|
||||
<span className="text-[#F27D26]/60">{reasonLabel((tool as any).reason_code)}</span>
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Skills Tab */}
|
||||
{activeTab === "skills" && <SkillsPage embedded />}
|
||||
|
||||
{/* Agents Tab */}
|
||||
{activeTab === "agents" && <AgentsPage embedded />}
|
||||
|
||||
{/* Research Tab */}
|
||||
{activeTab === "research" && <ResearchPage />}
|
||||
</motion.div>
|
||||
|
||||
{/* Right Column - Circular Stats + Terminal */}
|
||||
{activeTab === "tools" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="col-span-3 flex flex-col gap-5 overflow-y-auto"
|
||||
>
|
||||
<CircularProgress
|
||||
icon={<IconShield className="size-7" />}
|
||||
label="DEFENSE"
|
||||
percentage={92}
|
||||
color="cyan"
|
||||
/>
|
||||
<CircularProgress
|
||||
icon={<IconCpu className="size-7" />}
|
||||
label="UPTIME"
|
||||
percentage={99}
|
||||
color="blue"
|
||||
/>
|
||||
<CircularProgress
|
||||
icon={<IconActivity className="size-7" />}
|
||||
label="CONNECTIVITY"
|
||||
percentage={87}
|
||||
color="cyan"
|
||||
/>
|
||||
|
||||
{/* Subagent Manifest */}
|
||||
<div className="rounded-2xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-4 shadow-[0_0_30px_#00bcff1a]">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<IconCircleDot className="size-3.5 text-[#00bcff]" />
|
||||
<span className="text-cyan-300 text-[10px] tracking-[0.2em] uppercase font-semibold">Subagents</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sessionSubagents === null ? (
|
||||
<div className="text-[10px] text-cyan-100/50 flex items-center gap-2">
|
||||
<motion.div
|
||||
className="w-2 h-2 rounded-full bg-[#00bcff]"
|
||||
animate={{ opacity: [0.3, 1, 0.3] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
/>
|
||||
Loading...
|
||||
</div>
|
||||
) : sessionSubagents.length === 0 ? (
|
||||
<div className="text-[10px] text-cyan-100/40">No subagents active</div>
|
||||
) : (
|
||||
sessionSubagents.map((task) => (
|
||||
<div key={task.id} className="group rounded-lg border border-[#00bcff]/15 bg-black/30 p-2.5 hover:border-[#00bcff]/30 transition-all cursor-pointer">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="font-bold text-[10px] uppercase tracking-tight text-cyan-100 group-hover:text-[#00bcff] transition-colors truncate">
|
||||
{task.label || task.id}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-[7px] uppercase font-mono px-1.5 py-0.5 rounded-md",
|
||||
task.status === "completed"
|
||||
? "bg-green-400/15 text-green-400"
|
||||
: task.status === "failed"
|
||||
? "bg-red-400/15 text-red-400"
|
||||
: "bg-[#00bcff]/15 text-[#00bcff]"
|
||||
)}>
|
||||
{task.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[8px] font-mono text-cyan-100/30">
|
||||
{dayjs(task.created).format("HH:mm:ss")}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
|
@ -190,79 +761,12 @@ export function CockpitPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory Network */}
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-end justify-between border-b border-white/10 pb-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] uppercase tracking-[0.22em] font-bold text-[#F27D26]">Relational Map</span>
|
||||
<h2 className="text-4xl font-black uppercase tracking-tight">Memory Network</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-8 border border-white/10 bg-[#0A0A0A] relative overflow-hidden">
|
||||
<MemoryGraph
|
||||
nodes={sessionMemoryGraph?.nodes ?? []}
|
||||
edges={sessionMemoryGraph?.edges ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar */}
|
||||
<aside className="space-y-12">
|
||||
{/* Subagents */}
|
||||
<div className="space-y-8">
|
||||
<div className="border-b border-white/10 pb-2">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Subagent Manifest</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{sessionSubagents === null ? (
|
||||
<div className="border border-white/10 p-5 bg-[#0A0A0A] text-sm text-white/40">
|
||||
Loading subagents...
|
||||
</div>
|
||||
) : sessionSubagents.length === 0 ? (
|
||||
<div className="border border-white/10 p-5 bg-[#0A0A0A] text-sm text-white/40">
|
||||
No subagents have been created in this session yet.
|
||||
</div>
|
||||
) : (
|
||||
sessionSubagents.map((task) => (
|
||||
<div key={task.id} className="group border border-white/10 p-5 bg-[#0A0A0A] hover:border-white/30 transition-all">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className="font-bold text-sm uppercase tracking-tight">{task.label || task.id}</span>
|
||||
<span className={cn(
|
||||
"text-[9px] uppercase font-mono px-1.5 py-0.5",
|
||||
task.status === "completed" ? "bg-green-500/20 text-green-400" : "bg-[#F27D26]/20 text-[#F27D26]"
|
||||
)}>
|
||||
{task.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-white/30 truncate">
|
||||
{dayjs(task.created).format("HH:mm:ss [UTC]")}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{/* Terminal Output */}
|
||||
<TerminalOutput />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="h-20 border-t border-white/10 flex items-center justify-between px-6 md:px-12 z-10 bg-[#050505]">
|
||||
<nav className="flex gap-10 text-[10px] font-bold uppercase tracking-[0.3em]">
|
||||
<a href="#" className="hover:text-[#F27D26] transition-colors">Portfolio</a>
|
||||
<a href="#" className="hover:text-[#F27D26] transition-colors">Documentation</a>
|
||||
<a href="#" className="hover:text-[#F27D26] transition-colors">Gateway</a>
|
||||
</nav>
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] opacity-40 font-bold">System Ref: BOLD-UX-01</span>
|
||||
<div className="h-10 w-10 rounded-full border border-white/10 flex items-center justify-center hover:bg-white hover:text-black cursor-pointer transition-all">
|
||||
<IconArrowRight size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import type {
|
||||
PicoMemoryGraphEdge,
|
||||
|
|
@ -28,11 +29,11 @@ const GROUP_COLUMNS: Record<string, number> = {
|
|||
}
|
||||
|
||||
function groupColor(group: string) {
|
||||
if (group === "memory") return "#72f0a0"
|
||||
if (group.startsWith("daily-")) return "#4dd0e1"
|
||||
if (group === "tool") return "#f8d66d"
|
||||
if (group === "media") return "#f395d6"
|
||||
return "#90e89f"
|
||||
if (group === "memory") return "#00bcff"
|
||||
if (group.startsWith("daily-")) return "#22d3ee"
|
||||
if (group === "tool") return "#38bdf8"
|
||||
if (group === "media") return "#7dd3fc"
|
||||
return "#06b6d4"
|
||||
}
|
||||
|
||||
function computeLayout(nodes: PicoMemoryGraphNode[]): PositionedNode[] {
|
||||
|
|
@ -76,69 +77,133 @@ export function MemoryGraph({ nodes, edges }: MemoryGraphProps) {
|
|||
positionedNodes.find((node) => node.id === selectedId) ?? positionedNodes[0]
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<div className="overflow-hidden rounded-xl border border-[#173621] bg-[#041008]">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_240px]">
|
||||
<div className="overflow-hidden rounded-xl border border-[#00bcff]/20 bg-[#060e1a] relative">
|
||||
{/* Holographic scan line overlay */}
|
||||
<motion.div
|
||||
className="absolute inset-0 z-10 pointer-events-none bg-gradient-to-b from-transparent via-[#00bcff]/5 to-transparent"
|
||||
animate={{ y: ["-100%", "100%"] }}
|
||||
transition={{ duration: 4, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
|
||||
className="h-[420px] w-full"
|
||||
className="h-[380px] w-full"
|
||||
role="img"
|
||||
aria-label="PicoClaw memory graph"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="memoryGrid" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#0c2014" />
|
||||
<stop offset="100%" stopColor="#050d08" />
|
||||
{/* 3D Grid Gradient */}
|
||||
<linearGradient id="jarvisGrid" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#0a1628" />
|
||||
<stop offset="50%" stopColor="#060e1a" />
|
||||
<stop offset="100%" stopColor="#0a1628" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Glow filter for edges */}
|
||||
<filter id="jarvisGlow">
|
||||
<feGaussianBlur stdDeviation="4" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
{/* Stronger glow for active nodes */}
|
||||
<filter id="jarvisGlowStrong">
|
||||
<feGaussianBlur stdDeviation="8" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
{/* Holographic radial gradient for nodes */}
|
||||
<radialGradient id="holoNodeGrad" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="#00bcff" stopOpacity="0.3" />
|
||||
<stop offset="70%" stopColor="#00bcff" stopOpacity="0.1" />
|
||||
<stop offset="100%" stopColor="#00bcff" stopOpacity="0.02" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width={VIEWBOX_WIDTH} height={VIEWBOX_HEIGHT} fill="url(#memoryGrid)" />
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
|
||||
{/* Background */}
|
||||
<rect width={VIEWBOX_WIDTH} height={VIEWBOX_HEIGHT} fill="url(#jarvisGrid)" />
|
||||
|
||||
{/* 3D Perspective Grid Lines */}
|
||||
{Array.from({ length: 20 }).map((_, index) => (
|
||||
<line
|
||||
key={`v-${index}`}
|
||||
x1={(VIEWBOX_WIDTH / 10) * index}
|
||||
x1={(VIEWBOX_WIDTH / 20) * index}
|
||||
y1="0"
|
||||
x2={(VIEWBOX_WIDTH / 10) * index}
|
||||
x2={(VIEWBOX_WIDTH / 20) * index}
|
||||
y2={VIEWBOX_HEIGHT}
|
||||
stroke="#0d2817"
|
||||
stroke="rgba(0, 188, 255, 0.03)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
{Array.from({ length: 12 }).map((_, index) => (
|
||||
<line
|
||||
key={`h-${index}`}
|
||||
x1="0"
|
||||
y1={(VIEWBOX_HEIGHT / 8) * index}
|
||||
y1={(VIEWBOX_HEIGHT / 12) * index}
|
||||
x2={VIEWBOX_WIDTH}
|
||||
y2={(VIEWBOX_HEIGHT / 8) * index}
|
||||
stroke="#0d2817"
|
||||
y2={(VIEWBOX_HEIGHT / 12) * index}
|
||||
stroke="rgba(0, 188, 255, 0.03)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Edges with holographic glow */}
|
||||
{edges.map((edge) => {
|
||||
const source = nodeMap.get(edge.source)
|
||||
const target = nodeMap.get(edge.target)
|
||||
if (!source || !target) {
|
||||
return null
|
||||
}
|
||||
if (!source || !target) return null
|
||||
|
||||
const active =
|
||||
selectedId != null &&
|
||||
(selectedId === edge.source || selectedId === edge.target)
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`${edge.source}-${edge.target}-${edge.kind}`}
|
||||
x1={source.x}
|
||||
y1={source.y}
|
||||
x2={target.x}
|
||||
y2={target.y}
|
||||
stroke={active ? "#9dfdbb" : "#1f5c34"}
|
||||
strokeOpacity={active ? 0.9 : 0.45}
|
||||
strokeWidth={active ? 2.5 : 1.4}
|
||||
/>
|
||||
<g key={`${edge.source}-${edge.target}-${edge.kind}`}>
|
||||
{/* Glow layer */}
|
||||
{active && (
|
||||
<line
|
||||
x1={source.x}
|
||||
y1={source.y}
|
||||
x2={target.x}
|
||||
y2={target.y}
|
||||
stroke="#00bcff"
|
||||
strokeOpacity={0.3}
|
||||
strokeWidth={6}
|
||||
filter="url(#jarvisGlowStrong)"
|
||||
/>
|
||||
)}
|
||||
{/* Main edge */}
|
||||
<line
|
||||
x1={source.x}
|
||||
y1={source.y}
|
||||
x2={target.x}
|
||||
y2={target.y}
|
||||
stroke={active ? "#00bcff" : "rgba(0, 188, 255, 0.12)"}
|
||||
strokeOpacity={active ? 0.9 : 0.5}
|
||||
strokeWidth={active ? 2 : 1}
|
||||
filter={active ? "url(#jarvisGlow)" : undefined}
|
||||
/>
|
||||
{/* Animated pulse along edge when active */}
|
||||
{active && (
|
||||
<circle r="3" fill="#00bcff" filter="url(#jarvisGlow)">
|
||||
<animateMotion
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
path={`M${source.x},${source.y} L${target.x},${target.y}`}
|
||||
/>
|
||||
</circle>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Nodes with 3D holographic style */}
|
||||
{positionedNodes.map((node) => {
|
||||
const active = node.id === selectedNode?.id
|
||||
const color = groupColor(node.group)
|
||||
|
|
@ -152,29 +217,82 @@ export function MemoryGraph({ nodes, edges }: MemoryGraphProps) {
|
|||
onClick={() => setSelectedId(node.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{/* Outer holographic ring - pulsing */}
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={radius + 18}
|
||||
fill="transparent"
|
||||
stroke={active ? color : "transparent"}
|
||||
strokeWidth="0.5"
|
||||
strokeOpacity={active ? 0.2 : 0}
|
||||
strokeDasharray="4 4"
|
||||
>
|
||||
{active && (
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="0"
|
||||
to="8"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
)}
|
||||
</circle>
|
||||
|
||||
{/* Glow halo */}
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={radius + 10}
|
||||
fill={active ? `${color}22` : "transparent"}
|
||||
fill={active ? `${color}15` : "transparent"}
|
||||
/>
|
||||
|
||||
{/* Holographic field */}
|
||||
{active && (
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={radius + 6}
|
||||
fill="url(#holoNodeGrad)"
|
||||
filter="url(#jarvisGlow)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main node circle */}
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={radius}
|
||||
fill="#07110a"
|
||||
fill="#0a1628"
|
||||
stroke={color}
|
||||
strokeWidth={active ? 3 : 2}
|
||||
strokeWidth={active ? 2.5 : 1.5}
|
||||
filter={active ? "url(#jarvisGlow)" : undefined}
|
||||
/>
|
||||
|
||||
{/* Inner glow ring */}
|
||||
{active && (
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={radius - 4}
|
||||
fill="transparent"
|
||||
stroke={color}
|
||||
strokeWidth="0.5"
|
||||
strokeOpacity="0.4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y + 4}
|
||||
textAnchor="middle"
|
||||
fill={color}
|
||||
fontSize={node.kind === "root" ? "12" : "10"}
|
||||
fill={active ? "#e0f2fe" : color}
|
||||
fontSize={node.kind === "root" ? "11" : "9"}
|
||||
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
||||
fontWeight={active ? "600" : "400"}
|
||||
>
|
||||
{node.kind === "root" ? node.label : node.label.slice(0, 18)}
|
||||
{node.kind === "root" ? node.label : node.label.slice(0, 16)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
|
|
@ -182,50 +300,51 @@ export function MemoryGraph({ nodes, edges }: MemoryGraphProps) {
|
|||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-[#173621] bg-[#050d08] p-4">
|
||||
{/* Node Detail Panel */}
|
||||
<div className="space-y-3 rounded-xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-4 shadow-[0_0_20px_#00bcff1a]">
|
||||
{selectedNode ? (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-[#95d7a5]">
|
||||
<p className="font-mono text-[9px] uppercase tracking-[0.25em] text-[#00bcff]">
|
||||
Node Focus
|
||||
</p>
|
||||
<h3 className="mt-2 text-sm font-semibold text-[#effff3]">
|
||||
<h3 className="mt-2 text-sm font-semibold text-cyan-100">
|
||||
{selectedNode.label}
|
||||
</h3>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-[#2a6b3d] text-[#9fd8ae]",
|
||||
selectedNode.kind === "tool" && "border-[#8c7931] text-[#f8d66d]",
|
||||
"border-[#00bcff]/30 text-[#00bcff] text-[9px]",
|
||||
selectedNode.kind === "tool" && "border-[#38bdf8]/30 text-[#38bdf8]",
|
||||
)}
|
||||
>
|
||||
{selectedNode.kind}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-[#9cc8a8]">
|
||||
<p className="text-[11px] leading-relaxed text-cyan-100/50">
|
||||
{selectedNode.preview || "No extra preview for this node yet."}
|
||||
</p>
|
||||
<div className="grid gap-2 text-xs text-[#74a282]">
|
||||
<div className="flex items-center justify-between rounded-lg border border-[#13301d] px-3 py-2">
|
||||
<div className="grid gap-2 text-[11px] text-cyan-100/50">
|
||||
<div className="flex items-center justify-between rounded-lg border border-[#00bcff]/10 bg-[#00bcff]/3 px-3 py-2">
|
||||
<span>Cluster</span>
|
||||
<span className="font-mono uppercase text-[#d7f9df]">
|
||||
<span className="font-mono uppercase text-cyan-100">
|
||||
{selectedNode.group}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-[#13301d] px-3 py-2">
|
||||
<div className="flex items-center justify-between rounded-lg border border-[#00bcff]/10 bg-[#00bcff]/3 px-3 py-2">
|
||||
<span>Weight</span>
|
||||
<span className="font-mono text-[#d7f9df]">
|
||||
<span className="font-mono text-cyan-100">
|
||||
{selectedNode.weight ?? 1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-[#8bb39a]">No graph data available.</p>
|
||||
<p className="text-[11px] text-cyan-100/40">No graph data available.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useDeferredValue, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { motion } from "motion/react"
|
||||
import type { SkillSupportItem } from "@/api/skills"
|
||||
import { useCockpitSkills } from "@/hooks/use-cockpit-skills"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
|
|
@ -56,7 +57,14 @@ export function SkillsPage({ embedded = false }: SkillsPageProps) {
|
|||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading skills...</p>
|
||||
<div className="flex items-center gap-2 text-cyan-100/50">
|
||||
<motion.div
|
||||
className="w-2 h-2 rounded-full bg-[#00bcff]"
|
||||
animate={{ opacity: [0.3, 1, 0.3] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
/>
|
||||
Loading skills...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -64,33 +72,38 @@ export function SkillsPage({ embedded = false }: SkillsPageProps) {
|
|||
if (isError) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<p className="text-destructive">Failed to load skills. Please try again.</p>
|
||||
<p className="text-red-400">Failed to load skills. Please try again.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mainContent = (
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
<div className="w-full">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="max-w-md flex-1">
|
||||
<Input
|
||||
placeholder={t("common.search", "Search skills...")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="bg-background"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("common.search", "Search skills...")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="bg-black/40 border-[#00bcff]/20 text-cyan-100 placeholder:text-cyan-100/30 focus:border-[#00bcff] focus:ring-[#00bcff]/20 backdrop-blur-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[9px] uppercase tracking-widest text-cyan-100/40 font-bold">
|
||||
{filteredSkills.length} Skills Loaded
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
|
||||
{filteredSkills.length === 0 ? (
|
||||
<div className="col-span-full rounded-lg border border-dashed border-white/10 p-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
<div className="col-span-full rounded-xl border border-dashed border-[#00bcff]/15 p-12 text-center bg-black/20 backdrop-blur-sm">
|
||||
<p className="text-cyan-100/50">
|
||||
{deferredSearchQuery
|
||||
? t("pages.agent.skills.no_results", "No skills found")
|
||||
: t("pages.agent.skills.no_skills", "No skills installed")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground/60">
|
||||
<p className="mt-2 text-[11px] text-cyan-100/30">
|
||||
{deferredSearchQuery
|
||||
? t("pages.agent.skills.no_results_hint", "Try a different search")
|
||||
: t("pages.agent.skills.no_skills_hint", "Install skills via the CLI or import them")}
|
||||
|
|
@ -114,23 +127,23 @@ export function SkillsPage({ embedded = false }: SkillsPageProps) {
|
|||
open={skillToDelete !== null}
|
||||
onOpenChange={(open) => { if (!open) setSkillToDelete(null) }}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogContent className="bg-[#0a0e27] border-[#00bcff]/20 text-cyan-100 shadow-[0_0_40px_#00bcff1a]">
|
||||
<AlertDialogTitle>
|
||||
{t("pages.agent.skills.confirm_delete", "Delete Skill?")}
|
||||
</AlertDialogTitle>
|
||||
<p className="text-muted-foreground">
|
||||
<p className="text-cyan-100/50">
|
||||
{t(
|
||||
"pages.agent.skills.confirm_delete_message",
|
||||
`Are you sure you want to delete "${skillToDelete?.name}"? This action cannot be undone.`,
|
||||
)}
|
||||
</p>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<AlertDialogCancel className="border-[#00bcff]/20 text-cyan-100/60 hover:text-cyan-100 hover:bg-[#00bcff]/5">
|
||||
{t("common.cancel", "Cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className="bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/30"
|
||||
>
|
||||
{t("common.delete", "Delete")}
|
||||
</AlertDialogAction>
|
||||
|
|
@ -149,7 +162,7 @@ export function SkillsPage({ embedded = false }: SkillsPageProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-full flex-col">
|
||||
<div className="bg-[#0a0e27] flex h-full flex-col">
|
||||
<PageHeader title={t("navigation.skills", "Skills")} />
|
||||
<div className="flex-1 overflow-auto px-6 py-6 pb-20">
|
||||
{mainContent}
|
||||
|
|
@ -157,4 +170,4 @@ export function SkillsPage({ embedded = false }: SkillsPageProps) {
|
|||
{modals}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -53,6 +53,30 @@ function readFileAsDataUrl(file: File): Promise<string> {
|
|||
})
|
||||
}
|
||||
|
||||
/** Background Jarvis Orb — large semi-transparent orb behind the chat messages */
|
||||
function BackgroundOrb() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{/* Main orb glow - centered in the viewport */}
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
{/* Outermost ring - very subtle */}
|
||||
<svg className="absolute -left-48 -top-48 h-96 w-96 opacity-[0.07]" viewBox="0 0 400 400">
|
||||
<circle cx="200" cy="200" r="190" fill="none" stroke="rgba(0, 212, 255, 0.3)" strokeWidth="0.5" strokeDasharray="12 8" style={{ animation: "orb-rotate 20s linear infinite", transformOrigin: "center" }} />
|
||||
<circle cx="200" cy="200" r="165" fill="none" stroke="rgba(0, 212, 255, 0.2)" strokeWidth="0.8" strokeDasharray="4 12" style={{ animation: "orb-rotate-reverse 14s linear infinite", transformOrigin: "center" }} />
|
||||
<circle cx="200" cy="200" r="140" fill="none" stroke="rgba(0, 212, 255, 0.35)" strokeWidth="0.5" style={{ animation: "ring-pulse 4s ease-in-out infinite" }} />
|
||||
</svg>
|
||||
{/* Core glow */}
|
||||
<div className="animate-breathe flex h-28 w-28 items-center justify-center rounded-full bg-[rgba(0,212,255,0.04)] shadow-[0_0_60px_rgba(0,212,255,0.12),0_0_120px_rgba(0,212,255,0.06)]">
|
||||
<div className="h-12 w-12 rounded-full bg-[rgba(0,212,255,0.08)] shadow-[0_0_30px_rgba(0,212,255,0.25),0_0_60px_rgba(0,212,255,0.1)]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Ambient side glows */}
|
||||
<div className="absolute -left-32 top-1/3 h-64 w-64 rounded-full bg-[#00d4ff]/[0.02] blur-[80px]" />
|
||||
<div className="absolute -right-32 bottom-1/4 h-56 w-56 rounded-full bg-[#0ea5e9]/[0.03] blur-[70px]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveChatInputDisabledReason({
|
||||
hasDefaultModel,
|
||||
connectionState,
|
||||
|
|
@ -62,46 +86,16 @@ function resolveChatInputDisabledReason({
|
|||
connectionState: ConnectionState
|
||||
gatewayState: GatewayState
|
||||
}): ChatInputDisabledReason | null {
|
||||
if (gatewayState === "unknown") {
|
||||
return "gatewayUnknown"
|
||||
}
|
||||
|
||||
if (gatewayState === "starting") {
|
||||
return "gatewayStarting"
|
||||
}
|
||||
|
||||
if (gatewayState === "restarting") {
|
||||
return "gatewayRestarting"
|
||||
}
|
||||
|
||||
if (gatewayState === "stopping") {
|
||||
return "gatewayStopping"
|
||||
}
|
||||
|
||||
if (gatewayState === "stopped") {
|
||||
return "gatewayStopped"
|
||||
}
|
||||
|
||||
if (gatewayState === "error") {
|
||||
return "gatewayError"
|
||||
}
|
||||
|
||||
if (connectionState === "connecting") {
|
||||
return "websocketConnecting"
|
||||
}
|
||||
|
||||
if (connectionState === "error") {
|
||||
return "websocketError"
|
||||
}
|
||||
|
||||
if (connectionState === "disconnected") {
|
||||
return "websocketDisconnected"
|
||||
}
|
||||
|
||||
if (!hasDefaultModel) {
|
||||
return "noDefaultModel"
|
||||
}
|
||||
|
||||
if (gatewayState === "unknown") return "gatewayUnknown"
|
||||
if (gatewayState === "starting") return "gatewayStarting"
|
||||
if (gatewayState === "restarting") return "gatewayRestarting"
|
||||
if (gatewayState === "stopping") return "gatewayStopping"
|
||||
if (gatewayState === "stopped") return "gatewayStopped"
|
||||
if (gatewayState === "error") return "gatewayError"
|
||||
if (connectionState === "connecting") return "websocketConnecting"
|
||||
if (connectionState === "error") return "websocketError"
|
||||
if (connectionState === "disconnected") return "websocketDisconnected"
|
||||
if (!hasDefaultModel) return "noDefaultModel"
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -199,19 +193,15 @@ export function ChatPage() {
|
|||
|
||||
// Check for permission requests in messages
|
||||
useEffect(() => {
|
||||
if (permissionRequest) return // Already showing a permission prompt
|
||||
|
||||
// Check the last few messages for request_permission tool calls
|
||||
if (permissionRequest) return
|
||||
for (let i = messages.length - 1; i >= Math.max(0, messages.length - 5); i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "assistant") continue
|
||||
if (!msg.toolCalls) continue
|
||||
|
||||
const permCall = msg.toolCalls.find(
|
||||
(tc) => tc.function?.name === "request_permission"
|
||||
)
|
||||
if (!permCall || !permCall.function) continue
|
||||
|
||||
try {
|
||||
const args = JSON.parse(permCall.function.arguments ?? "{}")
|
||||
if (args.path && args.command) {
|
||||
|
|
@ -222,25 +212,16 @@ export function ChatPage() {
|
|||
})
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}, [messages, permissionRequest])
|
||||
|
||||
const handlePermissionGranted = () => {
|
||||
setPermissionRequest(null)
|
||||
}
|
||||
|
||||
const handlePermissionDenied = () => {
|
||||
setPermissionRequest(null)
|
||||
}
|
||||
|
||||
const handlePermissionGranted = () => setPermissionRequest(null)
|
||||
const handlePermissionDenied = () => setPermissionRequest(null)
|
||||
const handleAddImages = () => {
|
||||
if (!canInput) return
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleRemoveAttachment = (index: number) => {
|
||||
setAttachments((prev) => prev.filter((_, itemIndex) => itemIndex !== index))
|
||||
}
|
||||
|
|
@ -298,11 +279,11 @@ export function ChatPage() {
|
|||
canInput && (Boolean(input.trim()) || attachments.length > 0)
|
||||
|
||||
return (
|
||||
<div className="bg-background/95 flex h-full flex-col">
|
||||
<div className="flex h-full flex-col bg-[#0a0e17]">
|
||||
<PageHeader
|
||||
title={t("navigation.chat")}
|
||||
className={`transition-shadow ${
|
||||
hasScrolled ? "shadow-xs" : "shadow-none"
|
||||
hasScrolled ? "shadow-[0_1px_0_rgba(0,212,255,0.1)]" : "shadow-none"
|
||||
}`}
|
||||
titleExtra={
|
||||
hasAvailableModels && (
|
||||
|
|
@ -316,8 +297,8 @@ export function ChatPage() {
|
|||
)
|
||||
}
|
||||
>
|
||||
<div className="border-border/60 hidden items-center gap-2 rounded-lg border px-3 py-1.5 sm:flex">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
<div className="hidden items-center gap-2 rounded-lg border border-[rgba(0,212,255,0.15)] bg-[rgba(0,212,255,0.03)] px-3 py-1.5 sm:flex">
|
||||
<span className="text-xs text-[#64748b]">
|
||||
{t("chat.showAssistantDetails")}
|
||||
</span>
|
||||
<Switch
|
||||
|
|
@ -332,7 +313,7 @@ export function ChatPage() {
|
|||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={newChat}
|
||||
className="h-9 gap-2"
|
||||
className="h-9 gap-2 border-[rgba(0,212,255,0.2)] bg-[rgba(0,212,255,0.05)] text-[#00d4ff] hover:bg-[rgba(0,212,255,0.1)]"
|
||||
>
|
||||
<IconPlus className="size-4" />
|
||||
<span className="hidden sm:inline">{t("chat.newChat")}</span>
|
||||
|
|
@ -358,9 +339,15 @@ export function ChatPage() {
|
|||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 [scrollbar-gutter:stable] md:px-8 lg:px-24 xl:px-48"
|
||||
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 [scrollbar-gutter:stable] md:px-8 lg:px-24 xl:px-48 relative"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
|
||||
{/* Background orb behind text */}
|
||||
<BackgroundOrb />
|
||||
|
||||
{/* Scan line overlay */}
|
||||
<div className="scan-line-overlay absolute inset-0 pointer-events-none z-[1]" />
|
||||
|
||||
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8 relative z-10">
|
||||
{messages.length === 0 && !isTyping && (
|
||||
<ChatEmptyState
|
||||
hasAvailableModels={hasAvailableModels}
|
||||
|
|
@ -439,4 +426,4 @@ export function ChatPage() {
|
|||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ export function LogsPage() {
|
|||
size="sm"
|
||||
onClick={clearLogs}
|
||||
disabled={logs.length === 0 || clearing}
|
||||
className="border-[rgba(0,212,255,0.3)] bg-[rgba(0,212,255,0.05)] text-[#00d4ff] hover:bg-[rgba(0,212,255,0.1)]"
|
||||
>
|
||||
<IconTrash className="size-4" />
|
||||
{t("pages.logs.clear")}
|
||||
|
|
|
|||
|
|
@ -45,92 +45,67 @@
|
|||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-jarvis-cyan: #00d4ff;
|
||||
--color-jarvis-blue: #0ea5e9;
|
||||
--color-jarvis-light: #22d3ee;
|
||||
--color-jarvis-surface: #0f172a;
|
||||
--color-jarvis-glow: rgba(0, 212, 255, 0.15);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--background: #0a0e17;
|
||||
--foreground: #e0f2fe;
|
||||
--card: rgba(6, 20, 40, 0.7);
|
||||
--card-foreground: #e0f2fe;
|
||||
--popover: #111827;
|
||||
--popover-foreground: #e0f2fe;
|
||||
--primary: #00d4ff;
|
||||
--primary-foreground: #0a0e17;
|
||||
--secondary: #1e293b;
|
||||
--secondary-foreground: #e0f2fe;
|
||||
--muted: #1e293b;
|
||||
--muted-foreground: #94a3b8;
|
||||
--accent: rgba(0, 212, 255, 0.1);
|
||||
--accent-foreground: #00d4ff;
|
||||
--destructive: #ef4444;
|
||||
--border: rgba(0, 212, 255, 0.15);
|
||||
--input: rgba(0, 212, 255, 0.1);
|
||||
--ring: #00d4ff;
|
||||
--chart-1: #00d4ff;
|
||||
--chart-2: #0ea5e9;
|
||||
--chart-3: #22d3ee;
|
||||
--chart-4: #06b6d4;
|
||||
--chart-5: #0891b2;
|
||||
--sidebar: #0f172a;
|
||||
--sidebar-foreground: #e0f2fe;
|
||||
--sidebar-primary: #00d4ff;
|
||||
--sidebar-primary-foreground: #0a0e17;
|
||||
--sidebar-accent: rgba(0, 212, 255, 0.1);
|
||||
--sidebar-accent-foreground: #00d4ff;
|
||||
--sidebar-border: rgba(0, 212, 255, 0.15);
|
||||
--sidebar-ring: #00d4ff;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
/* Force dark mode always */
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
scrollbar-color: rgba(0, 212, 255, 0.3) transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* WebKit Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
|
|
@ -138,12 +113,12 @@
|
|||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
background: rgba(0, 212, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
background: rgba(0, 212, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,25 +132,242 @@
|
|||
height: calc(100svh - 3.5rem);
|
||||
}
|
||||
|
||||
/* Typing indicator animations */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
/* ===== JARVIS CUSTOM ANIMATIONS ===== */
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes fadeSlideIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
0% { opacity: 0; transform: translateY(4px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 5px rgba(0, 212, 255, 0.2), 0 0 10px rgba(0, 212, 255, 0.1); }
|
||||
50% { box-shadow: 0 0 15px rgba(0, 212, 255, 0.4), 0 0 30px rgba(0, 212, 255, 0.15); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow-text {
|
||||
0%, 100% { text-shadow: 0 0 5px rgba(0, 212, 255, 0.4); }
|
||||
50% { text-shadow: 0 0 20px rgba(0, 212, 255, 0.7), 0 0 40px rgba(0, 212, 255, 0.3); }
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); opacity: 0.8; }
|
||||
50% { transform: scale(1.05); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes orb-rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes orb-rotate-reverse {
|
||||
from { transform: rotate(360deg); }
|
||||
to { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes typing-dot {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-6px); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes hud-corner-flash {
|
||||
0%, 90%, 100% { opacity: 0.5; }
|
||||
95% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes scan-line {
|
||||
0% { top: -2px; }
|
||||
100% { top: 100%; }
|
||||
}
|
||||
|
||||
@keyframes float-up {
|
||||
0% { opacity: 0; transform: translateY(12px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes ring-pulse {
|
||||
0%, 100% { stroke-opacity: 0.3; }
|
||||
50% { stroke-opacity: 0.8; }
|
||||
}
|
||||
|
||||
@keyframes voiceBar {
|
||||
0% { height: 4px; opacity: 0.5; }
|
||||
100% { height: 16px; opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 0 100%; }
|
||||
}
|
||||
|
||||
@keyframes holographic-pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ===== JARVIS UTILITY CLASSES ===== */
|
||||
|
||||
.glass-panel {
|
||||
background: rgba(6, 20, 40, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 212, 255, 0.15);
|
||||
}
|
||||
|
||||
.glass-panel-strong {
|
||||
background: rgba(6, 20, 40, 0.85);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.glow-border {
|
||||
box-shadow: 0 0 5px rgba(0, 212, 255, 0.15), inset 0 0 5px rgba(0, 212, 255, 0.03);
|
||||
}
|
||||
|
||||
.glow-border-active {
|
||||
box-shadow: 0 0 10px rgba(0, 212, 255, 0.25), 0 0 20px rgba(0, 212, 255, 0.1), inset 0 0 8px rgba(0, 212, 255, 0.05);
|
||||
border-color: rgba(0, 212, 255, 0.35);
|
||||
}
|
||||
|
||||
.hud-corners {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hud-corners::before,
|
||||
.hud-corners::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-color: rgba(0, 212, 255, 0.5);
|
||||
animation: hud-corner-flash 4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hud-corners::before {
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
border-top: 2px solid;
|
||||
border-left: 2px solid;
|
||||
}
|
||||
|
||||
.hud-corners::after {
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
border-bottom: 2px solid;
|
||||
border-right: 2px solid;
|
||||
}
|
||||
|
||||
.glow-text {
|
||||
text-shadow: 0 0 10px rgba(0, 212, 255, 0.4), 0 0 20px rgba(0, 212, 255, 0.15);
|
||||
}
|
||||
|
||||
.glow-text-pulse {
|
||||
animation: pulse-glow-text 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-breathe {
|
||||
animation: breathe 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float-up {
|
||||
animation: float-up 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ===== HEX GRID BACKGROUND ===== */
|
||||
|
||||
.hex-grid-bg {
|
||||
background-image:
|
||||
linear-gradient(30deg, rgba(0, 212, 255, 0.02) 12%, transparent 12.5%, transparent 87%, rgba(0, 212, 255, 0.02) 87.5%),
|
||||
linear-gradient(150deg, rgba(0, 212, 255, 0.02) 12%, transparent 12.5%, transparent 87%, rgba(0, 212, 255, 0.02) 87.5%),
|
||||
linear-gradient(30deg, rgba(0, 212, 255, 0.02) 12%, transparent 12.5%, transparent 87%, rgba(0, 212, 255, 0.02) 87.5%),
|
||||
linear-gradient(150deg, rgba(0, 212, 255, 0.02) 12%, transparent 12.5%, transparent 87%, rgba(0, 212, 255, 0.02) 87.5%),
|
||||
linear-gradient(60deg, rgba(0, 212, 255, 0.015) 25%, transparent 25.5%, transparent 75%, rgba(0, 212, 255, 0.015) 75%),
|
||||
linear-gradient(60deg, rgba(0, 212, 255, 0.015) 25%, transparent 25.5%, transparent 75%, rgba(0, 212, 255, 0.015) 75%);
|
||||
background-size: 40px 70px;
|
||||
background-position: 0 0, 0 0, 20px 35px, 20px 35px, 0 0, 20px 35px;
|
||||
}
|
||||
|
||||
/* ===== SCAN LINE ===== */
|
||||
|
||||
.scan-line-overlay {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scan-line-overlay::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.1), transparent);
|
||||
animation: scan-line 8s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Markdown in chat messages - Jarvis style */
|
||||
.jarvis-markdown h1, .jarvis-markdown h2, .jarvis-markdown h3 {
|
||||
color: #00d4ff;
|
||||
font-weight: 600;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.jarvis-markdown p {
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.jarvis-markdown code {
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
border: 1px solid rgba(0, 212, 255, 0.12);
|
||||
border-radius: 3px;
|
||||
padding: 0.1em 0.4em;
|
||||
font-size: 0.875em;
|
||||
color: #22d3ee;
|
||||
}
|
||||
|
||||
.jarvis-markdown pre {
|
||||
background: rgba(0, 0, 0, 0.4) !important;
|
||||
border: 1px solid rgba(0, 212, 255, 0.12) !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 1em !important;
|
||||
overflow-x: auto;
|
||||
margin: 0.75em 0 !important;
|
||||
}
|
||||
|
||||
.jarvis-markdown pre code {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
color: #e0f2fe !important;
|
||||
}
|
||||
|
||||
.jarvis-markdown a {
|
||||
color: #00d4ff;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.jarvis-markdown strong {
|
||||
color: #e0f2fe;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.jarvis-markdown blockquote {
|
||||
border-left: 3px solid rgba(0, 212, 255, 0.3);
|
||||
padding-left: 1em;
|
||||
color: #94a3b8;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue