mirror of
https://github.com/JohnRiceML/clawport-ui.git
synced 2026-08-14 00:47:50 +00:00
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:
co-authored by
Claude Opus 4.6
parent
0143591b7e
commit
28c4269539
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
>
|
||||
↻
|
||||
</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"
|
||||
>
|
||||
›
|
||||
</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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.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"
|
||||
>
|
||||
↻
|
||||
</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
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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)' }}>🔍</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">🔍</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 ‘{search.trim()}’
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
sorted.map(agent => {
|
||||
const conv = conversations[agent.id]
|
||||
const lastMsg = conv?.messages[conv.messages.length - 1]
|
||||
const unread = conv?.unread || 0
|
||||
const isActive = agent.id === activeId
|
||||
|
||||
const preview = lastMsg
|
||||
? lastMsg.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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
📎
|
||||
<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"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 11, textAlign: 'center', marginTop: 8, marginBottom: 2, color: 'var(--text-tertiary)' }}>
|
||||
↵ Send · ⇧↵ New line
|
||||
<p style={{ fontSize: 11, textAlign: 'center', marginTop: 8, marginBottom: 2, color: 'var(--text-quaternary)' }}>
|
||||
Enter to send · Shift+Enter for newline
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
]
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
@@ -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: '
|
||||
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 = ' 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 = [
|
||||
'',
|
||||
'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 = ''
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
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: /"((?:(?!").)*?)"(?=\s*:)/g,
|
||||
replacement:
|
||||
'<span style="color:var(--accent)">"$1"</span>',
|
||||
},
|
||||
{
|
||||
name: "json-string-value",
|
||||
pattern: /:\s*"((?:(?!").)*?)"/g,
|
||||
replacement:
|
||||
': <span style="color:var(--system-green)">"$1"</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;
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Generated
+2201
-35
File diff suppressed because it is too large
Load Diff
+8
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user