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:
@@ -1,26 +1,43 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
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 { ToastProvider } from "./components/Toast";
|
||||
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() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<DashboardLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/queue" element={<QueuePage />} />
|
||||
<Route path="/task/:taskRef" element={<TaskPage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/" element={<Suspense fallback={<PageLoader />}><DashboardPage /></Suspense>} />
|
||||
<Route path="/queue" element={<Suspense fallback={<PageLoader />}><QueuePage /></Suspense>} />
|
||||
<Route path="/task/:taskRef" element={<Suspense fallback={<PageLoader />}><TaskPage /></Suspense>} />
|
||||
<Route path="/projects" element={<Suspense fallback={<PageLoader />}><ProjectsPage /></Suspense>} />
|
||||
<Route path="/chat" element={<Suspense fallback={<PageLoader />}><ChatPage /></Suspense>} />
|
||||
<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>
|
||||
</Routes>
|
||||
|
||||
@@ -7,6 +7,7 @@ const navItems = [
|
||||
{ to: "/", label: "Dashboard", icon: "🔨" },
|
||||
{ to: "/queue", label: "Queue", icon: "📋" },
|
||||
{ to: "/projects", label: "Projects", icon: "📁" },
|
||||
{ to: "/activity", label: "Activity", icon: "📝" },
|
||||
{ to: "/chat", label: "Chat", icon: "💬" },
|
||||
];
|
||||
|
||||
|
||||
166
frontend/src/pages/ActivityPage.tsx
Normal file
166
frontend/src/pages/ActivityPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,14 @@ interface ChatThread {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface GatewaySession {
|
||||
sessionKey: string;
|
||||
kind?: string;
|
||||
channel?: string;
|
||||
lastActivity?: string;
|
||||
messageCount?: number;
|
||||
}
|
||||
|
||||
function ThreadList({
|
||||
threads,
|
||||
activeThread,
|
||||
@@ -24,6 +32,11 @@ function ThreadList({
|
||||
onRename,
|
||||
onDelete,
|
||||
onClose,
|
||||
onBrowseSessions,
|
||||
gatewaySessions,
|
||||
loadingGatewaySessions,
|
||||
showGatewaySessions,
|
||||
onToggleGatewaySessions,
|
||||
}: {
|
||||
threads: ChatThread[];
|
||||
activeThread: string | null;
|
||||
@@ -32,6 +45,11 @@ function ThreadList({
|
||||
onRename?: (key: string, name: string) => void;
|
||||
onDelete?: (key: string) => void;
|
||||
onClose?: () => void;
|
||||
onBrowseSessions?: () => void;
|
||||
gatewaySessions?: GatewaySession[];
|
||||
loadingGatewaySessions?: boolean;
|
||||
showGatewaySessions?: boolean;
|
||||
onToggleGatewaySessions?: () => void;
|
||||
}) {
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
@@ -48,6 +66,9 @@ function ThreadList({
|
||||
setEditingKey(null);
|
||||
};
|
||||
|
||||
// Check if a session key exists in local threads already
|
||||
const localSessionKeys = new Set(threads.map(t => t.sessionKey));
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -73,7 +94,8 @@ function ThreadList({
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
No threads yet
|
||||
</div>
|
||||
@@ -134,6 +156,64 @@ function ThreadList({
|
||||
</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>
|
||||
);
|
||||
@@ -357,6 +437,9 @@ export function ChatPage() {
|
||||
const [thinking, setThinking] = useState(false);
|
||||
const [streamText, setStreamText] = useState("");
|
||||
const [showThreads, setShowThreads] = useState(false);
|
||||
const [gatewaySessions, setGatewaySessions] = useState<GatewaySession[]>([]);
|
||||
const [loadingGatewaySessions, setLoadingGatewaySessions] = useState(false);
|
||||
const [showGatewaySessions, setShowGatewaySessions] = useState(false);
|
||||
|
||||
// Persist threads to localStorage
|
||||
useEffect(() => {
|
||||
@@ -460,6 +543,27 @@ export function ChatPage() {
|
||||
}
|
||||
}, [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 id = `dash:chat:${Date.now()}`;
|
||||
const thread: ChatThread = {
|
||||
@@ -472,6 +576,24 @@ export function ChatPage() {
|
||||
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) => {
|
||||
setThreads((prev) => prev.map((t) => (t.sessionKey === key ? { ...t, name } : t)));
|
||||
};
|
||||
@@ -580,7 +702,7 @@ export function ChatPage() {
|
||||
threads={threads}
|
||||
activeThread={activeThread}
|
||||
onSelect={(key) => {
|
||||
setActiveThread(key);
|
||||
handleSelectThread(key);
|
||||
setShowThreads(false);
|
||||
}}
|
||||
onCreate={() => {
|
||||
@@ -590,6 +712,11 @@ export function ChatPage() {
|
||||
onRename={handleRenameThread}
|
||||
onDelete={handleDeleteThread}
|
||||
onClose={() => setShowThreads(false)}
|
||||
onBrowseSessions={handleBrowseSessions}
|
||||
gatewaySessions={gatewaySessions}
|
||||
loadingGatewaySessions={loadingGatewaySessions}
|
||||
showGatewaySessions={showGatewaySessions}
|
||||
onToggleGatewaySessions={() => setShowGatewaySessions(!showGatewaySessions)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user