feat: code splitting, activity log page, gateway session browser in chat

- Lazy-load all route pages (React.lazy + Suspense) - main bundle 520KB → 266KB
- New Activity Log page (/activity) with timeline view, day grouping, status filter
- Chat: gateway session browser - browse existing Clawdbot sessions from sidebar
- Chat: clicking a gateway session adds it as a local thread for conversation
- Sidebar: added Activity nav item between Projects and Chat
This commit is contained in:
2026-01-29 09:03:36 +00:00
parent 819649c8c7
commit 8c284684c9
4 changed files with 325 additions and 14 deletions

View File

@@ -1,26 +1,43 @@
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { DashboardLayout } from "./components/DashboardLayout"; import { DashboardLayout } from "./components/DashboardLayout";
import { DashboardPage } from "./pages/DashboardPage";
import { QueuePage } from "./pages/QueuePage";
import { ChatPage } from "./pages/ChatPage";
import { ProjectsPage } from "./pages/ProjectsPage";
import { TaskPage } from "./pages/TaskPage";
import { AdminPage } from "./components/AdminPage";
import { LoginPage } from "./components/LoginPage"; import { LoginPage } from "./components/LoginPage";
import { ToastProvider } from "./components/Toast"; import { ToastProvider } from "./components/Toast";
import { useSession } from "./lib/auth-client"; import { useSession } from "./lib/auth-client";
// Lazy-loaded pages for code splitting
const DashboardPage = lazy(() => import("./pages/DashboardPage").then(m => ({ default: m.DashboardPage })));
const QueuePage = lazy(() => import("./pages/QueuePage").then(m => ({ default: m.QueuePage })));
const ChatPage = lazy(() => import("./pages/ChatPage").then(m => ({ default: m.ChatPage })));
const ProjectsPage = lazy(() => import("./pages/ProjectsPage").then(m => ({ default: m.ProjectsPage })));
const TaskPage = lazy(() => import("./pages/TaskPage").then(m => ({ default: m.TaskPage })));
const ActivityPage = lazy(() => import("./pages/ActivityPage").then(m => ({ default: m.ActivityPage })));
const AdminPage = lazy(() => import("./components/AdminPage").then(m => ({ default: m.AdminPage })));
function PageLoader() {
return (
<div className="min-h-[50vh] flex items-center justify-center text-gray-400">
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
</div>
);
}
function AuthenticatedApp() { function AuthenticatedApp() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route element={<DashboardLayout />}> <Route element={<DashboardLayout />}>
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<Suspense fallback={<PageLoader />}><DashboardPage /></Suspense>} />
<Route path="/queue" element={<QueuePage />} /> <Route path="/queue" element={<Suspense fallback={<PageLoader />}><QueuePage /></Suspense>} />
<Route path="/task/:taskRef" element={<TaskPage />} /> <Route path="/task/:taskRef" element={<Suspense fallback={<PageLoader />}><TaskPage /></Suspense>} />
<Route path="/projects" element={<ProjectsPage />} /> <Route path="/projects" element={<Suspense fallback={<PageLoader />}><ProjectsPage /></Suspense>} />
<Route path="/chat" element={<ChatPage />} /> <Route path="/chat" element={<Suspense fallback={<PageLoader />}><ChatPage /></Suspense>} />
<Route path="/admin" element={<AdminPage />} /> <Route path="/activity" element={<Suspense fallback={<PageLoader />}><ActivityPage /></Suspense>} />
<Route path="/admin" element={<Suspense fallback={<PageLoader />}><AdminPage /></Suspense>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -7,6 +7,7 @@ const navItems = [
{ to: "/", label: "Dashboard", icon: "🔨" }, { to: "/", label: "Dashboard", icon: "🔨" },
{ to: "/queue", label: "Queue", icon: "📋" }, { to: "/queue", label: "Queue", icon: "📋" },
{ to: "/projects", label: "Projects", icon: "📁" }, { to: "/projects", label: "Projects", icon: "📁" },
{ to: "/activity", label: "Activity", icon: "📝" },
{ to: "/chat", label: "Chat", icon: "💬" }, { to: "/chat", label: "Chat", icon: "💬" },
]; ];

View File

@@ -0,0 +1,166 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useTasks } from "../hooks/useTasks";
import type { Task, ProgressNote } from "../lib/types";
function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
interface ActivityItem {
task: Task;
note: ProgressNote;
}
export function ActivityPage() {
const { tasks, loading } = useTasks(15000);
const [filter, setFilter] = useState<string>("all");
const allActivity = useMemo(() => {
const items: ActivityItem[] = [];
for (const task of tasks) {
if (task.progressNotes) {
for (const note of task.progressNotes) {
items.push({ task, note });
}
}
}
items.sort(
(a, b) =>
new Date(b.note.timestamp).getTime() - new Date(a.note.timestamp).getTime()
);
return items;
}, [tasks]);
// Group by day
const groupedActivity = useMemo(() => {
const filtered =
filter === "all"
? allActivity
: allActivity.filter((a) => a.task.status === filter);
const groups: { date: string; items: ActivityItem[] }[] = [];
let currentDate = "";
for (const item of filtered) {
const d = new Date(item.note.timestamp).toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
if (d !== currentDate) {
currentDate = d;
groups.push({ date: d, items: [] });
}
groups[groups.length - 1].items.push(item);
}
return groups;
}, [allActivity, filter]);
if (loading && tasks.length === 0) {
return (
<div className="min-h-screen flex items-center justify-center text-gray-400">
Loading activity...
</div>
);
}
return (
<div className="min-h-screen">
<header className="bg-white border-b border-gray-200 sticky top-14 md:top-0 z-30">
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900">📝 Activity Log</h1>
<p className="text-sm text-gray-400">
{allActivity.length} updates across {tasks.length} tasks
</p>
</div>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200"
>
<option value="all">All Tasks</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="queued">Queued</option>
<option value="blocked">Blocked</option>
</select>
</div>
</header>
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6">
{groupedActivity.length === 0 ? (
<div className="text-center text-gray-400 py-12">No activity found</div>
) : (
<div className="space-y-8">
{groupedActivity.map((group) => (
<div key={group.date}>
<div className="sticky top-28 md:top-14 z-10 bg-gray-50/95 backdrop-blur-sm py-2 mb-3">
<h2 className="text-sm font-semibold text-gray-500">{group.date}</h2>
</div>
<div className="space-y-1">
{group.items.map((item, i) => (
<div
key={`${item.task.id}-${i}`}
className="flex gap-3 py-3 px-3 rounded-lg hover:bg-white transition group"
>
{/* Timeline dot */}
<div className="flex flex-col items-center pt-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-amber-400 shrink-0" />
{i < group.items.length - 1 && (
<div className="w-px flex-1 bg-gray-200 mt-1" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<Link
to={`/task/HQ-${item.task.taskNumber}`}
className="text-xs font-bold text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded font-mono hover:bg-amber-100 transition"
>
HQ-{item.task.taskNumber}
</Link>
<span className="text-[10px] text-gray-400">
{formatDate(item.note.timestamp)}
</span>
<span className="text-[10px] text-gray-300">
({timeAgo(item.note.timestamp)})
</span>
</div>
<p className="text-sm text-gray-700 leading-relaxed">
{item.note.note}
</p>
<p className="text-xs text-gray-400 mt-1 truncate">
{item.task.title}
</p>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -16,6 +16,14 @@ interface ChatThread {
updatedAt: number; updatedAt: number;
} }
interface GatewaySession {
sessionKey: string;
kind?: string;
channel?: string;
lastActivity?: string;
messageCount?: number;
}
function ThreadList({ function ThreadList({
threads, threads,
activeThread, activeThread,
@@ -24,6 +32,11 @@ function ThreadList({
onRename, onRename,
onDelete, onDelete,
onClose, onClose,
onBrowseSessions,
gatewaySessions,
loadingGatewaySessions,
showGatewaySessions,
onToggleGatewaySessions,
}: { }: {
threads: ChatThread[]; threads: ChatThread[];
activeThread: string | null; activeThread: string | null;
@@ -32,6 +45,11 @@ function ThreadList({
onRename?: (key: string, name: string) => void; onRename?: (key: string, name: string) => void;
onDelete?: (key: string) => void; onDelete?: (key: string) => void;
onClose?: () => void; onClose?: () => void;
onBrowseSessions?: () => void;
gatewaySessions?: GatewaySession[];
loadingGatewaySessions?: boolean;
showGatewaySessions?: boolean;
onToggleGatewaySessions?: () => void;
}) { }) {
const [editingKey, setEditingKey] = useState<string | null>(null); const [editingKey, setEditingKey] = useState<string | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
@@ -48,6 +66,9 @@ function ThreadList({
setEditingKey(null); setEditingKey(null);
}; };
// Check if a session key exists in local threads already
const localSessionKeys = new Set(threads.map(t => t.sessionKey));
return ( return (
<div className="w-full sm:w-64 bg-white border-r border-gray-200 flex flex-col h-full"> <div className="w-full sm:w-64 bg-white border-r border-gray-200 flex flex-col h-full">
<div className="p-3 border-b border-gray-100 flex items-center justify-between"> <div className="p-3 border-b border-gray-100 flex items-center justify-between">
@@ -73,7 +94,8 @@ function ThreadList({
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{threads.length === 0 ? ( {/* Local threads */}
{threads.length === 0 && !showGatewaySessions ? (
<div className="p-4 text-sm text-gray-400 text-center"> <div className="p-4 text-sm text-gray-400 text-center">
No threads yet No threads yet
</div> </div>
@@ -134,6 +156,64 @@ function ThreadList({
</div> </div>
)) ))
)} )}
{/* Gateway sessions browser */}
<div className="border-t border-gray-200">
<button
onClick={() => {
onToggleGatewaySessions?.();
if (!showGatewaySessions) onBrowseSessions?.();
}}
className="w-full px-3 py-2.5 text-xs font-semibold text-gray-500 hover:text-gray-700 hover:bg-gray-50 transition flex items-center justify-between"
>
<span>🔌 Gateway Sessions</span>
<svg
className={`w-3.5 h-3.5 transition-transform ${showGatewaySessions ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showGatewaySessions && (
<div className="bg-gray-50">
{loadingGatewaySessions ? (
<div className="px-3 py-3 text-xs text-gray-400 text-center">Loading sessions...</div>
) : !gatewaySessions || gatewaySessions.length === 0 ? (
<div className="px-3 py-3 text-xs text-gray-400 text-center">No sessions found</div>
) : (
gatewaySessions.filter(s => !localSessionKeys.has(s.sessionKey)).map((session) => (
<div
key={session.sessionKey}
className="px-3 py-2.5 border-b border-gray-100 hover:bg-gray-100 cursor-pointer transition"
onClick={() => onSelect(session.sessionKey)}
>
<div className="flex items-center gap-1.5">
<span className="text-xs">
{session.channel === "telegram" ? "📱" : session.kind === "cron" ? "⏰" : "💬"}
</span>
<span className="text-xs font-medium text-gray-700 truncate">
{session.sessionKey}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
{session.channel && (
<span className="text-[10px] text-gray-400">{session.channel}</span>
)}
{session.kind && (
<span className="text-[10px] text-gray-400">{session.kind}</span>
)}
{session.messageCount != null && (
<span className="text-[10px] text-gray-400">{session.messageCount} msgs</span>
)}
</div>
</div>
))
)}
</div>
)}
</div>
</div> </div>
</div> </div>
); );
@@ -357,6 +437,9 @@ export function ChatPage() {
const [thinking, setThinking] = useState(false); const [thinking, setThinking] = useState(false);
const [streamText, setStreamText] = useState(""); const [streamText, setStreamText] = useState("");
const [showThreads, setShowThreads] = useState(false); const [showThreads, setShowThreads] = useState(false);
const [gatewaySessions, setGatewaySessions] = useState<GatewaySession[]>([]);
const [loadingGatewaySessions, setLoadingGatewaySessions] = useState(false);
const [showGatewaySessions, setShowGatewaySessions] = useState(false);
// Persist threads to localStorage // Persist threads to localStorage
useEffect(() => { useEffect(() => {
@@ -460,6 +543,27 @@ export function ChatPage() {
} }
}, [activeThread, connectionState, loadMessages]); }, [activeThread, connectionState, loadMessages]);
const handleBrowseSessions = useCallback(async () => {
if (!client.isConnected()) return;
setLoadingGatewaySessions(true);
try {
const result = await client.sessionsList(50);
if (result?.sessions) {
setGatewaySessions(result.sessions.map((s: any) => ({
sessionKey: s.sessionKey || s.key,
kind: s.kind,
channel: s.channel,
lastActivity: s.lastActivity,
messageCount: s.messageCount,
})));
}
} catch (e) {
console.error("Failed to load sessions:", e);
} finally {
setLoadingGatewaySessions(false);
}
}, [client]);
const handleCreateThread = () => { const handleCreateThread = () => {
const id = `dash:chat:${Date.now()}`; const id = `dash:chat:${Date.now()}`;
const thread: ChatThread = { const thread: ChatThread = {
@@ -472,6 +576,24 @@ export function ChatPage() {
setMessages([]); setMessages([]);
}; };
const handleSelectThread = (sessionKey: string) => {
// If it's a gateway session not in local threads, add it
if (!threads.find(t => t.sessionKey === sessionKey)) {
const gwSession = gatewaySessions.find(s => s.sessionKey === sessionKey);
const thread: ChatThread = {
sessionKey,
name: gwSession?.channel
? `${gwSession.channel} (${sessionKey.slice(0, 12)}...)`
: sessionKey.length > 20
? `${sessionKey.slice(0, 20)}...`
: sessionKey,
updatedAt: Date.now(),
};
setThreads((prev) => [thread, ...prev]);
}
setActiveThread(sessionKey);
};
const handleRenameThread = (key: string, name: string) => { const handleRenameThread = (key: string, name: string) => {
setThreads((prev) => prev.map((t) => (t.sessionKey === key ? { ...t, name } : t))); setThreads((prev) => prev.map((t) => (t.sessionKey === key ? { ...t, name } : t)));
}; };
@@ -580,7 +702,7 @@ export function ChatPage() {
threads={threads} threads={threads}
activeThread={activeThread} activeThread={activeThread}
onSelect={(key) => { onSelect={(key) => {
setActiveThread(key); handleSelectThread(key);
setShowThreads(false); setShowThreads(false);
}} }}
onCreate={() => { onCreate={() => {
@@ -590,6 +712,11 @@ export function ChatPage() {
onRename={handleRenameThread} onRename={handleRenameThread}
onDelete={handleDeleteThread} onDelete={handleDeleteThread}
onClose={() => setShowThreads(false)} onClose={() => setShowThreads(false)}
onBrowseSessions={handleBrowseSessions}
gatewaySessions={gatewaySessions}
loadingGatewaySessions={loadingGatewaySessions}
showGatewaySessions={showGatewaySessions}
onToggleGatewaySessions={() => setShowGatewaySessions(!showGatewaySessions)}
/> />
</div> </div>
</div> </div>