feat: initial Honcho UI scaffold
React 19 + Vite 8 + TanStack Router SPA for browsing and chatting with a self-hosted Honcho instance. Configurable base URL stored in localStorage only. Dark/light theme, framer-motion animations, lucide-react icons.
This commit is contained in:
198
src/components/chat/ChatPage.tsx
Normal file
198
src/components/chat/ChatPage.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Send, Brain } from "lucide-react";
|
||||
import { useChat } from "@/api/queries";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function ChatPage() {
|
||||
const { workspaceId, peerId } = useParams({ strict: false }) as {
|
||||
workspaceId: string;
|
||||
peerId: string;
|
||||
};
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const chatMutation = useChat(workspaceId, peerId);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
async function handleSend() {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || chatMutation.isPending) return;
|
||||
|
||||
setInput("");
|
||||
setMessages((prev) => [...prev, { role: "user", content: trimmed }]);
|
||||
|
||||
try {
|
||||
const result = await chatMutation.mutateAsync(trimmed);
|
||||
const responseText =
|
||||
typeof result === "string"
|
||||
? result
|
||||
: typeof (result as { response?: unknown })?.response === "string"
|
||||
? (result as { response: string }).response
|
||||
: JSON.stringify(result);
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: responseText }]);
|
||||
} catch (err) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
content: `Error: ${err instanceof Error ? err.message : "Unknown error"}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen" style={{ background: "var(--bg)" }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="shrink-0 px-6 py-4"
|
||||
style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-2)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs mb-1" style={{ color: "var(--text-3)" }}>
|
||||
<Link
|
||||
to="/workspaces/$workspaceId/peers/$peerId"
|
||||
params={{ workspaceId, peerId } as never}
|
||||
className="hover:underline font-mono"
|
||||
>
|
||||
{peerId}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span>Chat</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<h1 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
|
||||
Memory-augmented chat
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||
Honcho responds using accumulated context for{" "}
|
||||
<span className="font-mono">{peerId}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-auto px-4 sm:px-6 py-4 space-y-4">
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex items-center justify-center h-full"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4"
|
||||
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)" }}
|
||||
>
|
||||
<Brain className="w-6 h-6" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className="text-sm font-medium" style={{ color: "var(--text-2)" }}>
|
||||
Start a conversation
|
||||
</p>
|
||||
<p className="text-xs mt-1 max-w-xs" style={{ color: "var(--text-3)" }}>
|
||||
Honcho will respond using accumulated memory context for this peer
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className="max-w-[80%] sm:max-w-[70%] rounded-2xl px-4 py-3 text-sm"
|
||||
style={
|
||||
msg.role === "user"
|
||||
? { background: "var(--accent)", color: "#fff" }
|
||||
: {
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-2)",
|
||||
}
|
||||
}
|
||||
>
|
||||
<p className="whitespace-pre-wrap leading-relaxed">{msg.content}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{chatMutation.isPending && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex justify-start"
|
||||
>
|
||||
<div
|
||||
className="rounded-2xl px-4 py-3 flex items-center gap-2"
|
||||
style={{ background: "var(--bg-2)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="text-xs" style={{ color: "var(--text-3)" }}>
|
||||
Honcho is thinking...
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div
|
||||
className="shrink-0 px-4 sm:px-6 py-4"
|
||||
style={{ borderTop: "1px solid var(--border)", background: "var(--bg-2)" }}
|
||||
>
|
||||
<div className="flex gap-3 max-w-3xl mx-auto">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Message this peer... (Enter to send, Shift+Enter for newline)"
|
||||
rows={2}
|
||||
className="flex-1 px-4 py-3 text-sm rounded-xl resize-none outline-none transition-all"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border-2)",
|
||||
color: "var(--text-1)",
|
||||
}}
|
||||
onFocus={(e) => { e.target.style.borderColor = "var(--accent)"; }}
|
||||
onBlur={(e) => { e.target.style.borderColor = "var(--border-2)"; }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || chatMutation.isPending}
|
||||
className="px-4 rounded-xl self-end mb-0.5 py-3 text-sm font-medium transition-all flex items-center gap-2 disabled:opacity-40"
|
||||
style={{ background: "var(--accent)", color: "#fff" }}
|
||||
>
|
||||
<Send className="w-4 h-4" strokeWidth={1.5} />
|
||||
<span className="hidden sm:block">Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
src/components/conclusions/ConclusionBrowser.tsx
Normal file
230
src/components/conclusions/ConclusionBrowser.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Lightbulb, Search, X, Clock, ArrowLeft, Eye } from "lucide-react";
|
||||
import { useConclusions, useQueryConclusions } from "@/api/queries";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { Pagination } from "@/components/shared/Pagination";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
|
||||
type Conclusion = components["schemas"]["Conclusion"];
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
show: (i: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { delay: i * 0.04, type: "spring" as const, stiffness: 300, damping: 25 },
|
||||
}),
|
||||
};
|
||||
|
||||
export function ConclusionBrowser() {
|
||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeSearch, setActiveSearch] = useState("");
|
||||
|
||||
const { data, isLoading, error } = useConclusions(workspaceId, {}, page);
|
||||
const { data: searchResults, isLoading: searchLoading } = useQueryConclusions(
|
||||
workspaceId,
|
||||
activeSearch,
|
||||
{},
|
||||
Boolean(activeSearch),
|
||||
);
|
||||
|
||||
const conclusions: Conclusion[] = (data as { items?: Conclusion[] } | undefined)?.items ?? [];
|
||||
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||
|
||||
const displayedConclusions: Conclusion[] = activeSearch
|
||||
? Array.isArray(searchResults) ? searchResults : []
|
||||
: conclusions;
|
||||
|
||||
function handleSearch(e: React.SyntheticEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setActiveSearch(searchQuery.trim());
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<Link
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId }}
|
||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||
style={{ color: "rgba(148,163,184,0.5)" }}
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||
{workspaceId}
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Lightbulb className="w-5 h-5" style={{ color: "#6366f1" }} strokeWidth={1.5} />
|
||||
<h1 className="text-xl font-semibold tracking-tight" style={{ color: "#e4e4f0" }}>
|
||||
Conclusions
|
||||
</h1>
|
||||
{total > 0 && !activeSearch && (
|
||||
<span
|
||||
className="ml-auto text-xs font-mono px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "rgba(99,102,241,0.1)",
|
||||
color: "#818cf8",
|
||||
border: "1px solid rgba(99,102,241,0.2)",
|
||||
}}
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm mt-0.5" style={{ color: "rgba(148,163,184,0.6)" }}>
|
||||
Distilled memory observations about peers
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
|
||||
style={{ color: "rgba(148,163,184,0.4)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Semantic search across conclusions..."
|
||||
className="w-full rounded-xl pl-9 pr-4 py-2.5 text-sm font-mono outline-none transition-all"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.03)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
color: "#e4e4f0",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = "rgba(99,102,241,0.4)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = "rgba(255,255,255,0.08)";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2.5 rounded-xl text-sm font-medium transition-all"
|
||||
style={{ background: "#4f46e5", color: "#fff" }}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{activeSearch && (
|
||||
<motion.button
|
||||
type="button"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
onClick={() => { setActiveSearch(""); setSearchQuery(""); }}
|
||||
className="px-3 py-2.5 rounded-xl text-sm transition-all"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
color: "rgba(148,163,184,0.7)",
|
||||
}}
|
||||
>
|
||||
<X className="w-4 h-4" strokeWidth={1.5} />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{(isLoading || (activeSearch && searchLoading)) && <PageLoader />}
|
||||
|
||||
{!isLoading && !searchLoading && displayedConclusions.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Lightbulb}
|
||||
title={activeSearch ? "No results found" : "No conclusions yet"}
|
||||
description={
|
||||
activeSearch
|
||||
? `No conclusions match "${activeSearch}"`
|
||||
: "Conclusions are created when Honcho processes sessions."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{displayedConclusions.length > 0 && (
|
||||
<>
|
||||
{activeSearch && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-xs font-mono mb-3"
|
||||
style={{ color: "rgba(148,163,184,0.4)" }}
|
||||
>
|
||||
{displayedConclusions.length} result{displayedConclusions.length !== 1 ? "s" : ""}{" "}
|
||||
for “{activeSearch}”
|
||||
</motion.p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{displayedConclusions.map((c, i) => (
|
||||
<motion.div
|
||||
key={c.id}
|
||||
custom={i}
|
||||
variants={itemVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="rounded-xl p-5"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-sm leading-relaxed whitespace-pre-wrap"
|
||||
style={{ color: "#d4d4f5" }}
|
||||
>
|
||||
{c.content}
|
||||
</p>
|
||||
<div
|
||||
className="flex items-center gap-3 mt-4 pt-3"
|
||||
style={{ borderTop: "1px solid rgba(255,255,255,0.05)" }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Eye className="w-3 h-3" style={{ color: "rgba(148,163,184,0.35)" }} strokeWidth={1.5} />
|
||||
<span className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.4)" }}>
|
||||
{c.observer_id}
|
||||
</span>
|
||||
</div>
|
||||
{c.observed_id && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs" style={{ color: "rgba(148,163,184,0.2)" }}>→</span>
|
||||
<span className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.4)" }}>
|
||||
{c.observed_id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{c.created_at && (
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Clock className="w-3 h-3" style={{ color: "rgba(148,163,184,0.25)" }} strokeWidth={1.5} />
|
||||
<span className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}>
|
||||
{new Date(c.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
{!activeSearch && (
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
src/components/dashboard/Dashboard.tsx
Normal file
180
src/components/dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { Boxes, Activity, LayoutDashboard } from "lucide-react";
|
||||
import { useWorkspaces, useQueueStatus } from "@/api/queries";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
|
||||
function QueueCard({ workspaceId }: { workspaceId: string }) {
|
||||
const { data, isLoading } = useQueueStatus(workspaceId);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="rounded-xl p-5 theme-card">
|
||||
<PageLoader />
|
||||
</div>
|
||||
);
|
||||
if (!data) return null;
|
||||
|
||||
const pending = data.pending_work_units;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl p-5 theme-card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<h3 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>Queue Status</h3>
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-mono px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: pending === 0 ? "rgba(52,211,153,0.1)" : "rgba(245,158,11,0.1)",
|
||||
color: pending === 0 ? "#34d399" : "#f59e0b",
|
||||
border: `1px solid ${pending === 0 ? "rgba(52,211,153,0.2)" : "rgba(245,158,11,0.2)"}`,
|
||||
}}
|
||||
>
|
||||
{pending === 0 ? "Idle" : "Active"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(["total_work_units", "completed_work_units", "in_progress_work_units", "pending_work_units"] as const).map((key) => (
|
||||
<div key={key} className="flex justify-between text-xs">
|
||||
<span className="capitalize" style={{ color: "var(--text-3)" }}>
|
||||
{key.replace(/_work_units$/, "").replace(/_/g, " ")}
|
||||
</span>
|
||||
<span className="font-mono font-medium" style={{ color: "var(--text-1)" }}>
|
||||
{data[key]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const [page] = useState(1);
|
||||
const { data, isLoading, error } = useWorkspaces(page, 6);
|
||||
|
||||
const workspaces = (data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? [];
|
||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<LayoutDashboard className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<h1 className="text-xl font-semibold tracking-tight" style={{ color: "var(--text-1)" }}>
|
||||
Dashboard
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm" style={{ color: "var(--text-2)" }}>
|
||||
Overview of your Honcho instance
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{!isLoading && (
|
||||
<div className="space-y-4">
|
||||
{/* Stat row */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-3 gap-3"
|
||||
>
|
||||
{[
|
||||
{ label: "Workspaces", value: total, icon: Boxes },
|
||||
].map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={stat.label} className="rounded-xl p-5 theme-card">
|
||||
<Icon className="w-5 h-5 mb-3" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<div className="text-3xl font-semibold font-mono" style={{ color: "var(--text-1)" }}>
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="text-xs mt-1" style={{ color: "var(--text-3)" }}>
|
||||
{stat.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Workspace list */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
|
||||
Recent Workspaces
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
to="/workspaces"
|
||||
className="text-xs transition-colors"
|
||||
style={{ color: "var(--accent-text)" }}
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{workspaces.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: "var(--text-3)" }}>No workspaces found.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{workspaces.map((ws) => (
|
||||
<Link
|
||||
key={ws.id}
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId: ws.id } as never}
|
||||
className="flex items-center justify-between py-2 px-3 rounded-lg transition-all group"
|
||||
style={{ color: "var(--text-2)" }}
|
||||
>
|
||||
<span
|
||||
className="font-mono text-xs truncate"
|
||||
style={{ color: "var(--accent-text)" }}
|
||||
>
|
||||
{ws.id}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ color: "var(--text-4)" }}
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Queue for first workspace */}
|
||||
{workspaces[0] && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
>
|
||||
<QueueCard workspaceId={workspaces[0].id} />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/components/layout/Sidebar.tsx
Normal file
131
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Link, useMatchRoute } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { LayoutDashboard, Boxes, Settings, Brain, ChevronRight, Sun, Moon } from "lucide-react";
|
||||
import { loadConfig } from "@/lib/config";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/" as const, label: "Dashboard", icon: LayoutDashboard, exact: true },
|
||||
{ to: "/workspaces" as const, label: "Workspaces", icon: Boxes, exact: false },
|
||||
{ to: "/settings" as const, label: "Settings", icon: Settings, exact: false },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const matchRoute = useMatchRoute();
|
||||
const config = loadConfig();
|
||||
const { theme, toggle } = useTheme();
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
initial={{ x: -20, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="w-14 sm:w-56 shrink-0 flex flex-col h-full"
|
||||
style={{
|
||||
background: "var(--sidebar-bg)",
|
||||
borderRight: "1px solid var(--border)",
|
||||
position: "relative",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="px-3 sm:px-5 py-5" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<div className="flex items-center gap-2.5 justify-center sm:justify-start">
|
||||
<div
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #4f46e5, #7c3aed)",
|
||||
boxShadow: "0 0 16px rgba(99,102,241,0.4)",
|
||||
}}
|
||||
>
|
||||
<Brain className="w-4 h-4 text-white" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<span className="font-semibold text-sm tracking-tight" style={{ color: "var(--text-1)" }}>
|
||||
Honcho UI
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{config && (
|
||||
<p
|
||||
className="text-xs mt-2 truncate font-mono hidden sm:block"
|
||||
style={{ color: "var(--text-4)" }}
|
||||
title={config.baseUrl}
|
||||
>
|
||||
{config.baseUrl.replace(/^https?:\/\//, "")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-2 sm:px-3 py-3 space-y-0.5">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = matchRoute({ to: item.to, fuzzy: !item.exact });
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="relative flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-all group justify-center sm:justify-start"
|
||||
style={{
|
||||
color: isActive ? "var(--accent-text)" : "var(--text-2)",
|
||||
background: isActive ? "var(--accent-dim)" : "transparent",
|
||||
}}
|
||||
title={item.label}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="nav-indicator"
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: "var(--accent-dim)",
|
||||
border: "1px solid var(--accent-border)",
|
||||
}}
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
className="w-4 h-4 shrink-0 relative z-10"
|
||||
strokeWidth={isActive ? 2 : 1.5}
|
||||
/>
|
||||
<span className="relative z-10 font-medium hidden sm:block">{item.label}</span>
|
||||
{isActive && (
|
||||
<ChevronRight
|
||||
className="w-3 h-3 ml-auto relative z-10 opacity-60 hidden sm:block"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Theme toggle + footer */}
|
||||
<div
|
||||
className="px-3 sm:px-5 py-3 flex items-center justify-between"
|
||||
style={{ borderTop: "1px solid var(--border)" }}
|
||||
>
|
||||
<p className="text-xs font-mono hidden sm:block" style={{ color: "var(--text-4)" }}>
|
||||
API v3
|
||||
</p>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center transition-colors mx-auto sm:mx-0"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-3)",
|
||||
}}
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
) : (
|
||||
<Moon className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
||||
168
src/components/peers/PeerDetail.tsx
Normal file
168
src/components/peers/PeerDetail.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { User, MessageCircle } from "lucide-react";
|
||||
import { usePeer, usePeerCard, usePeerContext, usePeerRepresentation } from "@/api/queries";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||
|
||||
type Tab = "context" | "card" | "representation" | "metadata";
|
||||
|
||||
export function PeerDetail() {
|
||||
const { workspaceId, peerId } = useParams({ strict: false }) as {
|
||||
workspaceId: string;
|
||||
peerId: string;
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<Tab>("context");
|
||||
|
||||
const { data: peer, isLoading, error } = usePeer(workspaceId, peerId);
|
||||
const { data: card, isLoading: cardLoading } = usePeerCard(workspaceId, peerId);
|
||||
const { data: context, isLoading: contextLoading } = usePeerContext(workspaceId, peerId);
|
||||
const { data: representation, isLoading: repLoading } = usePeerRepresentation(workspaceId, peerId);
|
||||
|
||||
const tabs: Array<{ id: Tab; label: string }> = [
|
||||
{ id: "context", label: "Context" },
|
||||
{ id: "card", label: "Card" },
|
||||
{ id: "representation", label: "Representation" },
|
||||
{ id: "metadata", label: "Metadata" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}>
|
||||
<Link to="/workspaces" className="hover:underline">Workspaces</Link>
|
||||
<span>/</span>
|
||||
<Link to="/workspaces/$workspaceId" params={{ workspaceId } as never} className="hover:underline font-mono">
|
||||
{workspaceId}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link to="/workspaces/$workspaceId/peers" params={{ workspaceId } as never} className="hover:underline">
|
||||
Peers
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<User className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<h1
|
||||
className="text-xl font-semibold font-mono break-all tracking-tight"
|
||||
style={{ color: "var(--text-1)" }}
|
||||
>
|
||||
{peerId}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm" style={{ color: "var(--text-2)" }}>Peer identity & memory</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/workspaces/$workspaceId/peers/$peerId/chat",
|
||||
params: { workspaceId, peerId } as never,
|
||||
})
|
||||
}
|
||||
className="shrink-0 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all"
|
||||
style={{
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4" strokeWidth={1.5} />
|
||||
Chat
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-8">
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{!isLoading && peer && (
|
||||
<>
|
||||
{/* Tab bar */}
|
||||
<div
|
||||
className="flex gap-0.5 mb-4 p-1 rounded-xl"
|
||||
style={{ background: "var(--bg-3)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className="relative flex-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-all"
|
||||
style={{ color: tab === t.id ? "var(--text-1)" : "var(--text-3)" }}
|
||||
>
|
||||
{tab === t.id && (
|
||||
<motion.div
|
||||
layoutId="tab-active"
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{ background: "var(--bg-2)", border: "1px solid var(--border)" }}
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.35 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<motion.div
|
||||
key={tab}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
{tab === "context" && (
|
||||
contextLoading ? <PageLoader /> : (
|
||||
<>
|
||||
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Peer Context</h2>
|
||||
{typeof context === "string" ? (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}>{context}</p>
|
||||
) : (
|
||||
<JsonViewer data={context} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{tab === "card" && (
|
||||
cardLoading ? <PageLoader /> : (
|
||||
<>
|
||||
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Peer Card</h2>
|
||||
{typeof card === "string" ? (
|
||||
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-2)" }}>{card}</p>
|
||||
) : (
|
||||
<JsonViewer data={card} maxHeight="400px" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{tab === "representation" && (
|
||||
repLoading ? <PageLoader /> : (
|
||||
<>
|
||||
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Memory Representation</h2>
|
||||
{representation && typeof (representation as { representation?: unknown }).representation === "string" ? (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}>
|
||||
{(representation as { representation: string }).representation}
|
||||
</p>
|
||||
) : (
|
||||
<JsonViewer data={representation} maxHeight="400px" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{tab === "metadata" && (
|
||||
<>
|
||||
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Peer Metadata</h2>
|
||||
<JsonViewer data={peer.metadata} maxHeight="400px" />
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
src/components/peers/PeerList.tsx
Normal file
140
src/components/peers/PeerList.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { Users, ChevronRight, Clock, ArrowLeft } from "lucide-react";
|
||||
import { usePeers } from "@/api/queries";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { Pagination } from "@/components/shared/Pagination";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
|
||||
type Peer = components["schemas"]["Peer"];
|
||||
|
||||
const container: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.06 } },
|
||||
};
|
||||
const item: Variants = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 25 } },
|
||||
};
|
||||
|
||||
export function PeerList() {
|
||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||
const [page, setPage] = useState(1);
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error } = usePeers(workspaceId, page);
|
||||
|
||||
const peers: Peer[] = (data as { items?: Peer[] } | undefined)?.items ?? [];
|
||||
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<Link
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId }}
|
||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||
style={{ color: "rgba(148,163,184,0.5)" }}
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||
{workspaceId}
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Users className="w-5 h-5" style={{ color: "#6366f1" }} strokeWidth={1.5} />
|
||||
<h1 className="text-xl font-semibold tracking-tight" style={{ color: "#e4e4f0" }}>
|
||||
Peers
|
||||
</h1>
|
||||
{total > 0 && (
|
||||
<span
|
||||
className="ml-auto text-xs font-mono px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "rgba(99,102,241,0.1)",
|
||||
color: "#818cf8",
|
||||
border: "1px solid rgba(99,102,241,0.2)",
|
||||
}}
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: "rgba(148,163,184,0.4)" }}>
|
||||
{workspaceId}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{!isLoading && peers.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No peers found"
|
||||
description="No peers exist in this workspace."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && peers.length > 0 && (
|
||||
<>
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-2"
|
||||
>
|
||||
{peers.map((peer) => (
|
||||
<motion.button
|
||||
key={peer.id}
|
||||
variants={item}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/workspaces/$workspaceId/peers/$peerId",
|
||||
params: { workspaceId, peerId: peer.id } as never,
|
||||
})
|
||||
}
|
||||
className="text-left rounded-xl px-5 py-4 group"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
}}
|
||||
whileHover={{
|
||||
background: "rgba(99,102,241,0.06)",
|
||||
borderColor: "rgba(99,102,241,0.2)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span
|
||||
className="font-mono text-sm font-medium truncate"
|
||||
style={{ color: "#c7d2fe" }}
|
||||
>
|
||||
{peer.id}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="w-4 h-4 shrink-0 ml-2 opacity-30 group-hover:opacity-70 transition-opacity"
|
||||
style={{ color: "#6366f1" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
{peer.created_at && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" style={{ color: "rgba(148,163,184,0.3)" }} strokeWidth={1.5} />
|
||||
<p className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}>
|
||||
{new Date(peer.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
src/components/sessions/SessionDetail.tsx
Normal file
164
src/components/sessions/SessionDetail.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { useSessionMessages, useSessionSummaries, useSessionContext } from "@/api/queries";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { Pagination } from "@/components/shared/Pagination";
|
||||
import { Badge } from "@/components/shared/Badge";
|
||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
|
||||
type Message = components["schemas"]["Message"];
|
||||
type Tab = "messages" | "summaries" | "context";
|
||||
|
||||
export function SessionDetail() {
|
||||
const { workspaceId, sessionId } = useParams({ strict: false }) as {
|
||||
workspaceId: string;
|
||||
sessionId: string;
|
||||
};
|
||||
const [tab, setTab] = useState<Tab>("messages");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data: msgData, isLoading: msgsLoading } = useSessionMessages(workspaceId, sessionId, page);
|
||||
const { data: summaries, isLoading: summariesLoading } = useSessionSummaries(workspaceId, sessionId);
|
||||
const { data: context, isLoading: contextLoading } = useSessionContext(workspaceId, sessionId);
|
||||
|
||||
const messages: Message[] = (msgData as { items?: Message[] } | undefined)?.items ?? [];
|
||||
const totalPages = (msgData as { pages?: number } | undefined)?.pages ?? 1;
|
||||
|
||||
const tabs: Array<{ id: Tab; label: string }> = [
|
||||
{ id: "messages", label: "Messages" },
|
||||
{ id: "summaries", label: "Summaries" },
|
||||
{ id: "context", label: "Context" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}>
|
||||
<Link to="/workspaces/$workspaceId" params={{ workspaceId } as never} className="hover:underline font-mono">
|
||||
{workspaceId}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link to="/workspaces/$workspaceId/sessions" params={{ workspaceId } as never} className="hover:underline">
|
||||
Sessions
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<MessageSquare className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<h1
|
||||
className="text-xl font-semibold font-mono break-all tracking-tight"
|
||||
style={{ color: "var(--text-1)" }}
|
||||
>
|
||||
{sessionId}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm" style={{ color: "var(--text-2)" }}>Session detail</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-8">
|
||||
{/* Tab bar */}
|
||||
<div
|
||||
className="flex gap-0.5 mb-4 p-1 rounded-xl"
|
||||
style={{ background: "var(--bg-3)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className="relative flex-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-all"
|
||||
style={{ color: tab === t.id ? "var(--text-1)" : "var(--text-3)" }}
|
||||
>
|
||||
{tab === t.id && (
|
||||
<motion.div
|
||||
layoutId="session-tab-active"
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{ background: "var(--bg-2)", border: "1px solid var(--border)" }}
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.35 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
key={tab}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
{tab === "messages" && (
|
||||
msgsLoading ? <PageLoader /> : (
|
||||
<div>
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: "var(--text-3)" }}>No messages.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="pb-4"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<Badge variant={msg.peer_id ? "blue" : "default"}>
|
||||
{msg.peer_id ?? "system"}
|
||||
</Badge>
|
||||
{msg.token_count != null && (
|
||||
<span className="text-xs" style={{ color: "var(--text-4)" }}>
|
||||
{msg.token_count} tokens
|
||||
</span>
|
||||
)}
|
||||
{msg.created_at && (
|
||||
<span className="text-xs" style={{ color: "var(--text-4)" }}>
|
||||
{new Date(msg.created_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className="text-sm whitespace-pre-wrap leading-relaxed"
|
||||
style={{ color: "var(--text-2)" }}
|
||||
>
|
||||
{msg.content}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === "summaries" && (
|
||||
summariesLoading ? <PageLoader /> : (
|
||||
<>
|
||||
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Session Summaries</h2>
|
||||
<JsonViewer data={summaries} maxHeight="500px" />
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === "context" && (
|
||||
contextLoading ? <PageLoader /> : (
|
||||
<>
|
||||
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Session Context</h2>
|
||||
{typeof context === "string" ? (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}>
|
||||
{context}
|
||||
</p>
|
||||
) : (
|
||||
<JsonViewer data={context} maxHeight="500px" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
src/components/sessions/SessionList.tsx
Normal file
146
src/components/sessions/SessionList.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { MessageSquare, ChevronRight, Clock, CircleDot, ArrowLeft } from "lucide-react";
|
||||
import { useSessions } from "@/api/queries";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { Pagination } from "@/components/shared/Pagination";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
|
||||
type Session = components["schemas"]["Session"];
|
||||
|
||||
const container: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
|
||||
};
|
||||
const item: Variants = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 280, damping: 24 } },
|
||||
};
|
||||
|
||||
export function SessionList() {
|
||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||
const [page, setPage] = useState(1);
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error } = useSessions(workspaceId, page);
|
||||
|
||||
const sessions: Session[] = (data as { items?: Session[] } | undefined)?.items ?? [];
|
||||
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<Link
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId }}
|
||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||
style={{ color: "rgba(148,163,184,0.5)" }}
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||
{workspaceId}
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<MessageSquare className="w-5 h-5" style={{ color: "#6366f1" }} strokeWidth={1.5} />
|
||||
<h1 className="text-xl font-semibold tracking-tight" style={{ color: "#e4e4f0" }}>
|
||||
Sessions
|
||||
</h1>
|
||||
{total > 0 && (
|
||||
<span
|
||||
className="ml-auto text-xs font-mono px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "rgba(99,102,241,0.1)",
|
||||
color: "#818cf8",
|
||||
border: "1px solid rgba(99,102,241,0.2)",
|
||||
}}
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: "rgba(148,163,184,0.4)" }}>
|
||||
{workspaceId}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{!isLoading && sessions.length === 0 && (
|
||||
<EmptyState
|
||||
icon={MessageSquare}
|
||||
title="No sessions found"
|
||||
description="No sessions exist in this workspace."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && sessions.length > 0 && (
|
||||
<>
|
||||
<motion.div variants={container} initial="hidden" animate="show" className="space-y-2">
|
||||
{sessions.map((session) => (
|
||||
<motion.button
|
||||
key={session.id}
|
||||
variants={item}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/workspaces/$workspaceId/sessions/$sessionId",
|
||||
params: { workspaceId, sessionId: session.id } as never,
|
||||
})
|
||||
}
|
||||
className="w-full text-left rounded-xl px-5 py-4 group"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
}}
|
||||
whileHover={{
|
||||
background: "rgba(99,102,241,0.06)",
|
||||
borderColor: "rgba(99,102,241,0.2)",
|
||||
x: 2,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm font-medium truncate" style={{ color: "#c7d2fe" }}>
|
||||
{session.id}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
{session.is_active && (
|
||||
<div className="flex items-center gap-1">
|
||||
<motion.div
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
>
|
||||
<CircleDot className="w-3 h-3" style={{ color: "#34d399" }} strokeWidth={2} />
|
||||
</motion.div>
|
||||
<span className="text-xs" style={{ color: "#34d399" }}>Active</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight
|
||||
className="w-4 h-4 opacity-30 group-hover:opacity-70 transition-opacity"
|
||||
style={{ color: "#6366f1" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{session.created_at && (
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
<Clock className="w-3 h-3" style={{ color: "rgba(148,163,184,0.3)" }} strokeWidth={1.5} />
|
||||
<p className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}>
|
||||
{new Date(session.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
229
src/components/settings/SettingsForm.tsx
Normal file
229
src/components/settings/SettingsForm.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Wifi, WifiOff, Loader, CheckCircle, AlertCircle, Lock, LockOpen } from "lucide-react";
|
||||
import {
|
||||
configSchema,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
checkConnection,
|
||||
type Config,
|
||||
type HealthStatus,
|
||||
} from "@/lib/config";
|
||||
|
||||
interface SettingsFormProps {
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
ok: { icon: CheckCircle, color: "#34d399", label: "Connected" },
|
||||
"auth-required": { icon: AlertCircle, color: "#f59e0b", label: "Auth required" },
|
||||
unreachable: { icon: WifiOff, color: "#f87171", label: "Unreachable" },
|
||||
checking: { icon: Loader, color: "#818cf8", label: "Checking..." },
|
||||
};
|
||||
|
||||
export function SettingsForm({ onSaved }: SettingsFormProps) {
|
||||
const existing = loadConfig();
|
||||
const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? "http://localhost:8000");
|
||||
const [token, setToken] = useState(existing?.token ?? "");
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof Config, string>>>({});
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [health, setHealth] = useState<{ status: HealthStatus; message: string } | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
async function handleTest() {
|
||||
setChecking(true);
|
||||
setHealth({ status: "checking", message: "Connecting..." });
|
||||
const result = await checkConnection(baseUrl, token || undefined);
|
||||
setHealth(result);
|
||||
setChecking(false);
|
||||
|
||||
// Auto-show token field if auth is required
|
||||
if (result.status === "auth-required" && !token) {
|
||||
document.getElementById("honcho-token")?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const result = configSchema.safeParse({ baseUrl, token });
|
||||
if (!result.success) {
|
||||
const fieldErrors: typeof errors = {};
|
||||
for (const issue of result.error.issues) {
|
||||
const key = issue.path[0] as keyof Config;
|
||||
fieldErrors[key] = issue.message;
|
||||
}
|
||||
setErrors(fieldErrors);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
saveConfig(result.data);
|
||||
setSaved(true);
|
||||
setTimeout(() => {
|
||||
setSaved(false);
|
||||
onSaved?.();
|
||||
}, 600);
|
||||
}
|
||||
|
||||
const StatusIcon = health ? statusConfig[health.status].icon : null;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-2xl p-6 space-y-5"
|
||||
style={{
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{/* Base URL */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
style={{ color: "var(--text-1)" }}
|
||||
>
|
||||
Honcho Base URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => { setBaseUrl(e.target.value); setHealth(null); }}
|
||||
placeholder="http://localhost:8000"
|
||||
className="flex-1 px-3 py-2 text-sm font-mono rounded-xl outline-none transition-all"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border-2)",
|
||||
color: "var(--text-1)",
|
||||
}}
|
||||
onFocus={(e) => { e.target.style.borderColor = "var(--accent)"; }}
|
||||
onBlur={(e) => { e.target.style.borderColor = "var(--border-2)"; }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={checking || !baseUrl}
|
||||
className="px-3 py-2 rounded-xl text-sm font-medium flex items-center gap-1.5 transition-all disabled:opacity-40"
|
||||
style={{
|
||||
background: "var(--accent-dim)",
|
||||
border: "1px solid var(--accent-border)",
|
||||
color: "var(--accent-text)",
|
||||
}}
|
||||
>
|
||||
{checking ? (
|
||||
<motion.div animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: "linear" }}>
|
||||
<Loader className="w-4 h-4" strokeWidth={1.5} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<Wifi className="w-4 h-4" strokeWidth={1.5} />
|
||||
)}
|
||||
<span className="hidden sm:block">Test</span>
|
||||
</button>
|
||||
</div>
|
||||
{errors.baseUrl && (
|
||||
<p className="text-xs mt-1" style={{ color: "#f87171" }}>{errors.baseUrl}</p>
|
||||
)}
|
||||
<p className="text-xs mt-1.5" style={{ color: "var(--text-3)" }}>
|
||||
URL of your self-hosted Honcho instance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Health status */}
|
||||
<AnimatePresence>
|
||||
{health && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="rounded-xl px-4 py-3 flex items-center gap-2.5"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: `1px solid ${statusConfig[health.status].color}33`,
|
||||
}}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
className="w-4 h-4 shrink-0"
|
||||
style={{ color: statusConfig[health.status].color }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: statusConfig[health.status].color }}>
|
||||
{statusConfig[health.status].label}
|
||||
</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||
{health.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Token */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="honcho-token"
|
||||
className="flex items-center gap-1.5 text-sm font-medium mb-1.5"
|
||||
style={{ color: "var(--text-1)" }}
|
||||
>
|
||||
{token ? (
|
||||
<Lock className="w-3.5 h-3.5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
) : (
|
||||
<LockOpen className="w-3.5 h-3.5" style={{ color: "var(--text-3)" }} strokeWidth={1.5} />
|
||||
)}
|
||||
API Token
|
||||
<span
|
||||
className="ml-1 text-xs font-normal px-1.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-3)",
|
||||
}}
|
||||
>
|
||||
optional
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="honcho-token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="eyJ... (required only if your instance has auth enabled)"
|
||||
className="w-full px-3 py-2.5 text-sm rounded-xl font-mono resize-none outline-none transition-all"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border-2)",
|
||||
color: "var(--text-1)",
|
||||
}}
|
||||
onFocus={(e) => { e.target.style.borderColor = "var(--accent)"; }}
|
||||
onBlur={(e) => { e.target.style.borderColor = "var(--border-2)"; }}
|
||||
/>
|
||||
{health?.status === "auth-required" && !token && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-xs mt-1"
|
||||
style={{ color: "#f59e0b" }}
|
||||
>
|
||||
This instance requires an API token to proceed
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-2.5 px-4 rounded-xl text-sm font-medium transition-all"
|
||||
style={{
|
||||
background: saved ? "#059669" : "var(--accent)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{saved ? "✓ Saved" : "Save Connection"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
23
src/components/shared/Badge.tsx
Normal file
23
src/components/shared/Badge.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: "default" | "green" | "yellow" | "red" | "blue";
|
||||
}
|
||||
|
||||
const variantStyles: Record<string, React.CSSProperties> = {
|
||||
default: { background: "var(--surface)", color: "var(--text-2)", border: "1px solid var(--border)" },
|
||||
green: { background: "rgba(52,211,153,0.08)", color: "#34d399", border: "1px solid rgba(52,211,153,0.2)" },
|
||||
yellow: { background: "rgba(245,158,11,0.08)", color: "#f59e0b", border: "1px solid rgba(245,158,11,0.2)" },
|
||||
red: { background: "rgba(239,68,68,0.08)", color: "#f87171", border: "1px solid rgba(239,68,68,0.2)" },
|
||||
blue: { background: "rgba(99,102,241,0.08)", color: "var(--accent-text)", border: "1px solid var(--accent-border)" },
|
||||
};
|
||||
|
||||
export function Badge({ children, variant = "default" }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium font-mono"
|
||||
style={variantStyles[variant]}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
28
src/components/shared/Card.tsx
Normal file
28
src/components/shared/Card.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Card({ children, className = "", onClick }: CardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
onClick={onClick}
|
||||
whileHover={onClick ? { scale: 1.005, y: -1 } : undefined}
|
||||
whileTap={onClick ? { scale: 0.998 } : undefined}
|
||||
className={className}
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
transition: "border-color 0.2s",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
37
src/components/shared/EmptyState.tsx
Normal file
37
src/components/shared/EmptyState.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { motion } from "framer-motion";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="flex flex-col items-center justify-center py-20 text-center"
|
||||
>
|
||||
{Icon && (
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center mb-4"
|
||||
style={{
|
||||
background: "rgba(99,102,241,0.08)",
|
||||
border: "1px solid rgba(99,102,241,0.15)",
|
||||
}}
|
||||
>
|
||||
<Icon className="w-5 h-5" style={{ color: "rgba(99,102,241,0.6)" }} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-zinc-300 font-medium text-sm">{title}</p>
|
||||
{description && (
|
||||
<p className="text-zinc-600 text-xs mt-1.5 max-w-xs leading-relaxed">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
24
src/components/shared/ErrorAlert.tsx
Normal file
24
src/components/shared/ErrorAlert.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
interface ErrorAlertProps {
|
||||
error: Error | null;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function ErrorAlert({ error, message }: ErrorAlertProps) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-4 mb-4"
|
||||
style={{
|
||||
background: "rgba(239, 68, 68, 0.08)",
|
||||
border: "1px solid rgba(239, 68, 68, 0.25)",
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium" style={{ color: "#f87171" }}>
|
||||
{message ?? "An error occurred"}
|
||||
</p>
|
||||
<p className="text-xs mt-1 font-mono" style={{ color: "rgba(248, 113, 113, 0.6)" }}>
|
||||
{error.message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/components/shared/JsonViewer.tsx
Normal file
30
src/components/shared/JsonViewer.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
interface JsonViewerProps {
|
||||
data: unknown;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
export function JsonViewer({ data, maxHeight = "200px" }: JsonViewerProps) {
|
||||
if (data === null || data === undefined) {
|
||||
return <span className="text-xs italic" style={{ color: "var(--text-4)" }}>empty</span>;
|
||||
}
|
||||
|
||||
const isEmpty =
|
||||
typeof data === "object" && data !== null && Object.keys(data as object).length === 0;
|
||||
if (isEmpty) {
|
||||
return <span className="text-xs italic" style={{ color: "var(--text-4)" }}>{}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
className="text-xs rounded-xl p-3 overflow-auto font-mono"
|
||||
style={{
|
||||
maxHeight,
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-2)",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
40
src/components/shared/LoadingSpinner.tsx
Normal file
40
src/components/shared/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizes = { sm: 16, md: 24, lg: 40 };
|
||||
|
||||
export function LoadingSpinner({ size = "md", className = "" }: LoadingSpinnerProps) {
|
||||
const s = sizes[size];
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
style={{
|
||||
width: s,
|
||||
height: s,
|
||||
borderRadius: "50%",
|
||||
border: `2px solid rgba(99,102,241,0.15)`,
|
||||
borderTopColor: "#6366f1",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageLoader() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3">
|
||||
<LoadingSpinner size="lg" />
|
||||
<motion.div
|
||||
className="h-px w-24"
|
||||
style={{ background: "linear-gradient(90deg, transparent, #6366f1, transparent)" }}
|
||||
animate={{ opacity: [0.4, 1, 0.4] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/shared/Pagination.tsx
Normal file
41
src/components/shared/Pagination.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1.5 text-sm rounded-lg disabled:opacity-30 transition-colors"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-2)",
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-xs font-mono px-2" style={{ color: "var(--text-3)" }}>
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1.5 text-sm rounded-lg disabled:opacity-30 transition-colors"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-2)",
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
src/components/workspaces/WorkspaceDetail.tsx
Normal file
151
src/components/workspaces/WorkspaceDetail.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { Boxes, Users, MessageSquare, Lightbulb, ArrowLeft, CircleDot } from "lucide-react";
|
||||
import { useWorkspace, useQueueStatus } from "@/api/queries";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||
|
||||
const sections = [
|
||||
{ label: "Peers", icon: Users, to: "peers" as const, description: "Browse peer identities and memory" },
|
||||
{ label: "Sessions", icon: MessageSquare, to: "sessions" as const, description: "View conversation sessions" },
|
||||
{ label: "Conclusions", icon: Lightbulb, to: "conclusions" as const, description: "Browse memory conclusions" },
|
||||
];
|
||||
|
||||
export function WorkspaceDetail() {
|
||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||
const { data: workspace, isLoading, error } = useWorkspace(workspaceId);
|
||||
const { data: queue } = useQueueStatus(workspaceId);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<Link
|
||||
to="/workspaces"
|
||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||
Workspaces
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Boxes className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<h1
|
||||
className="text-xl font-semibold font-mono break-all tracking-tight"
|
||||
style={{ color: "var(--text-1)" }}
|
||||
>
|
||||
{workspaceId}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm" style={{ color: "var(--text-2)" }}>Workspace overview</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-8">
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{!isLoading && workspace && (
|
||||
<div className="space-y-4">
|
||||
{/* Nav cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{sections.map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={s.to}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.07, type: "spring", stiffness: 300, damping: 25 }}
|
||||
>
|
||||
<Link
|
||||
to={`/workspaces/$workspaceId/${s.to}` as never}
|
||||
params={{ workspaceId } as never}
|
||||
className="block rounded-xl p-5 group transition-all theme-card"
|
||||
>
|
||||
<Icon
|
||||
className="w-5 h-5 mb-3"
|
||||
style={{ color: "var(--accent)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<h2
|
||||
className="text-sm font-medium mb-0.5"
|
||||
style={{ color: "var(--text-1)" }}
|
||||
>
|
||||
{s.label}
|
||||
</h2>
|
||||
<p className="text-xs" style={{ color: "var(--text-3)" }}>
|
||||
{s.description}
|
||||
</p>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Queue status */}
|
||||
{queue && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
|
||||
Queue Status
|
||||
</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{queue.pending_work_units > 0 ? (
|
||||
<motion.div
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
>
|
||||
<CircleDot className="w-3.5 h-3.5" style={{ color: "#f59e0b" }} strokeWidth={2} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<CircleDot className="w-3.5 h-3.5" style={{ color: "#34d399" }} strokeWidth={2} />
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: queue.pending_work_units > 0 ? "#f59e0b" : "#34d399" }}
|
||||
>
|
||||
{queue.pending_work_units === 0 ? "Idle" : `${queue.pending_work_units} pending`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{(["total_work_units", "completed_work_units", "in_progress_work_units", "pending_work_units"] as const).map((key) => (
|
||||
<div key={key}>
|
||||
<div
|
||||
className="text-2xl font-semibold font-mono"
|
||||
style={{ color: "var(--text-1)" }}
|
||||
>
|
||||
{queue[key]}
|
||||
</div>
|
||||
<div className="text-xs capitalize mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||
{key.replace(/_work_units$/, "").replace(/_/g, " ")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.35 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
|
||||
Metadata
|
||||
</h2>
|
||||
<JsonViewer data={workspace.metadata} />
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
src/components/workspaces/WorkspaceList.tsx
Normal file
140
src/components/workspaces/WorkspaceList.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { Boxes, ChevronRight, Clock } from "lucide-react";
|
||||
import { useWorkspaces } from "@/api/queries";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { Pagination } from "@/components/shared/Pagination";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
|
||||
type Workspace = components["schemas"]["Workspace"];
|
||||
|
||||
const container: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.06 } },
|
||||
};
|
||||
|
||||
const item: Variants = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 25 } },
|
||||
};
|
||||
|
||||
export function WorkspaceList() {
|
||||
const [page, setPage] = useState(1);
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error } = useWorkspaces(page);
|
||||
|
||||
const workspaces: Workspace[] = (data as { items?: Workspace[] } | undefined)?.items ?? [];
|
||||
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Boxes className="w-5 h-5" style={{ color: "#6366f1" }} strokeWidth={1.5} />
|
||||
<h1
|
||||
className="text-xl font-semibold tracking-tight"
|
||||
style={{ color: "#e4e4f0" }}
|
||||
>
|
||||
Workspaces
|
||||
</h1>
|
||||
{total > 0 && (
|
||||
<span
|
||||
className="ml-auto text-xs font-mono px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "rgba(99,102,241,0.1)",
|
||||
color: "#818cf8",
|
||||
border: "1px solid rgba(99,102,241,0.2)",
|
||||
}}
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm" style={{ color: "rgba(148,163,184,0.6)" }}>
|
||||
All workspaces in your Honcho instance
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{!isLoading && workspaces.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title="No workspaces found"
|
||||
description="No workspaces exist yet in this Honcho instance."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && workspaces.length > 0 && (
|
||||
<>
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="space-y-2"
|
||||
>
|
||||
{workspaces.map((ws) => (
|
||||
<motion.button
|
||||
key={ws.id}
|
||||
variants={item}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId: ws.id } as never,
|
||||
})
|
||||
}
|
||||
className="w-full text-left rounded-xl px-5 py-4 group transition-all"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
}}
|
||||
whileHover={{
|
||||
background: "rgba(99,102,241,0.06)",
|
||||
borderColor: "rgba(99,102,241,0.2)",
|
||||
x: 2,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className="font-mono text-sm font-medium"
|
||||
style={{ color: "#c7d2fe" }}
|
||||
>
|
||||
{ws.id}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="w-4 h-4 opacity-30 group-hover:opacity-70 transition-opacity"
|
||||
style={{ color: "#6366f1" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
{ws.created_at && (
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
<Clock
|
||||
className="w-3 h-3"
|
||||
style={{ color: "rgba(148,163,184,0.35)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<p className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.35)" }}>
|
||||
{new Date(ws.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user