feat: security hardening, UX/UI overhaul, test infrastructure

- Fix XSS in memory browser (escape-first markdown pipeline)
- Add API input validation with discriminated union returns
- Fix chat race condition with useRef for message freshness
- Wire up agent search filtering in chat sidebar
- Remove debug logging, add proper error responses to all API routes
- Fix NavLinks silent error swallowing
- Handle overdue cron timestamps
- Extract agent registry to JSON config
- Remove unused @anthropic-ai/sdk dependency
- Set up Vitest with 68 tests (conversations, agents, crons)
- Add skeleton loaders, ErrorState component, keyboard navigation
- Add responsive mobile sidebar with hamburger menu
- Add ARIA labels, roles, prefers-reduced-motion support
- Fix broken CSS vars in agent detail page
- Replace hardcoded colors with theme variables
- Define typography scale (--text-xs through --text-4xl)
- Move inline hover styles to CSS classes
- Polish light theme shadows and accent contrast
- Add shimmer/slideDown animations, code block copy button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JohnRiceML
2026-02-27 13:40:20 -06:00
co-authored by Claude Opus 4.6
parent 0143591b7e
commit 28c4269539
29 changed files with 4775 additions and 807 deletions
+7 -2
View File
@@ -1,7 +1,12 @@
import { getAgents } from '@/lib/agents'
import { apiErrorResponse } from '@/lib/api-error'
import { NextResponse } from 'next/server'
export async function GET() {
const agents = await getAgents()
return NextResponse.json(agents)
try {
const agents = await getAgents()
return NextResponse.json(agents)
} catch (err) {
return apiErrorResponse(err, 'Failed to load agents')
}
}
+20 -1
View File
@@ -1,6 +1,7 @@
export const runtime = 'nodejs'
import { getAgent } from '@/lib/agents'
import { validateChatMessages } from '@/lib/validation'
import OpenAI from 'openai'
// Route through the OpenClaw gateway — no separate API key needed
@@ -23,7 +24,25 @@ export async function POST(
})
}
const { messages } = await request.json()
let body: unknown
try {
body = await request.json()
} catch {
return new Response(
JSON.stringify({ error: 'Invalid JSON in request body.' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
const result = validateChatMessages(body)
if (!result.ok) {
return new Response(
JSON.stringify({ error: result.error }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
const { messages } = result
const systemPrompt = agent.soul
? `${agent.soul}\n\nYou are speaking directly with John, your operator. Stay fully in character. Be concise — this is a live chat. 2-4 sentences unless detail is asked for. No em dashes.`
+7 -2
View File
@@ -1,7 +1,12 @@
import { getCrons } from '@/lib/crons'
import { apiErrorResponse } from '@/lib/api-error'
import { NextResponse } from 'next/server'
export async function GET() {
const crons = await getCrons()
return NextResponse.json(crons)
try {
const crons = await getCrons()
return NextResponse.json(crons)
} catch (err) {
return apiErrorResponse(err, 'Failed to load cron jobs')
}
}
+7 -2
View File
@@ -1,7 +1,12 @@
import { getMemoryFiles } from '@/lib/memory'
import { apiErrorResponse } from '@/lib/api-error'
import { NextResponse } from 'next/server'
export async function GET() {
const files = await getMemoryFiles()
return NextResponse.json(files)
try {
const files = await getMemoryFiles()
return NextResponse.json(files)
} catch (err) {
return apiErrorResponse(err, 'Failed to load memory files')
}
}
+1 -8
View File
@@ -70,14 +70,6 @@ function MessengerApp() {
}
}, [activeAgent?.id])
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', background: 'var(--bg)' }}>
<span style={{ color: 'var(--text-tertiary)', fontSize: 15 }}>Loading...</span>
</div>
)
}
return (
<div style={{ display: 'flex', height: '100%', background: 'var(--bg)' }}>
<AgentList
@@ -85,6 +77,7 @@ function MessengerApp() {
conversations={conversations}
activeId={activeAgentId}
onSelect={handleSelectAgent}
loading={loading}
/>
{activeAgent && conversations[activeAgent.id] ? (
+85 -20
View File
@@ -1,7 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } 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";
function timeAgo(dateStr: string | null): string {
if (!dateStr) return "never";
@@ -26,6 +28,20 @@ function timeAgo(dateStr: string | null): string {
return `${days}d ago`;
}
function nextRunLabel(dateStr: string | null): string {
if (!dateStr) return "not scheduled";
const d = new Date(dateStr);
if (isNaN(d.getTime())) return "\u2014";
const diff = d.getTime() - Date.now();
if (diff < 0) return "overdue";
const mins = Math.floor(diff / 60000);
const hrs = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (mins < 60) return `in ${mins}m`;
if (hrs < 24) return `in ${hrs}h`;
return `in ${days}d`;
}
type Filter = "all" | "ok" | "error" | "idle";
export default function CronsPage() {
@@ -35,24 +51,38 @@ export default function CronsPage() {
const [expanded, setExpanded] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
function refresh() {
const refresh = useCallback(() => {
setLoading(true);
setError(null);
Promise.all([
fetch("/api/crons").then((r) => r.json()),
fetch("/api/agents").then((r) => r.json()),
]).then(([c, a]) => {
setCrons(c);
setAgents(a);
setLastRefresh(new Date());
setLoading(false);
});
}
fetch("/api/crons").then((r) => {
if (!r.ok) throw new Error(`Crons API: ${r.status}`);
return r.json();
}),
fetch("/api/agents").then((r) => {
if (!r.ok) throw new Error(`Agents API: ${r.status}`);
return r.json();
}),
])
.then(([c, a]) => {
if (Array.isArray(c)) setCrons(c);
if (Array.isArray(a)) setAgents(a);
setLastRefresh(new Date());
setLoading(false);
})
.catch((e) => {
setError(e.message);
setLoading(false);
});
}, []);
useEffect(() => {
refresh();
const interval = setInterval(refresh, 60000);
return () => clearInterval(interval);
}, []);
}, [refresh]);
const agentMap = new Map(agents.map((a) => [a.id, a]));
const statusOrder: Record<string, number> = { error: 0, idle: 1, ok: 2 };
@@ -79,6 +109,10 @@ export default function CronsPage() {
{ key: "idle", label: "Idle", dotColor: "var(--text-tertiary)" },
];
if (error && crons.length === 0) {
return <ErrorState message={`Failed to load crons: ${error}`} onRetry={refresh} />;
}
return (
<div
className="h-full flex flex-col overflow-hidden"
@@ -125,7 +159,8 @@ export default function CronsPage() {
<button
onClick={refresh}
className="hover:opacity-80 transition-opacity text-[16px]"
style={{ color: "var(--text-tertiary)" }}
style={{ color: "var(--text-tertiary)", background: "none", border: "none", cursor: "pointer" }}
aria-label="Refresh cron data"
>
&#8635;
</button>
@@ -133,19 +168,23 @@ export default function CronsPage() {
</div>
{/* Filter pills */}
<div className="px-6 py-3 flex items-center gap-2 overflow-x-auto flex-shrink-0">
<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"
style={{
borderRadius: 20,
padding: "6px 14px",
fontSize: 13,
fontWeight: 500,
border: "none",
cursor: "pointer",
transition: "all 200ms var(--ease-smooth)",
...(isActive
? {
@@ -184,11 +223,26 @@ export default function CronsPage() {
{/* Cron list */}
<div className="flex-1 overflow-y-auto px-6 pb-6">
{loading ? (
<div
className="flex items-center justify-center h-32 text-[15px] animate-pulse"
style={{ color: "var(--text-secondary)" }}
>
Loading crons...
<div role="status" aria-label="Loading cron jobs" style={{
borderRadius: "var(--radius-md)",
overflow: "hidden",
background: "var(--material-regular)",
padding: "8px 16px",
}}>
{[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>
</div>
))}
</div>
) : filtered.length === 0 ? (
<div
@@ -235,6 +289,15 @@ export default function CronsPage() {
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);
}
}}
style={{
minHeight: 44,
padding: "0 16px",
@@ -317,6 +380,7 @@ export default function CronsPage() {
? "rotate(90deg)"
: "rotate(0deg)",
}}
aria-hidden="true"
>
&#8250;
</span>
@@ -329,6 +393,7 @@ export default function CronsPage() {
{cron.lastError && (
<div
className="mt-2 px-4 py-3"
role="alert"
style={{
borderRadius: "var(--radius-sm)",
background: "rgba(255,69,58,0.06)",
@@ -359,7 +424,7 @@ export default function CronsPage() {
color: "var(--text-tertiary)",
}}
>
Next run: {timeAgo(cron.nextRun)}
Next run: {nextRunLabel(cron.nextRun)}
</span>
<span
className="text-[12px] font-mono"
+12
View File
@@ -435,3 +435,15 @@ body {
/* User bubble code blocks need inverted colors in light theme */
.msg-user pre { background: rgba(0,0,0,0.15) !important; color: #000 !important; }
.msg-user code { background: rgba(0,0,0,0.12) !important; color: #000 !important; }
/* ============================================
Accessibility: Reduced Motion
============================================ */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+7 -3
View File
@@ -3,6 +3,7 @@ import "./globals.css";
import { NavLinks } from "@/components/NavLinks";
import { ThemeProvider } from "./providers";
import { ThemeToggle } from "@/components/ThemeToggle";
import { MobileSidebar } from "@/components/MobileSidebar";
export const metadata: Metadata = {
title: "Manor — Command Centre",
@@ -15,9 +16,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<body>
<ThemeProvider>
<div className="flex h-screen overflow-hidden" style={{ background: 'var(--bg)' }}>
{/* Apple Source List Sidebar */}
{/* Desktop sidebar — hidden on mobile */}
<aside
className="w-[220px] flex-shrink-0 flex flex-col"
className="hidden md:flex w-[220px] flex-shrink-0 flex-col"
style={{
background: 'var(--sidebar-bg)',
backdropFilter: 'var(--sidebar-backdrop)',
@@ -62,9 +63,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<ThemeToggle />
</aside>
{/* Mobile sidebar */}
<MobileSidebar />
<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>
<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%',
+69 -81
View File
@@ -1,6 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef, useCallback } from "react";
import type { MemoryFile } from "@/lib/types";
import { renderMarkdown, colorizeJson } from "@/lib/sanitize";
import { Skeleton } from "@/components/ui/skeleton";
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
@@ -17,81 +19,12 @@ function wordCount(text: string): number {
return text.trim().split(/\s+/).filter(Boolean).length;
}
function simpleMarkdown(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(
/^#### (.+)$/gm,
'<h4 class="text-[15px] font-semibold" style="color:var(--text-primary);margin-top:1rem;margin-bottom:0.25rem">$1</h4>'
)
.replace(
/^### (.+)$/gm,
'<h3 class="text-[17px] font-semibold" style="color:var(--text-primary);margin-top:1.25rem;margin-bottom:0.375rem">$1</h3>'
)
.replace(
/^## (.+)$/gm,
'<h2 class="text-[22px] font-semibold" style="color:var(--text-primary);margin-top:1.5rem;margin-bottom:0.5rem;padding-bottom:0.25rem;border-bottom:1px solid var(--separator)">$1</h2>'
)
.replace(
/^# (.+)$/gm,
'<h1 class="text-[28px] font-bold" style="color:var(--text-primary);margin-top:1rem;margin-bottom:0.75rem">$1</h1>'
)
.replace(
/\*\*(.+?)\*\*/g,
'<strong class="font-semibold" style="color:var(--text-primary)">$1</strong>'
)
.replace(
/`([^`]+)`/g,
'<code style="background:var(--fill-secondary);color:var(--accent);padding:2px 6px;border-radius:6px;font-size:13px;font-family:var(--font-mono)">$1</code>'
)
.replace(
/^- (.+)$/gm,
'<li class="ml-4 text-[15px] leading-[1.7] list-disc" style="color:var(--text-secondary)">$1</li>'
)
.replace(
/^(\d+)\. (.+)$/gm,
'<li class="ml-4 text-[15px] leading-[1.7] list-decimal" style="color:var(--text-secondary)">$2</li>'
)
.replace(
/\n{2,}/g,
'</p><p class="mb-3" style="color:var(--text-secondary)">'
)
.replace(/\n/g, "<br/>");
}
function colorizeJson(json: string): string {
return json
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(
/"([^"]+)"(?=\s*:)/g,
'<span style="color:var(--accent)">"$1"</span>'
)
.replace(
/:\s*"([^"]*?)"/g,
': <span style="color:var(--system-green)">"$1"</span>'
)
.replace(
/:\s*(\d+\.?\d*)/g,
': <span style="color:var(--system-blue)">$1</span>'
)
.replace(
/:\s*(true|false)/g,
': <span style="color:#bf5af2">$1</span>'
)
.replace(
/:\s*(null)/g,
': <span style="color:var(--text-tertiary)">$1</span>'
);
}
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);
function refresh() {
fetch("/api/memory")
@@ -107,6 +40,45 @@ export default function MemoryPage() {
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]
);
// Auto-focus content area when file selected
useEffect(() => {
if (selected && contentRef.current) {
contentRef.current.focus();
}
}, [selected]);
const isJSON =
selected?.label.includes("JSON") || selected?.path.endsWith(".json");
@@ -177,7 +149,7 @@ export default function MemoryPage() {
className="text-[15px] leading-[1.7]"
style={{ color: "var(--text-secondary)" }}
dangerouslySetInnerHTML={{
__html: `<p class="mb-3" style="color:var(--text-secondary)">${simpleMarkdown(selected.content)}</p>`,
__html: `<p class="mb-3" style="color:var(--text-secondary)">${renderMarkdown(selected.content)}</p>`,
}}
/>
);
@@ -216,20 +188,30 @@ export default function MemoryPage() {
<button
onClick={refresh}
className="hover:opacity-80 transition-opacity text-[16px]"
style={{ color: "var(--text-tertiary)" }}
style={{ color: "var(--text-tertiary)", background: "none", border: "none", cursor: "pointer" }}
aria-label="Refresh memory files"
>
&#8635;
</button>
</div>
{/* File list */}
<div className="flex-1 overflow-y-auto">
<div
ref={fileListRef}
className="flex-1 overflow-y-auto"
role="listbox"
aria-label="Memory files"
tabIndex={0}
onKeyDown={handleFileListKeyDown}
>
{loading ? (
<div
className="p-4 text-[14px] animate-pulse"
style={{ color: "var(--text-secondary)" }}
>
Loading...
<div className="p-4 flex flex-col gap-2" role="status" aria-label="Loading files">
{[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>
))}
</div>
) : (
files.map((file) => {
@@ -238,6 +220,8 @@ export default function MemoryPage() {
<button
key={file.path}
onClick={() => setSelected(file)}
role="option"
aria-selected={isActive}
className="w-full text-left transition-colors"
style={{
height: 52,
@@ -248,6 +232,8 @@ export default function MemoryPage() {
borderLeft: isActive
? "3px solid var(--accent)"
: "3px solid transparent",
border: "none",
cursor: "pointer",
}}
onMouseEnter={(e) => {
if (!isActive)
@@ -280,8 +266,10 @@ export default function MemoryPage() {
{/* Main content */}
<div
ref={contentRef}
tabIndex={-1}
className="flex-1 flex flex-col overflow-hidden"
style={{ background: "var(--bg)" }}
style={{ background: "var(--bg)", outline: "none" }}
>
{selected ? (
<>
+319 -242
View File
@@ -1,17 +1,35 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import dynamic from "next/dynamic";
import type { Agent, CronJob } from "@/lib/types";
import { Skeleton } from "@/components/ui/skeleton";
import { ErrorState } from "@/components/ErrorState";
const ManorMap = dynamic(
() => import("@/components/ManorMap").then((m) => ({ default: m.ManorMap })),
{
ssr: false,
loading: () => (
<div className="flex items-center justify-center h-full">
<div style={{ fontSize: '13px', color: 'var(--text-tertiary)' }} className="animate-pulse">
Scanning the manor...
<div className="flex items-center justify-center h-full" role="status" aria-label="Loading map">
<div style={{ width: '100%', maxWidth: 600, padding: '0 24px' }}>
<div className="flex flex-col gap-4">
{/* Skeleton org chart: 3 rows of rectangles */}
<div className="flex justify-center">
<Skeleton className="rounded-xl" style={{ width: 140, height: 60 }} />
</div>
<div className="flex justify-center gap-6">
<Skeleton className="rounded-xl" style={{ width: 120, height: 52 }} />
<Skeleton className="rounded-xl" style={{ width: 120, height: 52 }} />
<Skeleton className="rounded-xl" style={{ width: 120, height: 52 }} />
</div>
<div className="flex justify-center gap-4">
<Skeleton className="rounded-xl" style={{ width: 100, height: 44 }} />
<Skeleton className="rounded-xl" style={{ width: 100, height: 44 }} />
<Skeleton className="rounded-xl" style={{ width: 100, height: 44 }} />
<Skeleton className="rounded-xl" style={{ width: 100, height: 44 }} />
</div>
</div>
</div>
</div>
),
@@ -43,25 +61,50 @@ export default function ManorPage() {
const [selected, setSelected] = useState<Agent | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const closeBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const fetchData = useCallback(() => {
setLoading(true);
setError(null);
Promise.all([
fetch("/api/agents").then((r) => r.json()),
fetch("/api/crons").then((r) => r.json()),
])
.then(([a, c]) => { setAgents(a); setCrons(c); })
.then(([a, c]) => {
setAgents(Array.isArray(a) ? a : []);
setCrons(Array.isArray(c) ? c : []);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
// ESC key to close detail panel
useEffect(() => {
if (!selected) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
setSelected(null);
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selected]);
// Auto-focus close button when detail panel opens
useEffect(() => {
if (selected && closeBtnRef.current) {
closeBtnRef.current.focus();
}
}, [selected]);
const agentCrons = selected ? crons.filter((c) => c.agentId === selected.id) : [];
if (error) {
return (
<div className="flex items-center justify-center h-full" style={{ color: 'var(--system-red)', fontSize: '13px' }}>
Error loading manor: {error}
</div>
);
return <ErrorState message={`Error loading manor: ${error}`} onRetry={fetchData} />;
}
return (
@@ -69,9 +112,24 @@ export default function ManorPage() {
{/* Map */}
<div className="flex-1 h-full">
{loading ? (
<div className="flex items-center justify-center h-full">
<div style={{ fontSize: '13px', color: 'var(--text-tertiary)' }} className="animate-pulse">
Scanning the manor...
<div className="flex items-center justify-center h-full" role="status" aria-label="Loading agents">
<div style={{ width: '100%', maxWidth: 600, padding: '0 24px' }}>
<div className="flex flex-col gap-4">
<div className="flex justify-center">
<Skeleton className="rounded-xl" style={{ width: 140, height: 60 }} />
</div>
<div className="flex justify-center gap-6">
<Skeleton className="rounded-xl" style={{ width: 120, height: 52 }} />
<Skeleton className="rounded-xl" style={{ width: 120, height: 52 }} />
<Skeleton className="rounded-xl" style={{ width: 120, height: 52 }} />
</div>
<div className="flex justify-center gap-4">
<Skeleton className="rounded-xl" style={{ width: 100, height: 44 }} />
<Skeleton className="rounded-xl" style={{ width: 100, height: 44 }} />
<Skeleton className="rounded-xl" style={{ width: 100, height: 44 }} />
<Skeleton className="rounded-xl" style={{ width: 100, height: 44 }} />
</div>
</div>
</div>
</div>
) : (
@@ -81,242 +139,261 @@ export default function ManorPage() {
{/* Detail panel */}
{selected ? (
<>
{/* Mobile backdrop */}
<div
className="fixed inset-0 z-40 md:hidden"
style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={() => setSelected(null)}
aria-hidden="true"
/>
<div
className="animate-slide-in-right fixed inset-0 z-50 md:relative md:inset-auto md:z-auto"
style={{
width: undefined,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
background: 'var(--material-regular)',
backdropFilter: 'var(--sidebar-backdrop)',
WebkitBackdropFilter: 'var(--sidebar-backdrop)',
boxShadow: 'var(--shadow-overlay)',
}}
>
<style>{`
@media (min-width: 768px) {
.manor-detail-panel { width: 340px !important; position: relative !important; }
}
`}</style>
<div className="manor-detail-panel flex flex-col h-full" style={{ width: '100%' }}>
{/* Color strip */}
<div style={{ height: '4px', background: selected.color, flexShrink: 0 }} />
{/* Close */}
<div style={{ padding: '16px 20px 0', display: 'flex', justifyContent: 'flex-end' }}>
<button
ref={closeBtnRef}
onClick={() => setSelected(null)}
aria-label="Close detail panel"
style={{
width: '28px',
height: '28px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--fill-secondary)',
color: 'var(--text-secondary)',
border: 'none',
cursor: 'pointer',
fontSize: '13px',
transition: 'all 150ms var(--ease-spring)',
}}
>
</button>
</div>
{/* Header */}
<div style={{ padding: '8px 24px 20px' }}>
{/* Emoji on squircle */}
<div style={{
width: '64px',
height: '64px',
borderRadius: '16px',
background: `${selected.color}26`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '32px',
marginBottom: '12px',
}}>
{selected.emoji}
</div>
<h2 style={{
fontSize: '22px',
fontWeight: 700,
letterSpacing: '-0.5px',
color: 'var(--text-primary)',
margin: 0,
lineHeight: 1.2,
}}>
{selected.name}
</h2>
<p style={{
fontSize: '15px',
fontWeight: 400,
color: 'var(--text-secondary)',
margin: '2px 0 0',
}}>
{selected.title}
</p>
{/* Color badge */}
<span style={{
display: 'inline-block',
marginTop: '8px',
padding: '2px 10px',
borderRadius: '20px',
fontSize: '11px',
fontWeight: 500,
background: `${selected.color}33`,
color: selected.color,
}}>
{selected.color}
</span>
</div>
{/* Description */}
<div style={{ padding: '0 24px 16px' }}>
<p style={{
fontSize: '14px',
lineHeight: 1.65,
color: 'var(--text-secondary)',
margin: 0,
}}>
{selected.description}
</p>
</div>
{/* Tools */}
<div style={{ padding: '0 24px 16px' }}>
<div style={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase' as const,
color: 'var(--text-tertiary)',
marginBottom: '8px',
}}>
Tools
</div>
<div className="flex flex-wrap gap-1.5">
{selected.tools.map((t) => (
<span key={t} style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
background: 'var(--fill-secondary)',
borderRadius: '8px',
padding: '5px 10px',
fontSize: '12px',
fontFamily: 'var(--font-mono)',
color: 'var(--text-secondary)',
}}>
{TOOL_ICONS[t] && <span style={{ fontSize: '11px' }}>{TOOL_ICONS[t]}</span>}
{t}
</span>
))}
</div>
</div>
{/* Crons */}
{agentCrons.length > 0 && (
<div style={{ padding: '0 24px 16px' }}>
<div style={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase' as const,
color: 'var(--text-tertiary)',
marginBottom: '8px',
}}>
Crons
</div>
<div style={{
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
}}>
{agentCrons.map((c, idx) => (
<div key={c.id} style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
minHeight: '44px',
padding: '0 12px',
borderTop: idx > 0 ? '1px solid var(--separator)' : undefined,
}}>
<StatusDot status={c.status} />
<span style={{
fontSize: '14px',
fontWeight: 500,
color: 'var(--text-primary)',
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{c.name}
</span>
<span style={{
fontSize: '12px',
fontFamily: 'var(--font-mono)',
color: 'var(--text-tertiary)',
flexShrink: 0,
}}>
{c.schedule}
</span>
</div>
))}
</div>
</div>
)}
{/* CTA */}
<div style={{ marginTop: 'auto', padding: '20px 24px' }}>
<button
onClick={() => router.push(`/chat/${selected.id}`)}
style={{
width: '100%',
height: '50px',
borderRadius: '14px',
background: 'var(--accent)',
color: '#000',
fontWeight: 600,
fontSize: '15px',
border: 'none',
cursor: 'pointer',
transition: 'all 150ms var(--ease-spring)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(0.98)';
e.currentTarget.style.boxShadow = '0 0 20px rgba(245,197,24,0.30)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = 'none';
}}
onMouseDown={(e) => {
e.currentTarget.style.transform = 'scale(0.96)';
}}
onMouseUp={(e) => {
e.currentTarget.style.transform = 'scale(0.98)';
}}
>
Open Chat
</button>
</div>
</div>
</div>
</>
) : (
/* Empty state — hidden on mobile */
<div
className="animate-slide-in-right"
className="hidden md:flex"
style={{
width: '340px',
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--material-regular)',
backdropFilter: 'var(--sidebar-backdrop)',
WebkitBackdropFilter: 'var(--sidebar-backdrop)',
boxShadow: 'var(--shadow-overlay)',
}}
>
{/* Color strip */}
<div style={{ height: '4px', background: selected.color, flexShrink: 0 }} />
{/* Close */}
<div style={{ padding: '16px 20px 0', display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={() => setSelected(null)}
style={{
width: '28px',
height: '28px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--fill-secondary)',
color: 'var(--text-secondary)',
border: 'none',
cursor: 'pointer',
fontSize: '13px',
transition: 'all 150ms var(--ease-spring)',
}}
>
</button>
</div>
{/* Header */}
<div style={{ padding: '8px 24px 20px' }}>
{/* Emoji on squircle */}
<div style={{
width: '64px',
height: '64px',
borderRadius: '16px',
background: `${selected.color}26`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '32px',
marginBottom: '12px',
}}>
{selected.emoji}
</div>
<h2 style={{
fontSize: '22px',
fontWeight: 700,
letterSpacing: '-0.5px',
color: 'var(--text-primary)',
margin: 0,
lineHeight: 1.2,
}}>
{selected.name}
</h2>
<p style={{
fontSize: '15px',
fontWeight: 400,
color: 'var(--text-secondary)',
margin: '2px 0 0',
}}>
{selected.title}
</p>
{/* Color badge */}
<span style={{
display: 'inline-block',
marginTop: '8px',
padding: '2px 10px',
borderRadius: '20px',
fontSize: '11px',
fontWeight: 500,
background: `${selected.color}33`,
color: selected.color,
}}>
{selected.color}
</span>
</div>
{/* Description */}
<div style={{ padding: '0 24px 16px' }}>
<p style={{
fontSize: '14px',
lineHeight: 1.65,
color: 'var(--text-secondary)',
margin: 0,
}}>
{selected.description}
</p>
</div>
{/* Tools */}
<div style={{ padding: '0 24px 16px' }}>
<div style={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase' as const,
color: 'var(--text-tertiary)',
marginBottom: '8px',
}}>
Tools
</div>
<div className="flex flex-wrap gap-1.5">
{selected.tools.map((t) => (
<span key={t} style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
background: 'var(--fill-secondary)',
borderRadius: '8px',
padding: '5px 10px',
fontSize: '12px',
fontFamily: 'var(--font-mono)',
color: 'var(--text-secondary)',
}}>
{TOOL_ICONS[t] && <span style={{ fontSize: '11px' }}>{TOOL_ICONS[t]}</span>}
{t}
</span>
))}
</div>
</div>
{/* Crons */}
{agentCrons.length > 0 && (
<div style={{ padding: '0 24px 16px' }}>
<div style={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase' as const,
color: 'var(--text-tertiary)',
marginBottom: '8px',
}}>
Crons
</div>
<div style={{
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
}}>
{agentCrons.map((c, idx) => (
<div key={c.id} style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
minHeight: '44px',
padding: '0 12px',
borderTop: idx > 0 ? '1px solid var(--separator)' : undefined,
}}>
<StatusDot status={c.status} />
<span style={{
fontSize: '14px',
fontWeight: 500,
color: 'var(--text-primary)',
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{c.name}
</span>
<span style={{
fontSize: '12px',
fontFamily: 'var(--font-mono)',
color: 'var(--text-tertiary)',
flexShrink: 0,
}}>
{c.schedule}
</span>
</div>
))}
</div>
</div>
)}
{/* CTA */}
<div style={{ marginTop: 'auto', padding: '20px 24px' }}>
<button
onClick={() => router.push(`/chat/${selected.id}`)}
style={{
width: '100%',
height: '50px',
borderRadius: '14px',
background: 'var(--accent)',
color: '#000',
fontWeight: 600,
fontSize: '15px',
border: 'none',
cursor: 'pointer',
transition: 'all 150ms var(--ease-spring)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(0.98)';
e.currentTarget.style.boxShadow = '0 0 20px rgba(245,197,24,0.30)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = 'none';
}}
onMouseDown={(e) => {
e.currentTarget.style.transform = 'scale(0.96)';
}}
onMouseUp={(e) => {
e.currentTarget.style.transform = 'scale(0.98)';
}}
>
Open Chat
</button>
</div>
</div>
) : (
/* Empty state */
<div style={{
width: '340px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--material-regular)',
backdropFilter: 'var(--sidebar-backdrop)',
WebkitBackdropFilter: 'var(--sidebar-backdrop)',
boxShadow: 'var(--shadow-overlay)',
}}>
}}>
<div style={{ textAlign: 'center', padding: '0 24px' }}>
<div style={{ fontSize: '48px', marginBottom: '12px' }}>{"\uD83D\uDD75\uFE0F"}</div>
<div style={{
+90
View File
@@ -0,0 +1,90 @@
'use client'
interface ErrorStateProps {
message: string
onRetry?: () => void
}
export function ErrorState({ message, onRetry }: ErrorStateProps) {
return (
<div
className="flex items-center justify-center h-full"
role="alert"
style={{ background: 'var(--bg)' }}
>
<div style={{ textAlign: 'center', padding: '0 24px', maxWidth: 360 }}>
<div style={{
width: 56,
height: 56,
borderRadius: '50%',
background: 'var(--fill-secondary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
margin: '0 auto 16px',
}}>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: 'var(--text-secondary)' }}
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<div style={{
fontSize: 17,
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: 4,
}}>
Something went wrong
</div>
<p style={{
fontSize: 14,
lineHeight: 1.5,
color: 'var(--text-secondary)',
margin: '0 0 20px',
}}>
{message}
</p>
{onRetry && (
<button
onClick={onRetry}
style={{
height: 40,
padding: '0 20px',
borderRadius: 'var(--radius-md)',
background: 'var(--fill-secondary)',
color: 'var(--text-primary)',
fontWeight: 600,
fontSize: 14,
border: 'none',
cursor: 'pointer',
transition: 'all 150ms var(--ease-spring)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--fill-primary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--fill-secondary)';
}}
>
Try Again
</button>
)}
</div>
</div>
)
}
+161
View File
@@ -0,0 +1,161 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { NavLinks } from '@/components/NavLinks';
import { ThemeToggle } from '@/components/ThemeToggle';
import { usePathname } from 'next/navigation';
export function MobileSidebar() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
// Close sidebar on route change
useEffect(() => {
setOpen(false);
}, [pathname]);
// Close on ESC
useEffect(() => {
if (!open) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open]);
// Prevent body scroll when open
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
}, [open]);
const toggle = useCallback(() => setOpen(prev => !prev), []);
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}
style={{
width: 36,
height: 36,
borderRadius: 8,
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)',
}}
>
<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',
}}
/>
<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>
{/* 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"
style={{
width: 260,
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)',
boxShadow: open ? 'var(--shadow-overlay)' : 'none',
}}
aria-hidden={!open}
>
{/* 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>
</>
);
}
+18 -3
View File
@@ -18,9 +18,22 @@ export function NavLinks() {
useEffect(() => {
fetch("/api/agents")
.then((r) => r.json())
.then((agents: unknown[]) => setAgentCount(agents.length))
.catch(() => {});
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((data: unknown) => {
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() {
@@ -73,6 +86,8 @@ export function NavLinks() {
key={item.href}
href={item.href}
className="flex items-center gap-2.5 no-underline"
aria-label={item.label}
aria-current={isActive ? "page" : undefined}
style={{
height: '34px',
padding: '0 8px 0 12px',
+44 -1
View File
@@ -1,9 +1,31 @@
'use client';
import { useRef, useCallback } from 'react';
import { THEMES } from '@/lib/themes';
import { useTheme } from '@/app/providers';
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 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]);
return (
<div style={{ padding: '8px 16px 12px' }}>
<div style={{
@@ -17,7 +39,13 @@ export function ThemeToggle() {
}}>
THEME
</div>
<div className="flex gap-1.5">
<div
ref={containerRef}
className="flex gap-1.5"
role="radiogroup"
aria-label="Theme selection"
onKeyDown={handleKeyDown}
>
{THEMES.map(t => {
const isActive = theme === t.id;
return (
@@ -25,6 +53,10 @@ export function ThemeToggle() {
key={t.id}
onClick={() => setTheme(t.id)}
title={t.label}
role="radio"
aria-checked={isActive}
aria-label={`${t.label} theme`}
tabIndex={isActive ? 0 : -1}
style={{
width: '28px',
height: '28px',
@@ -38,6 +70,17 @@ export function ThemeToggle() {
transition: 'all 150ms var(--ease-spring)',
background: isActive ? 'var(--accent-fill)' : 'var(--fill-quaternary)',
boxShadow: isActive ? '0 0 0 1.5px var(--accent)' : 'none',
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}
+174 -110
View File
@@ -1,16 +1,28 @@
'use client'
import { useState } from 'react'
import type { Agent } from '@/lib/types'
import type { ConversationStore } from '@/lib/conversations'
import { Skeleton } from '@/components/ui/skeleton'
interface AgentListProps {
agents: Agent[]
conversations: ConversationStore
activeId: string | null
onSelect: (agent: Agent) => void
loading?: boolean
}
export function AgentList({ agents, conversations, activeId, onSelect }: AgentListProps) {
const sorted = [...agents].sort((a, b) => {
export function AgentList({ agents, conversations, activeId, onSelect, loading }: AgentListProps) {
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
@@ -52,127 +64,179 @@ export function AgentList({ agents, conversations, activeId, onSelect }: AgentLi
alignItems: 'center',
gap: 8,
}}>
<span style={{ fontSize: 14, color: 'var(--text-tertiary)' }}>&#128269;</span>
<span style={{ fontSize: 14, color: 'var(--text-tertiary)' }}>Search agents...</span>
<span style={{ fontSize: 14, color: 'var(--text-tertiary)', flexShrink: 0 }} aria-hidden="true">&#128269;</span>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search agents..."
aria-label="Search agents"
style={{
flex: 1,
fontSize: 14,
color: 'var(--text-primary)',
background: 'transparent',
border: 'none',
outline: 'none',
padding: 0,
margin: 0,
lineHeight: 1.4,
}}
/>
</div>
</div>
{/* Agent list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
{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
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }} role="listbox" aria-label="Agent list">
{loading ? (
/* Skeleton loaders while agents load */
<div style={{ padding: '4px 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>
</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',
textAlign: 'center',
}}>
<div style={{
fontSize: 13,
color: 'var(--text-tertiary)',
lineHeight: 1.5,
}}>
No agents match &lsquo;{search.trim()}&rsquo;
</div>
</div>
) : (
sorted.map(agent => {
const conv = conversations[agent.id]
const lastMsg = conv?.messages[conv.messages.length - 1]
const unread = conv?.unread || 0
const isActive = agent.id === activeId
const preview = lastMsg
? lastMsg.content.replace(/[#*`]/g, '').slice(0, 55) + (lastMsg.content.length > 55 ? '\u2026' : '')
: agent.description?.slice(0, 55) || 'Start a conversation'
const preview = lastMsg
? lastMsg.content.replace(/[#*`]/g, '').slice(0, 55) + (lastMsg.content.length > 55 ? '\u2026' : '')
: agent.description?.slice(0, 55) || 'Start a conversation'
const timeLabel = lastMsg ? formatTime(lastMsg.timestamp) : ''
const timeLabel = lastMsg ? formatTime(lastMsg.timestamp) : ''
return (
<button
key={agent.id}
onClick={() => onSelect(agent)}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 14px',
background: isActive ? 'var(--accent-fill, rgba(255,255,255,0.12))' : '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,
borderRadius: '50%',
background: `linear-gradient(135deg, ${agent.color}cc, ${agent.color}55)`,
return (
<button
key={agent.id}
onClick={() => onSelect(agent)}
role="option"
aria-selected={isActive}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
boxShadow: isActive ? `0 0 0 2px var(--accent)` : 'none',
border: `2px solid ${agent.color}44`,
}}>
{agent.emoji}
gap: 12,
padding: '10px 14px',
background: isActive ? 'var(--accent-fill, rgba(255,255,255,0.12))' : '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,
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`,
}}>
{agent.emoji}
</div>
<div style={{
position: 'absolute',
bottom: 1,
right: 1,
width: 12,
height: 12,
borderRadius: '50%',
background: 'var(--system-green, #30d158)',
border: '2px solid var(--bg, #000)',
}} />
</div>
<div style={{
position: 'absolute',
bottom: 1,
right: 1,
width: 12,
height: 12,
borderRadius: '50%',
background: 'var(--system-green, #30d158)',
border: '2px solid var(--bg, #000)',
}} />
</div>
{/* Text content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 2 }}>
<span style={{
fontSize: 15,
fontWeight: unread > 0 ? 700 : 600,
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 }}>
{timeLabel}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{
fontSize: 13,
color: unread > 0 ? 'var(--text-secondary)' : 'var(--text-tertiary)',
fontWeight: unread > 0 ? 500 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 160,
}}>
{lastMsg?.role === 'user' ? 'You: ' : ''}{preview}
</span>
{unread > 0 && (
<div style={{
flexShrink: 0,
marginLeft: 6,
background: 'var(--accent)',
color: '#000',
borderRadius: '50%',
width: 20,
height: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
fontWeight: 700,
{/* Text content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 2 }}>
<span style={{
fontSize: 15,
fontWeight: unread > 0 ? 700 : 600,
color: 'var(--text-primary)',
letterSpacing: '-0.2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 130,
}}>
{unread > 9 ? '9+' : unread}
</div>
)}
{agent.name}
</span>
<span style={{ fontSize: 11, color: unread > 0 ? 'var(--accent)' : 'var(--text-tertiary)', flexShrink: 0, marginLeft: 4 }}>
{timeLabel}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{
fontSize: 13,
color: unread > 0 ? 'var(--text-secondary)' : 'var(--text-tertiary)',
fontWeight: unread > 0 ? 500 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 160,
}}>
{lastMsg?.role === 'user' ? 'You: ' : ''}{preview}
</span>
{unread > 0 && (
<div style={{
flexShrink: 0,
marginLeft: 6,
background: 'var(--accent)',
color: '#000',
borderRadius: '50%',
width: 20,
height: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
fontWeight: 700,
}}>
{unread > 9 ? '9+' : unread}
</div>
)}
</div>
</div>
</div>
</button>
)
})}
</button>
)
})
)}
</div>
</div>
)
+15 -5
View File
@@ -123,6 +123,8 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation
const textareaRef = useRef<HTMLTextAreaElement>(null)
const messages = conversation?.messages || []
const messagesRef = useRef(messages)
messagesRef.current = messages
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -159,7 +161,8 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation
setIsStreaming(true)
// Build message history for API (role + content only)
const apiMessages = [...messages, userMsg].map(m => ({ role: m.role, content: m.content }))
// Use ref to read the latest messages and avoid stale closure on concurrent sends
const apiMessages = [...messagesRef.current, userMsg].map(m => ({ role: m.role, content: m.content }))
try {
const res = await fetch(`/api/chat/${agent.id}`, {
@@ -204,9 +207,14 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation
setIsStreaming(false)
textareaRef.current?.focus()
}
}, [input, isStreaming, agent.id, messages, onUpdate])
}, [input, isStreaming, agent.id, onUpdate])
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Escape') {
e.preventDefault()
textareaRef.current?.blur()
return
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
@@ -273,6 +281,7 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation
onClick={clearChat}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-tertiary)', padding: 4 }}
title="Clear conversation"
aria-label="Clear conversation"
>
<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" />
@@ -458,7 +467,7 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation
}}>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
{/* Attach button */}
<label style={{ cursor: 'pointer', color: 'var(--text-tertiary)', padding: 8, flexShrink: 0, fontSize: 18 }} title="Attach image">
<label style={{ cursor: 'pointer', color: 'var(--text-tertiary)', padding: 8, flexShrink: 0, fontSize: 18 }} title="Attach image" aria-label="Attach file">
&#128206;
<input
type="file"
@@ -524,14 +533,15 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation
transition: 'transform 150ms ease',
}}
title="Send message"
aria-label="Send message"
>
&#8593;
</button>
</div>
</div>
<p style={{ fontSize: 11, textAlign: 'center', marginTop: 8, marginBottom: 2, color: 'var(--text-tertiary)' }}>
&#8629; Send &middot; &#8679;&#8629; New line
<p style={{ fontSize: 11, textAlign: 'center', marginTop: 8, marginBottom: 2, color: 'var(--text-quaternary)' }}>
Enter to send &middot; Shift+Enter for newline
</p>
</div>
</div>
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md", className)}
style={{ background: 'var(--fill-secondary)' }}
{...props}
/>
)
}
export { Skeleton }
+282
View File
@@ -0,0 +1,282 @@
[
{
"id": "jarvis",
"name": "Jarvis",
"title": "Manor Orchestrator",
"reportsTo": null,
"directReports": ["vera", "lumen", "herald", "pulse", "echo", "sage", "kaze", "spark", "scribe"],
"soulPath": "SOUL.md",
"voiceId": "agL69Vji082CshT65Tcy",
"color": "#f5c518",
"emoji": "\ud83e\udd16",
"tools": ["exec", "read", "write", "edit", "web_search", "tts", "message", "sessions_spawn", "memory_search"],
"memoryPath": null,
"description": "Manor orchestrator. Manages the team, holds memory, delivers briefings."
},
{
"id": "vera",
"name": "VERA",
"title": "Chief Strategy Officer",
"reportsTo": "jarvis",
"directReports": ["robin"],
"soulPath": "agents/vera/SOUL.md",
"voiceId": "EAHourGM2PqzHHl0Ywjp",
"color": "#a855f7",
"emoji": "\u265f\ufe0f",
"tools": ["web_search", "web_fetch", "read", "write", "sessions_spawn"],
"memoryPath": null,
"description": "CSO. Manages validation team. Decides what gets built and what gets killed."
},
{
"id": "robin",
"name": "Robin",
"title": "Field Intel Operator",
"reportsTo": "vera",
"directReports": ["trace", "proof"],
"soulPath": "agents/robin/SOUL.md",
"voiceId": "IRHApOXLvnW57QJPQH2P",
"color": "#3b82f6",
"emoji": "\ud83e\udd85",
"tools": ["web_search", "web_fetch", "read", "write", "message"],
"memoryPath": null,
"description": "Field operator. Competitive intel, opportunity scouting, lead signals."
},
{
"id": "trace",
"name": "TRACE",
"title": "Market Researcher",
"reportsTo": "robin",
"directReports": [],
"soulPath": "agents/trace/SOUL.md",
"voiceId": null,
"color": "#06b6d4",
"emoji": "\ud83d\udd0d",
"tools": ["web_search", "web_fetch", "read", "write"],
"memoryPath": null,
"description": "Market research. TAM, competitors, pricing benchmarks. Returns Market Briefs."
},
{
"id": "proof",
"name": "PROOF",
"title": "Validation Designer",
"reportsTo": "robin",
"directReports": [],
"soulPath": "agents/proof/SOUL.md",
"voiceId": null,
"color": "#06b6d4",
"emoji": "\u2705",
"tools": ["web_search", "web_fetch", "read", "write"],
"memoryPath": null,
"description": "Designs minimum viable tests. Writes outreach copy. Calls BUILD/KILL/PIVOT."
},
{
"id": "lumen",
"name": "LUMEN",
"title": "SEO Team Director",
"reportsTo": "jarvis",
"directReports": ["scout", "analyst", "strategist", "writer", "auditor"],
"soulPath": "agents/seo-team/SOUL.md",
"voiceId": "EVy5l1wEi54nXdQwAJJf",
"color": "#22c55e",
"emoji": "\ud83d\udd26",
"tools": ["web_search", "web_fetch", "read", "write", "exec"],
"memoryPath": null,
"description": "SEO Team Director. Runs SCOUT\u2192ANALYST\u2192STRATEGIST\u2192WRITER pipeline."
},
{
"id": "scout",
"name": "SCOUT",
"title": "Content Scout",
"reportsTo": "lumen",
"directReports": [],
"soulPath": null,
"voiceId": null,
"color": "#86efac",
"emoji": "\ud83d\uddfa\ufe0f",
"tools": ["web_search", "web_fetch", "read"],
"memoryPath": null,
"description": "Scouts trending topics, pulls RSS feeds, identifies content opportunities."
},
{
"id": "analyst",
"name": "ANALYST",
"title": "SEO Analyst",
"reportsTo": "lumen",
"directReports": [],
"soulPath": null,
"voiceId": null,
"color": "#86efac",
"emoji": "\ud83d\udcca",
"tools": ["web_search", "web_fetch", "read", "write"],
"memoryPath": null,
"description": "Keyword research, GSC data analysis, competitive gap identification."
},
{
"id": "strategist",
"name": "STRATEGIST",
"title": "Content Strategist",
"reportsTo": "lumen",
"directReports": [],
"soulPath": null,
"voiceId": null,
"color": "#86efac",
"emoji": "\ud83c\udfaf",
"tools": ["read", "write"],
"memoryPath": null,
"description": "Topic angle selection using SAGE and ECHO briefs."
},
{
"id": "writer",
"name": "WRITER",
"title": "Content Writer",
"reportsTo": "lumen",
"directReports": [],
"soulPath": null,
"voiceId": null,
"color": "#86efac",
"emoji": "\u270d\ufe0f",
"tools": ["read", "write"],
"memoryPath": null,
"description": "1500-2000 word posts in John's voice."
},
{
"id": "auditor",
"name": "AUDITOR",
"title": "Quality Gate",
"reportsTo": "lumen",
"directReports": [],
"soulPath": null,
"voiceId": null,
"color": "#86efac",
"emoji": "\ud83d\udee1\ufe0f",
"tools": ["read", "write"],
"memoryPath": null,
"description": "Pre-ship quality gate. 6-item checklist before publishing."
},
{
"id": "herald",
"name": "HERALD",
"title": "LinkedIn Content Director",
"reportsTo": "jarvis",
"directReports": ["quill", "maven"],
"soulPath": "agents/herald/SOUL.md",
"voiceId": null,
"color": "#f97316",
"emoji": "\ud83d\udce3",
"tools": ["web_search", "web_fetch", "read", "write", "message", "exec"],
"memoryPath": null,
"description": "LinkedIn content pipeline. Reads Pulse feed, picks angles, briefs QUILL."
},
{
"id": "quill",
"name": "QUILL",
"title": "LinkedIn Writer",
"reportsTo": "herald",
"directReports": [],
"soulPath": "agents/herald/sub-agents/QUILL.md",
"voiceId": null,
"color": "#fdba74",
"emoji": "\ud83d\udd8a\ufe0f",
"tools": ["read", "write"],
"memoryPath": null,
"description": "Writes LinkedIn posts in John's voice."
},
{
"id": "maven",
"name": "MAVEN",
"title": "LinkedIn Strategist",
"reportsTo": "herald",
"directReports": [],
"soulPath": "agents/herald/sub-agents/MAVEN.md",
"voiceId": null,
"color": "#fdba74",
"emoji": "\ud83e\udded",
"tools": ["web_search", "read", "write"],
"memoryPath": null,
"description": "Weekly LinkedIn strategy and content calendar."
},
{
"id": "pulse",
"name": "Pulse",
"title": "Trend Radar",
"reportsTo": "jarvis",
"directReports": [],
"soulPath": "agents/pulse/SOUL.md",
"voiceId": "eadgjmk4R4uojdsheG9t",
"color": "#eab308",
"emoji": "\ud83c\udf0a",
"tools": ["web_search", "web_fetch", "read", "write", "message"],
"memoryPath": null,
"description": "Hype radar. Monitors trending signals. Feeds hot topics to LUMEN."
},
{
"id": "echo",
"name": "ECHO",
"title": "Community Voice Monitor",
"reportsTo": "jarvis",
"directReports": [],
"soulPath": "agents/echo/SOUL.md",
"voiceId": null,
"color": "#14b8a6",
"emoji": "\ud83d\udce1",
"tools": ["web_fetch", "read", "write"],
"memoryPath": null,
"description": "Scans ICP subreddits weekly. Extracts verbatim customer language."
},
{
"id": "sage",
"name": "SAGE",
"title": "ICP & Market Expert",
"reportsTo": "jarvis",
"directReports": [],
"soulPath": "agents/sage/SOUL.md",
"voiceId": null,
"color": "#14b8a6",
"emoji": "\ud83e\uddd9",
"tools": ["read"],
"memoryPath": null,
"description": "Deep ICP and market knowledge. Injected into STRATEGIST and WRITER."
},
{
"id": "kaze",
"name": "KAZE",
"title": "Japan Flight Monitor",
"reportsTo": "jarvis",
"directReports": [],
"soulPath": "agents/kaze/SOUL.md",
"voiceId": null,
"color": "#60a5fa",
"emoji": "\u2708\ufe0f",
"tools": ["web_fetch", "message"],
"memoryPath": null,
"description": "Monitors MSP to Tokyo flights. Alerts on deals under $1400."
},
{
"id": "spark",
"name": "SPARK",
"title": "Tech Discovery",
"reportsTo": "jarvis",
"directReports": [],
"soulPath": "agents/spark/SOUL.md",
"voiceId": "xNtG3W2oqJs0cJZuTyBc",
"color": "#f59e0b",
"emoji": "\u26a1",
"tools": ["web_fetch", "web_search", "message"],
"memoryPath": null,
"description": "Finds cool OpenClaw builds. Reports every other day."
},
{
"id": "scribe",
"name": "SCRIBE",
"title": "Memory Architect",
"reportsTo": "jarvis",
"directReports": [],
"soulPath": "agents/scribe/SOUL.md",
"voiceId": null,
"color": "#94a3b8",
"emoji": "\ud83d\udcda",
"tools": ["read", "write", "exec"],
"memoryPath": null,
"description": "Weekly memory compression. Silent worker."
}
]
+245
View File
@@ -0,0 +1,245 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockReadFileSync, mockExistsSync } = vi.hoisted(() => ({
mockReadFileSync: vi.fn(),
mockExistsSync: vi.fn(),
}))
// Mock fs (Dependency Inversion -- no real file system access in tests)
vi.mock('fs', () => ({
readFileSync: mockReadFileSync,
existsSync: mockExistsSync,
default: { readFileSync: mockReadFileSync, existsSync: mockExistsSync },
}))
// Mock the agents.json import with representative test data
vi.mock('@/lib/agents.json', () => ({
default: [
{
id: 'jarvis',
name: 'Jarvis',
title: 'Manor Orchestrator',
reportsTo: null,
directReports: ['vera', 'lumen', 'pulse'],
soulPath: 'SOUL.md',
voiceId: 'agL69Vji082CshT65Tcy',
color: '#f5c518',
emoji: 'R',
tools: ['exec', 'read', 'write'],
memoryPath: null,
description: 'Manor orchestrator.',
},
{
id: 'vera',
name: 'VERA',
title: 'Chief Strategy Officer',
reportsTo: 'jarvis',
directReports: ['robin'],
soulPath: 'agents/vera/SOUL.md',
voiceId: 'EAHourGM2PqzHHl0Ywjp',
color: '#a855f7',
emoji: 'P',
tools: ['web_search', 'read'],
memoryPath: null,
description: 'CSO. Decides what gets built.',
},
{
id: 'robin',
name: 'Robin',
title: 'Field Intel Operator',
reportsTo: 'vera',
directReports: [],
soulPath: 'agents/robin/SOUL.md',
voiceId: null,
color: '#3b82f6',
emoji: 'E',
tools: ['web_search'],
memoryPath: null,
description: 'Field operator.',
},
{
id: 'lumen',
name: 'LUMEN',
title: 'SEO Team Director',
reportsTo: 'jarvis',
directReports: ['scout'],
soulPath: 'agents/seo-team/SOUL.md',
voiceId: null,
color: '#22c55e',
emoji: 'L',
tools: ['web_search', 'read'],
memoryPath: null,
description: 'SEO Team Director.',
},
{
id: 'scout',
name: 'SCOUT',
title: 'Content Scout',
reportsTo: 'lumen',
directReports: [],
soulPath: null,
voiceId: null,
color: '#86efac',
emoji: 'S',
tools: ['web_search'],
memoryPath: null,
description: 'Scouts trending topics.',
},
{
id: 'pulse',
name: 'Pulse',
title: 'Trend Radar',
reportsTo: 'jarvis',
directReports: [],
soulPath: 'agents/pulse/SOUL.md',
voiceId: null,
color: '#eab308',
emoji: 'W',
tools: ['web_search'],
memoryPath: null,
description: 'Hype radar.',
},
{
id: 'kaze',
name: 'KAZE',
title: 'Japan Flight Monitor',
reportsTo: 'jarvis',
directReports: [],
soulPath: null,
voiceId: null,
color: '#60a5fa',
emoji: 'A',
tools: ['web_fetch'],
memoryPath: null,
description: 'Monitors flights.',
},
],
}))
import { getAgents, getAgent } from './agents'
beforeEach(() => {
vi.clearAllMocks()
// Default: no SOUL files exist on disk
mockExistsSync.mockReturnValue(false)
})
// --- getAgents ---
describe('getAgents', () => {
it('returns all agents from the registry', async () => {
const agents = await getAgents()
expect(agents.length).toBeGreaterThan(0)
})
it('every agent has required fields', async () => {
const agents = await getAgents()
for (const agent of agents) {
expect(agent.id).toEqual(expect.any(String))
expect(agent.name).toEqual(expect.any(String))
expect(agent.title).toEqual(expect.any(String))
expect(agent.color).toMatch(/^#[0-9a-fA-F]{6}$/)
expect(agent.emoji).toEqual(expect.any(String))
expect(Array.isArray(agent.tools)).toBe(true)
expect(Array.isArray(agent.directReports)).toBe(true)
expect(Array.isArray(agent.crons)).toBe(true)
expect(agent.description).toEqual(expect.any(String))
}
})
it('includes known agents by id', async () => {
const agents = await getAgents()
const ids = agents.map(a => a.id)
expect(ids).toContain('jarvis')
expect(ids).toContain('vera')
expect(ids).toContain('lumen')
expect(ids).toContain('pulse')
expect(ids).toContain('kaze')
})
it('sets soul to null when soulPath file does not exist', async () => {
mockExistsSync.mockReturnValue(false)
const agents = await getAgents()
const jarvis = agents.find(a => a.id === 'jarvis')!
expect(jarvis.soulPath).toBeTruthy()
expect(jarvis.soul).toBeNull()
})
it('reads soul content when soulPath file exists', async () => {
mockExistsSync.mockReturnValue(true)
mockReadFileSync.mockReturnValue('# Jarvis SOUL')
const agents = await getAgents()
const jarvis = agents.find(a => a.id === 'jarvis')!
expect(jarvis.soul).toBe('# Jarvis SOUL')
})
it('sets soul to null when readFileSync throws', async () => {
mockExistsSync.mockReturnValue(true)
mockReadFileSync.mockImplementation(() => { throw new Error('EACCES') })
const agents = await getAgents()
const jarvis = agents.find(a => a.id === 'jarvis')!
expect(jarvis.soul).toBeNull()
})
it('initializes crons as empty array for every agent', async () => {
const agents = await getAgents()
for (const agent of agents) {
expect(agent.crons).toEqual([])
}
})
it('agents with no soulPath get soul=null without reading fs', async () => {
const agents = await getAgents()
const scout = agents.find(a => a.id === 'scout')!
expect(scout.soulPath).toBeNull()
expect(scout.soul).toBeNull()
})
})
// --- getAgent ---
describe('getAgent', () => {
it('returns the correct agent by id', async () => {
const agent = await getAgent('vera')
expect(agent).not.toBeNull()
expect(agent!.id).toBe('vera')
expect(agent!.name).toBe('VERA')
expect(agent!.title).toBe('Chief Strategy Officer')
})
it('returns null for an unknown id', async () => {
const agent = await getAgent('nonexistent-agent')
expect(agent).toBeNull()
})
it('returns null for empty string', async () => {
const agent = await getAgent('')
expect(agent).toBeNull()
})
it('is case-sensitive (uppercase id returns null)', async () => {
const agent = await getAgent('VERA')
expect(agent).toBeNull()
})
it('returns agent with correct directReports', async () => {
const jarvis = await getAgent('jarvis')
expect(jarvis).not.toBeNull()
expect(jarvis!.directReports).toContain('vera')
expect(jarvis!.directReports).toContain('lumen')
expect(jarvis!.directReports).toContain('pulse')
})
it('returns agent with correct reportsTo chain', async () => {
const robin = await getAgent('robin')
expect(robin).not.toBeNull()
expect(robin!.reportsTo).toBe('vera')
const vera = await getAgent('vera')
expect(vera!.reportsTo).toBe('jarvis')
const jarvis = await getAgent('jarvis')
expect(jarvis!.reportsTo).toBeNull()
})
})
+5 -282
View File
@@ -1,290 +1,13 @@
import { Agent } from '@/lib/types'
import { readFileSync, existsSync } from 'fs'
import registryData from '@/lib/agents.json'
const WORKSPACE_PATH = process.env.WORKSPACE_PATH || '/Users/johnrice/.openclaw/workspace'
const registry: Omit<Agent, 'soul' | 'crons'>[] = [
{
id: 'jarvis',
name: 'Jarvis',
title: 'Manor Orchestrator',
reportsTo: null,
directReports: ['vera', 'lumen', 'herald', 'pulse', 'echo', 'sage', 'kaze', 'spark', 'scribe'],
soulPath: 'SOUL.md',
voiceId: 'agL69Vji082CshT65Tcy',
color: '#f5c518',
emoji: '🤖',
tools: ['exec', 'read', 'write', 'edit', 'web_search', 'tts', 'message', 'sessions_spawn', 'memory_search'],
memoryPath: null,
description: 'Manor orchestrator. Manages the team, holds memory, delivers briefings.',
},
{
id: 'vera',
name: 'VERA',
title: 'Chief Strategy Officer',
reportsTo: 'jarvis',
directReports: ['robin'],
soulPath: 'agents/vera/SOUL.md',
voiceId: 'EAHourGM2PqzHHl0Ywjp',
color: '#a855f7',
emoji: '♟️',
tools: ['web_search', 'web_fetch', 'read', 'write', 'sessions_spawn'],
memoryPath: null,
description: 'CSO. Manages validation team. Decides what gets built and what gets killed.',
},
{
id: 'robin',
name: 'Robin',
title: 'Field Intel Operator',
reportsTo: 'vera',
directReports: ['trace', 'proof'],
soulPath: 'agents/robin/SOUL.md',
voiceId: 'IRHApOXLvnW57QJPQH2P',
color: '#3b82f6',
emoji: '🦅',
tools: ['web_search', 'web_fetch', 'read', 'write', 'message'],
memoryPath: null,
description: 'Field operator. Competitive intel, opportunity scouting, lead signals.',
},
{
id: 'trace',
name: 'TRACE',
title: 'Market Researcher',
reportsTo: 'robin',
directReports: [],
soulPath: 'agents/trace/SOUL.md',
voiceId: null,
color: '#06b6d4',
emoji: '🔍',
tools: ['web_search', 'web_fetch', 'read', 'write'],
memoryPath: null,
description: 'Market research. TAM, competitors, pricing benchmarks. Returns Market Briefs.',
},
{
id: 'proof',
name: 'PROOF',
title: 'Validation Designer',
reportsTo: 'robin',
directReports: [],
soulPath: 'agents/proof/SOUL.md',
voiceId: null,
color: '#06b6d4',
emoji: '✅',
tools: ['web_search', 'web_fetch', 'read', 'write'],
memoryPath: null,
description: 'Designs minimum viable tests. Writes outreach copy. Calls BUILD/KILL/PIVOT.',
},
{
id: 'lumen',
name: 'LUMEN',
title: 'SEO Team Director',
reportsTo: 'jarvis',
directReports: ['scout', 'analyst', 'strategist', 'writer', 'auditor'],
soulPath: 'agents/seo-team/SOUL.md',
voiceId: 'EVy5l1wEi54nXdQwAJJf',
color: '#22c55e',
emoji: '🔦',
tools: ['web_search', 'web_fetch', 'read', 'write', 'exec'],
memoryPath: null,
description: 'SEO Team Director. Runs SCOUT→ANALYST→STRATEGIST→WRITER pipeline.',
},
{
id: 'scout',
name: 'SCOUT',
title: 'Content Scout',
reportsTo: 'lumen',
directReports: [],
soulPath: null,
voiceId: null,
color: '#86efac',
emoji: '🗺️',
tools: ['web_search', 'web_fetch', 'read'],
memoryPath: null,
description: 'Scouts trending topics, pulls RSS feeds, identifies content opportunities.',
},
{
id: 'analyst',
name: 'ANALYST',
title: 'SEO Analyst',
reportsTo: 'lumen',
directReports: [],
soulPath: null,
voiceId: null,
color: '#86efac',
emoji: '📊',
tools: ['web_search', 'web_fetch', 'read', 'write'],
memoryPath: null,
description: 'Keyword research, GSC data analysis, competitive gap identification.',
},
{
id: 'strategist',
name: 'STRATEGIST',
title: 'Content Strategist',
reportsTo: 'lumen',
directReports: [],
soulPath: null,
voiceId: null,
color: '#86efac',
emoji: '🎯',
tools: ['read', 'write'],
memoryPath: null,
description: 'Topic angle selection using SAGE and ECHO briefs.',
},
{
id: 'writer',
name: 'WRITER',
title: 'Content Writer',
reportsTo: 'lumen',
directReports: [],
soulPath: null,
voiceId: null,
color: '#86efac',
emoji: '✍️',
tools: ['read', 'write'],
memoryPath: null,
description: '1500-2000 word posts in John\'s voice.',
},
{
id: 'auditor',
name: 'AUDITOR',
title: 'Quality Gate',
reportsTo: 'lumen',
directReports: [],
soulPath: null,
voiceId: null,
color: '#86efac',
emoji: '🛡️',
tools: ['read', 'write'],
memoryPath: null,
description: 'Pre-ship quality gate. 6-item checklist before publishing.',
},
{
id: 'herald',
name: 'HERALD',
title: 'LinkedIn Content Director',
reportsTo: 'jarvis',
directReports: ['quill', 'maven'],
soulPath: 'agents/herald/SOUL.md',
voiceId: null,
color: '#f97316',
emoji: '📣',
tools: ['web_search', 'web_fetch', 'read', 'write', 'message', 'exec'],
memoryPath: null,
description: 'LinkedIn content pipeline. Reads Pulse feed, picks angles, briefs QUILL.',
},
{
id: 'quill',
name: 'QUILL',
title: 'LinkedIn Writer',
reportsTo: 'herald',
directReports: [],
soulPath: 'agents/herald/sub-agents/QUILL.md',
voiceId: null,
color: '#fdba74',
emoji: '🖊️',
tools: ['read', 'write'],
memoryPath: null,
description: 'Writes LinkedIn posts in John\'s voice.',
},
{
id: 'maven',
name: 'MAVEN',
title: 'LinkedIn Strategist',
reportsTo: 'herald',
directReports: [],
soulPath: 'agents/herald/sub-agents/MAVEN.md',
voiceId: null,
color: '#fdba74',
emoji: '🧭',
tools: ['web_search', 'read', 'write'],
memoryPath: null,
description: 'Weekly LinkedIn strategy and content calendar.',
},
{
id: 'pulse',
name: 'Pulse',
title: 'Trend Radar',
reportsTo: 'jarvis',
directReports: [],
soulPath: 'agents/pulse/SOUL.md',
voiceId: 'eadgjmk4R4uojdsheG9t',
color: '#eab308',
emoji: '🌊',
tools: ['web_search', 'web_fetch', 'read', 'write', 'message'],
memoryPath: null,
description: 'Hype radar. Monitors trending signals. Feeds hot topics to LUMEN.',
},
{
id: 'echo',
name: 'ECHO',
title: 'Community Voice Monitor',
reportsTo: 'jarvis',
directReports: [],
soulPath: 'agents/echo/SOUL.md',
voiceId: null,
color: '#14b8a6',
emoji: '📡',
tools: ['web_fetch', 'read', 'write'],
memoryPath: null,
description: 'Scans ICP subreddits weekly. Extracts verbatim customer language.',
},
{
id: 'sage',
name: 'SAGE',
title: 'ICP & Market Expert',
reportsTo: 'jarvis',
directReports: [],
soulPath: 'agents/sage/SOUL.md',
voiceId: null,
color: '#14b8a6',
emoji: '🧙',
tools: ['read'],
memoryPath: null,
description: 'Deep ICP and market knowledge. Injected into STRATEGIST and WRITER.',
},
{
id: 'kaze',
name: 'KAZE',
title: 'Japan Flight Monitor',
reportsTo: 'jarvis',
directReports: [],
soulPath: 'agents/kaze/SOUL.md',
voiceId: null,
color: '#60a5fa',
emoji: '✈️',
tools: ['web_fetch', 'message'],
memoryPath: null,
description: 'Monitors MSP to Tokyo flights. Alerts on deals under $1400.',
},
{
id: 'spark',
name: 'SPARK',
title: 'Tech Discovery',
reportsTo: 'jarvis',
directReports: [],
soulPath: 'agents/spark/SOUL.md',
voiceId: 'xNtG3W2oqJs0cJZuTyBc',
color: '#f59e0b',
emoji: '⚡',
tools: ['web_fetch', 'web_search', 'message'],
memoryPath: null,
description: 'Finds cool OpenClaw builds. Reports every other day.',
},
{
id: 'scribe',
name: 'SCRIBE',
title: 'Memory Architect',
reportsTo: 'jarvis',
directReports: [],
soulPath: 'agents/scribe/SOUL.md',
voiceId: null,
color: '#94a3b8',
emoji: '📚',
tools: ['read', 'write', 'exec'],
memoryPath: null,
description: 'Weekly memory compression. Silent worker.',
},
]
/** Raw agent data from JSON (everything except runtime-loaded soul and crons) */
type AgentEntry = Omit<Agent, 'soul' | 'crons'>
const registry: AgentEntry[] = registryData as AgentEntry[]
export async function getAgents(): Promise<Agent[]> {
return registry.map((entry) => {
+16
View File
@@ -0,0 +1,16 @@
/**
* Shared error response helper for API routes.
* Returns a consistent JSON shape: { error: string }
* so clients can distinguish "no data" from "server error".
*/
export function apiErrorResponse(
err: unknown,
fallbackMessage = 'Internal server error',
status = 500
): Response {
const message = err instanceof Error ? err.message : fallbackMessage
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
+331
View File
@@ -0,0 +1,331 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
addMessage,
markRead,
updateLastMessage,
parseMedia,
getOrCreateConversation,
loadConversations,
saveConversations,
type Message,
type ConversationStore,
type Conversation,
} from './conversations'
import type { Agent } from './types'
// --- helpers ---
function makeMessage(overrides: Partial<Message> = {}): Message {
return {
id: overrides.id ?? 'msg-1',
role: overrides.role ?? 'user',
content: overrides.content ?? 'hello',
timestamp: overrides.timestamp ?? 1000,
...overrides,
}
}
function makeConversation(overrides: Partial<Conversation> = {}): Conversation {
return {
agentId: overrides.agentId ?? 'vera',
messages: overrides.messages ?? [],
unread: overrides.unread ?? 0,
lastActivity: overrides.lastActivity ?? 1000,
}
}
function makeStore(entries: Record<string, Partial<Conversation>> = {}): ConversationStore {
const store: ConversationStore = {}
for (const [id, overrides] of Object.entries(entries)) {
store[id] = makeConversation({ agentId: id, ...overrides })
}
return store
}
const fakeAgent: Agent = {
id: 'vera',
name: 'VERA',
title: 'Chief Strategy Officer',
reportsTo: 'jarvis',
directReports: ['robin'],
soulPath: null,
soul: null,
voiceId: null,
color: '#a855f7',
emoji: '?',
tools: [],
crons: [],
memoryPath: null,
description: 'CSO. Decides what gets built.',
}
// --- addMessage ---
describe('addMessage', () => {
it('appends a user message without incrementing unread', () => {
const store = makeStore({ vera: { messages: [] } })
const msg = makeMessage({ role: 'user' })
const result = addMessage(store, 'vera', msg)
expect(result.vera.messages).toHaveLength(1)
expect(result.vera.messages[0]).toEqual(msg)
expect(result.vera.unread).toBe(0)
})
it('appends an assistant message and increments unread', () => {
const store = makeStore({ vera: { messages: [], unread: 2 } })
const msg = makeMessage({ role: 'assistant' })
const result = addMessage(store, 'vera', msg)
expect(result.vera.messages).toHaveLength(1)
expect(result.vera.unread).toBe(3)
})
it('creates a new conversation entry when agentId not in store', () => {
const store: ConversationStore = {}
const msg = makeMessage({ role: 'user' })
const result = addMessage(store, 'pulse', msg)
expect(result.pulse).toBeDefined()
expect(result.pulse.agentId).toBe('pulse')
expect(result.pulse.messages).toHaveLength(1)
})
it('does not mutate the original store (immutability)', () => {
const store = makeStore({ vera: { messages: [] } })
const msg = makeMessage()
const result = addMessage(store, 'vera', msg)
expect(result).not.toBe(store)
expect(result.vera).not.toBe(store.vera)
expect(store.vera.messages).toHaveLength(0)
})
it('preserves other agents in the store', () => {
const store = makeStore({
vera: { messages: [] },
pulse: { messages: [makeMessage({ id: 'existing' })] },
})
const msg = makeMessage()
const result = addMessage(store, 'vera', msg)
expect(result.pulse.messages).toHaveLength(1)
expect(result.pulse.messages[0].id).toBe('existing')
})
})
// --- markRead ---
describe('markRead', () => {
it('resets unread to 0', () => {
const store = makeStore({ vera: { unread: 5 } })
const result = markRead(store, 'vera')
expect(result.vera.unread).toBe(0)
})
it('returns the same store reference when agentId is missing', () => {
const store = makeStore({})
const result = markRead(store, 'nonexistent')
expect(result).toBe(store)
})
it('does not mutate the original store', () => {
const store = makeStore({ vera: { unread: 3 } })
const result = markRead(store, 'vera')
expect(store.vera.unread).toBe(3)
expect(result.vera.unread).toBe(0)
})
})
// --- updateLastMessage ---
describe('updateLastMessage', () => {
it('updates the matching message content and streaming flag', () => {
const store = makeStore({
vera: {
messages: [
makeMessage({ id: 'msg-1', content: 'old', isStreaming: true }),
],
},
})
const result = updateLastMessage(store, 'vera', 'msg-1', 'new content', false)
expect(result.vera.messages[0].content).toBe('new content')
expect(result.vera.messages[0].isStreaming).toBe(false)
})
it('does not touch messages with different ids', () => {
const store = makeStore({
vera: {
messages: [
makeMessage({ id: 'msg-1', content: 'keep me' }),
makeMessage({ id: 'msg-2', content: 'update me' }),
],
},
})
const result = updateLastMessage(store, 'vera', 'msg-2', 'updated', false)
expect(result.vera.messages[0].content).toBe('keep me')
expect(result.vera.messages[1].content).toBe('updated')
})
it('returns same store when agentId not found', () => {
const store = makeStore({})
const result = updateLastMessage(store, 'nonexistent', 'msg-1', 'x', false)
expect(result).toBe(store)
})
it('returns store unchanged when msgId not found (no crash)', () => {
const store = makeStore({
vera: { messages: [makeMessage({ id: 'msg-1', content: 'original' })] },
})
const result = updateLastMessage(store, 'vera', 'no-such-id', 'x', false)
expect(result.vera.messages[0].content).toBe('original')
})
})
// --- getOrCreateConversation ---
describe('getOrCreateConversation', () => {
it('returns existing conversation when it exists in store', () => {
const existing = makeConversation({ agentId: 'vera', unread: 7 })
const store: ConversationStore = { vera: existing }
const result = getOrCreateConversation(store, fakeAgent)
expect(result).toBe(existing)
expect(result.unread).toBe(7)
})
it('creates a new conversation with a greeting when not in store', () => {
const store: ConversationStore = {}
const result = getOrCreateConversation(store, fakeAgent)
expect(result.agentId).toBe('vera')
expect(result.messages).toHaveLength(1)
expect(result.messages[0].role).toBe('assistant')
expect(result.messages[0].content).toContain('VERA')
expect(result.unread).toBe(0)
})
})
// --- parseMedia ---
describe('parseMedia', () => {
it('extracts markdown image links', () => {
const content = 'Check this out: ![diagram](https://example.com/img.png)'
const media = parseMedia(content)
expect(media).toHaveLength(1)
expect(media[0].type).toBe('image')
expect(media[0].url).toBe('https://example.com/img.png')
expect(media[0].name).toBe('diagram')
})
it('extracts bare image URLs', () => {
const content = 'See https://example.com/photo.jpg for reference'
const media = parseMedia(content)
expect(media).toHaveLength(1)
expect(media[0].type).toBe('image')
expect(media[0].url).toBe('https://example.com/photo.jpg')
})
it('does not duplicate an image that appears in both markdown and bare form', () => {
const content = '![pic](https://example.com/pic.png) and also https://example.com/pic.png'
const media = parseMedia(content)
// The markdown image regex captures it first, bare regex should skip the duplicate
const imageMedia = media.filter(m => m.type === 'image')
expect(imageMedia).toHaveLength(1)
})
it('extracts audio URLs', () => {
const content = 'Listen: https://example.com/sound.mp3'
const media = parseMedia(content)
expect(media).toHaveLength(1)
expect(media[0].type).toBe('audio')
expect(media[0].url).toBe('https://example.com/sound.mp3')
})
it('extracts multiple media types from one message', () => {
const content = [
'![chart](https://example.com/chart.png)',
'https://example.com/recording.wav',
'https://example.com/bg.webp',
].join('\n')
const media = parseMedia(content)
expect(media).toHaveLength(3)
expect(media.map(m => m.type)).toEqual(['image', 'image', 'audio'])
})
it('handles image URLs with query strings', () => {
const content = '![thumb](https://cdn.example.com/img.jpg?w=300&h=200)'
const media = parseMedia(content)
expect(media).toHaveLength(1)
expect(media[0].url).toBe('https://cdn.example.com/img.jpg?w=300&h=200')
})
it('returns empty array when no media is present', () => {
const content = 'Just a plain text message with no links'
const media = parseMedia(content)
expect(media).toHaveLength(0)
})
it('returns empty array for empty string', () => {
expect(parseMedia('')).toHaveLength(0)
})
it('handles multiple audio formats', () => {
const content = [
'https://example.com/a.wav',
'https://example.com/b.ogg',
'https://example.com/c.m4a',
'https://example.com/d.aac',
].join(' ')
const media = parseMedia(content)
expect(media).toHaveLength(4)
expect(media.every(m => m.type === 'audio')).toBe(true)
})
})
// --- loadConversations / saveConversations (localStorage) ---
describe('loadConversations', () => {
beforeEach(() => {
// jsdom provides localStorage
localStorage.clear()
})
it('returns empty object when nothing stored', () => {
const result = loadConversations()
expect(result).toEqual({})
})
it('returns parsed data when valid JSON is stored', () => {
const data: ConversationStore = {
vera: makeConversation({ agentId: 'vera' }),
}
localStorage.setItem('manor-conversations', JSON.stringify(data))
const result = loadConversations()
expect(result.vera.agentId).toBe('vera')
})
it('returns empty object when localStorage contains invalid JSON', () => {
localStorage.setItem('manor-conversations', 'not-json!!')
const result = loadConversations()
expect(result).toEqual({})
})
})
describe('saveConversations', () => {
beforeEach(() => {
localStorage.clear()
})
it('persists store to localStorage', () => {
const data: ConversationStore = {
vera: makeConversation({ agentId: 'vera' }),
}
saveConversations(data)
const raw = localStorage.getItem('manor-conversations')
expect(raw).toBeTruthy()
expect(JSON.parse(raw!).vera.agentId).toBe('vera')
})
})
+354
View File
@@ -0,0 +1,354 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockExecSync } = vi.hoisted(() => ({
mockExecSync: vi.fn(),
}))
// Mock child_process (Dependency Inversion -- no real CLI calls)
vi.mock('child_process', () => ({
execSync: mockExecSync,
default: { execSync: mockExecSync },
}))
import { getCrons } from './crons'
beforeEach(() => {
vi.clearAllMocks()
})
// --- Well-formed data ---
describe('getCrons - well-formed data', () => {
it('parses a flat array response', async () => {
const mockData = [
{
id: 'cron-1',
name: 'pulse-trending',
schedule: '0 8 * * *',
status: 'success',
state: {
nextRunAtMs: 1700000000000,
lastRunAtMs: 1699900000000,
},
},
]
mockExecSync.mockReturnValue(JSON.stringify(mockData))
const crons = await getCrons()
expect(crons).toHaveLength(1)
expect(crons[0].id).toBe('cron-1')
expect(crons[0].name).toBe('pulse-trending')
expect(crons[0].schedule).toBe('0 8 * * *')
expect(crons[0].status).toBe('ok')
expect(crons[0].agentId).toBe('pulse')
expect(crons[0].nextRun).toBeTruthy()
expect(crons[0].lastRun).toBeTruthy()
expect(crons[0].lastError).toBeNull()
})
it('parses a { jobs: [...] } wrapper', async () => {
const mockData = {
jobs: [
{
id: 'cron-2',
name: 'seo-team-weekly',
schedule: '0 9 * * 1',
state: { status: 'ok' },
},
],
}
mockExecSync.mockReturnValue(JSON.stringify(mockData))
const crons = await getCrons()
expect(crons).toHaveLength(1)
expect(crons[0].name).toBe('seo-team-weekly')
expect(crons[0].agentId).toBe('lumen')
})
it('parses a { data: [...] } wrapper', async () => {
const mockData = {
data: [
{
id: 'cron-3',
name: 'echo-reddit-scan',
schedule: '0 6 * * 0',
state: { status: 'completed' },
},
],
}
mockExecSync.mockReturnValue(JSON.stringify(mockData))
const crons = await getCrons()
expect(crons).toHaveLength(1)
expect(crons[0].status).toBe('ok')
expect(crons[0].agentId).toBe('echo')
})
it('maps multiple crons to correct agents', async () => {
const mockData = [
{ id: '1', name: 'pulse-daily', schedule: '0 8 * * *', state: {} },
{ id: '2', name: 'herald-linkedin', schedule: '0 10 * * 1-5', state: {} },
{ id: '3', name: 'kaze-flights', schedule: '0 7 * * *', state: {} },
{ id: '4', name: 'spark-discover', schedule: '0 12 */2 * *', state: {} },
{ id: '5', name: 'scribe-compress', schedule: '0 0 * * 0', state: {} },
{ id: '6', name: 'robin-recon', schedule: '0 6 * * 1', state: {} },
{ id: '7', name: 'vault-backup', schedule: '0 3 * * *', state: {} },
{ id: '8', name: 'maven-calendar', schedule: '0 9 * * 1', state: {} },
{ id: '9', name: 'team-memory-sync', schedule: '0 23 * * *', state: {} },
{ id: '10', name: 'mochi-feed', schedule: '0 11 * * *', state: {} },
]
mockExecSync.mockReturnValue(JSON.stringify(mockData))
const crons = await getCrons()
expect(crons).toHaveLength(10)
const agentMap: Record<string, string | null> = {}
for (const c of crons) agentMap[c.name] = c.agentId
expect(agentMap['pulse-daily']).toBe('pulse')
expect(agentMap['herald-linkedin']).toBe('herald')
expect(agentMap['kaze-flights']).toBe('kaze')
expect(agentMap['spark-discover']).toBe('spark')
expect(agentMap['scribe-compress']).toBe('scribe')
expect(agentMap['robin-recon']).toBe('robin')
expect(agentMap['vault-backup']).toBe('jarvis')
expect(agentMap['maven-calendar']).toBe('maven')
expect(agentMap['team-memory-sync']).toBe('scribe')
expect(agentMap['mochi-feed']).toBe('pulse')
})
})
// --- Status mapping ---
describe('getCrons - status mapping', () => {
function makeCronWithStatus(status: string) {
return JSON.stringify([{
id: 'test',
name: 'pulse-test',
schedule: '* * * * *',
state: { status },
}])
}
it('maps "success" to "ok"', async () => {
mockExecSync.mockReturnValue(makeCronWithStatus('success'))
const crons = await getCrons()
expect(crons[0].status).toBe('ok')
})
it('maps "completed" to "ok"', async () => {
mockExecSync.mockReturnValue(makeCronWithStatus('completed'))
const crons = await getCrons()
expect(crons[0].status).toBe('ok')
})
it('maps "ok" to "ok"', async () => {
mockExecSync.mockReturnValue(makeCronWithStatus('ok'))
const crons = await getCrons()
expect(crons[0].status).toBe('ok')
})
it('maps "error" to "error"', async () => {
mockExecSync.mockReturnValue(makeCronWithStatus('error'))
const crons = await getCrons()
expect(crons[0].status).toBe('error')
})
it('maps "failed" to "error"', async () => {
mockExecSync.mockReturnValue(makeCronWithStatus('failed'))
const crons = await getCrons()
expect(crons[0].status).toBe('error')
})
it('maps unknown status to "idle"', async () => {
mockExecSync.mockReturnValue(makeCronWithStatus('pending'))
const crons = await getCrons()
expect(crons[0].status).toBe('idle')
})
it('maps empty string status to "idle"', async () => {
mockExecSync.mockReturnValue(makeCronWithStatus(''))
const crons = await getCrons()
expect(crons[0].status).toBe('idle')
})
it('reads status from top-level when state.status is missing', async () => {
mockExecSync.mockReturnValue(JSON.stringify([{
id: 'test',
name: 'pulse-test',
schedule: '* * * * *',
status: 'error',
state: {},
}]))
const crons = await getCrons()
expect(crons[0].status).toBe('error')
})
})
// --- Error / lastError ---
describe('getCrons - error and lastError', () => {
it('captures lastError from state', async () => {
mockExecSync.mockReturnValue(JSON.stringify([{
id: 'test',
name: 'pulse-test',
schedule: '* * * * *',
state: { status: 'error', lastError: 'timeout after 10s' },
}]))
const crons = await getCrons()
expect(crons[0].lastError).toBe('timeout after 10s')
})
it('captures error from state.error fallback', async () => {
mockExecSync.mockReturnValue(JSON.stringify([{
id: 'test',
name: 'pulse-test',
schedule: '* * * * *',
state: { error: 'network failure' },
}]))
const crons = await getCrons()
expect(crons[0].lastError).toBe('network failure')
})
it('captures lastError from top-level', async () => {
mockExecSync.mockReturnValue(JSON.stringify([{
id: 'test',
name: 'pulse-test',
schedule: '* * * * *',
state: {},
lastError: 'out of memory',
}]))
const crons = await getCrons()
expect(crons[0].lastError).toBe('out of memory')
})
it('sets lastError to null when no error info present', async () => {
mockExecSync.mockReturnValue(JSON.stringify([{
id: 'test',
name: 'pulse-test',
schedule: '* * * * *',
state: {},
}]))
const crons = await getCrons()
expect(crons[0].lastError).toBeNull()
})
})
// --- Error propagation (current implementation throws) ---
describe('getCrons - error propagation', () => {
it('throws when execSync throws (CLI not installed)', async () => {
mockExecSync.mockImplementation(() => { throw new Error('ENOENT') })
await expect(getCrons()).rejects.toThrow('Failed to fetch cron jobs')
await expect(getCrons()).rejects.toThrow('ENOENT')
})
it('throws for invalid JSON output', async () => {
mockExecSync.mockReturnValue('not valid json {{')
await expect(getCrons()).rejects.toThrow('Failed to fetch cron jobs')
})
})
// --- Graceful defaults for missing fields ---
describe('getCrons - missing fields defaults', () => {
it('handles job with all fields missing (defaults to safe values)', async () => {
mockExecSync.mockReturnValue(JSON.stringify([{}]))
const crons = await getCrons()
expect(crons).toHaveLength(1)
expect(crons[0].id).toBe('')
expect(crons[0].name).toBe('')
expect(crons[0].schedule).toBe('')
expect(crons[0].status).toBe('idle')
expect(crons[0].lastRun).toBeNull()
expect(crons[0].nextRun).toBeNull()
expect(crons[0].lastError).toBeNull()
expect(crons[0].agentId).toBeNull()
})
it('handles job with no state object', async () => {
mockExecSync.mockReturnValue(JSON.stringify([{
id: 'x',
name: 'pulse-test',
schedule: '0 * * * *',
}]))
const crons = await getCrons()
expect(crons).toHaveLength(1)
expect(crons[0].status).toBe('idle')
})
it('uses j.name as id fallback when j.id is missing', async () => {
mockExecSync.mockReturnValue(JSON.stringify([{
name: 'herald-post',
schedule: '0 10 * * *',
state: {},
}]))
const crons = await getCrons()
expect(crons[0].id).toBe('herald-post')
})
it('returns null agentId for unrecognized name prefix', async () => {
mockExecSync.mockReturnValue(JSON.stringify([{
id: 'unknown',
name: 'mystery-cron',
schedule: '0 0 * * *',
state: {},
}]))
const crons = await getCrons()
expect(crons[0].agentId).toBeNull()
})
it('handles empty array from CLI', async () => {
mockExecSync.mockReturnValue(JSON.stringify([]))
const crons = await getCrons()
expect(crons).toEqual([])
})
it('handles empty object from CLI (no jobs/data key)', async () => {
mockExecSync.mockReturnValue(JSON.stringify({}))
const crons = await getCrons()
expect(crons).toEqual([])
})
})
// --- Date parsing ---
describe('getCrons - date parsing', () => {
it('converts nextRunAtMs (milliseconds) to ISO string', async () => {
const ts = 1700000000000
mockExecSync.mockReturnValue(JSON.stringify([{
id: 'test',
name: 'pulse-test',
schedule: '* * * * *',
state: { nextRunAtMs: ts },
}]))
const crons = await getCrons()
expect(crons[0].nextRun).toBe(new Date(ts).toISOString())
})
it('converts lastRunAtMs to ISO string', async () => {
const ts = 1699900000000
mockExecSync.mockReturnValue(JSON.stringify([{
id: 'test',
name: 'pulse-test',
schedule: '* * * * *',
state: { lastRunAtMs: ts },
}]))
const crons = await getCrons()
expect(crons[0].lastRun).toBe(new Date(ts).toISOString())
})
it('falls back to top-level nextRunAt', async () => {
const ts = 1700000000000
mockExecSync.mockReturnValue(JSON.stringify([{
id: 'test',
name: 'pulse-test',
schedule: '* * * * *',
state: {},
nextRunAt: ts,
}]))
const crons = await getCrons()
expect(crons[0].nextRun).toBe(new Date(ts).toISOString())
})
})
+4 -7
View File
@@ -41,11 +41,6 @@ export async function getCrons(): Promise<CronJob[]> {
? parsed
: parsed.jobs ?? parsed.data ?? []
// Debug: log the raw shape of the first cron item
if (jobs.length > 0) {
process.stderr.write(JSON.stringify(Object.keys(jobs[0] as Record<string, unknown>)) + '\n')
}
return jobs.map((job: unknown) => {
const j = job as Record<string, unknown>
const state = (j.state as Record<string, unknown>) || {}
@@ -86,7 +81,9 @@ export async function getCrons(): Promise<CronJob[]> {
agentId: matchAgent(name),
}
})
} catch {
return []
} catch (err) {
throw new Error(
`Failed to fetch cron jobs: ${err instanceof Error ? err.message : String(err)}`
)
}
}
+194
View File
@@ -0,0 +1,194 @@
/**
* HTML sanitization and safe markdown rendering utilities.
*
* Design:
* - escapeHtml() handles the 5 critical HTML special characters
* - MarkdownRenderer is a configurable pipeline: escape first, then transform
* - Open/Closed: add new renderers via the `rules` array without modifying core
* - Dependency Inversion: consumers depend on the MarkdownRule interface, not
* a specific implementation
*/
// ---------------------------------------------------------------------------
// Core escape function
// ---------------------------------------------------------------------------
const HTML_ESCAPE_MAP: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#x27;",
};
const HTML_ESCAPE_RE = /[&<>"']/g;
/**
* Escape all HTML-significant characters so that the string is safe
* to embed inside an HTML document (both element content and attributes).
*/
export function escapeHtml(text: string): string {
return text.replace(HTML_ESCAPE_RE, (ch) => HTML_ESCAPE_MAP[ch]);
}
// ---------------------------------------------------------------------------
// Markdown rendering pipeline
// ---------------------------------------------------------------------------
/**
* A single markdown-to-HTML transformation rule.
* Rules are applied in order after the input has already been HTML-escaped.
*/
export interface MarkdownRule {
/** Human-readable name for debugging / extensibility */
name: string;
/** Regex to match against the escaped text */
pattern: RegExp;
/** Replacement string (may use $1, $2, etc.) */
replacement: string;
}
/** Default rules that ship with the renderer. */
export const DEFAULT_MARKDOWN_RULES: MarkdownRule[] = [
{
name: "h4",
pattern: /^#### (.+)$/gm,
replacement:
'<h4 class="text-[15px] font-semibold" style="color:var(--text-primary);margin-top:1rem;margin-bottom:0.25rem">$1</h4>',
},
{
name: "h3",
pattern: /^### (.+)$/gm,
replacement:
'<h3 class="text-[17px] font-semibold" style="color:var(--text-primary);margin-top:1.25rem;margin-bottom:0.375rem">$1</h3>',
},
{
name: "h2",
pattern: /^## (.+)$/gm,
replacement:
'<h2 class="text-[22px] font-semibold" style="color:var(--text-primary);margin-top:1.5rem;margin-bottom:0.5rem;padding-bottom:0.25rem;border-bottom:1px solid var(--separator)">$1</h2>',
},
{
name: "h1",
pattern: /^# (.+)$/gm,
replacement:
'<h1 class="text-[28px] font-bold" style="color:var(--text-primary);margin-top:1rem;margin-bottom:0.75rem">$1</h1>',
},
{
name: "bold",
pattern: /\*\*(.+?)\*\*/g,
replacement:
'<strong class="font-semibold" style="color:var(--text-primary)">$1</strong>',
},
{
name: "inline-code",
pattern: /`([^`]+)`/g,
replacement:
'<code style="background:var(--fill-secondary);color:var(--accent);padding:2px 6px;border-radius:6px;font-size:13px;font-family:var(--font-mono)">$1</code>',
},
{
name: "unordered-list",
pattern: /^- (.+)$/gm,
replacement:
'<li class="ml-4 text-[15px] leading-[1.7] list-disc" style="color:var(--text-secondary)">$1</li>',
},
{
name: "ordered-list",
pattern: /^(\d+)\. (.+)$/gm,
replacement:
'<li class="ml-4 text-[15px] leading-[1.7] list-decimal" style="color:var(--text-secondary)">$2</li>',
},
{
name: "paragraph-break",
pattern: /\n{2,}/g,
replacement:
'</p><p class="mb-3" style="color:var(--text-secondary)">',
},
{
name: "line-break",
pattern: /\n/g,
replacement: "<br/>",
},
];
export interface MarkdownRendererOptions {
/** Override or extend the default rules */
rules?: MarkdownRule[];
}
/**
* Render a plain-text markdown string to safe HTML.
*
* The pipeline is:
* 1. Escape ALL HTML entities (neutralises any injected markup)
* 2. Apply markdown transformation rules in order
*
* Because escaping happens first, captured groups ($1 etc.) only ever
* contain escaped text no raw HTML can slip through.
*/
export function renderMarkdown(
text: string,
options?: MarkdownRendererOptions
): string {
const rules = options?.rules ?? DEFAULT_MARKDOWN_RULES;
// Step 1 — escape (this is the security boundary)
let html = escapeHtml(text);
// Step 2 — apply markdown transformations on the safe string
for (const rule of rules) {
html = html.replace(rule.pattern, rule.replacement);
}
return html;
}
// ---------------------------------------------------------------------------
// JSON colorizer (safe)
// ---------------------------------------------------------------------------
/** Default rules for JSON syntax highlighting (applied after escaping). */
export const JSON_COLORIZE_RULES: MarkdownRule[] = [
{
name: "json-key",
pattern: /&quot;((?:(?!&quot;).)*?)&quot;(?=\s*:)/g,
replacement:
'<span style="color:var(--accent)">&quot;$1&quot;</span>',
},
{
name: "json-string-value",
pattern: /:\s*&quot;((?:(?!&quot;).)*?)&quot;/g,
replacement:
': <span style="color:var(--system-green)">&quot;$1&quot;</span>',
},
{
name: "json-number",
pattern: /:\s*(\d+\.?\d*)/g,
replacement: ': <span style="color:var(--system-blue)">$1</span>',
},
{
name: "json-boolean",
pattern: /:\s*(true|false)/g,
replacement: ': <span style="color:#bf5af2">$1</span>',
},
{
name: "json-null",
pattern: /:\s*(null)/g,
replacement:
': <span style="color:var(--text-tertiary)">$1</span>',
},
];
/**
* Syntax-highlight a JSON string safely.
* Escapes HTML first, then applies colorization rules.
*/
export function colorizeJson(json: string): string {
let html = escapeHtml(json);
for (const rule of JSON_COLORIZE_RULES) {
html = html.replace(rule.pattern, rule.replacement);
}
return html;
}
+69
View File
@@ -0,0 +1,69 @@
// Chat message validation — manual runtime checks (no external deps)
// Single Responsibility: validation logic lives here, not in the route handler
// Open/Closed: add new validation rules by extending the validators array
const VALID_ROLES = ['user', 'assistant', 'system'] as const
type ValidRole = typeof VALID_ROLES[number]
export interface ValidatedChatMessage {
role: ValidRole
content: string
}
export type ValidationResult = {
ok: true
messages: ValidatedChatMessage[]
} | {
ok: false
error: string
}
/**
* Validates that the parsed request body contains a well-formed messages array.
* Returns a discriminated union so the caller can branch on `ok`.
*/
export function validateChatMessages(body: unknown): ValidationResult {
if (body === null || typeof body !== 'object') {
return { ok: false, error: 'Request body must be a JSON object.' }
}
const { messages } = body as Record<string, unknown>
if (!Array.isArray(messages)) {
return { ok: false, error: '`messages` must be an array.' }
}
if (messages.length === 0) {
return { ok: false, error: '`messages` must not be empty.' }
}
const validated: ValidatedChatMessage[] = []
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg === null || typeof msg !== 'object') {
return { ok: false, error: `messages[${i}] must be an object.` }
}
const { role, content } = msg as Record<string, unknown>
if (typeof role !== 'string' || !(VALID_ROLES as readonly string[]).includes(role)) {
return {
ok: false,
error: `messages[${i}].role must be one of: ${VALID_ROLES.join(', ')}. Got: ${JSON.stringify(role)}`,
}
}
if (typeof content !== 'string') {
return {
ok: false,
error: `messages[${i}].content must be a string. Got: ${typeof content}`,
}
}
validated.push({ role: role as ValidRole, content })
}
return { ok: true, messages: validated }
}
+2201 -35
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -5,10 +5,10 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"test": "vitest run"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -22,12 +22,17 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.4",
"jsdom": "^28.1.0",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.0.18"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
},
},
test: {
environment: 'jsdom',
include: ['**/*.test.ts', '**/*.test.tsx'],
exclude: ['node_modules', '.next'],
},
})