diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2f0fe60..61956da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( +
+
+ + + +
+
+ ); +} + function AuthenticatedApp() { return ( }> - } /> - } /> - } /> - } /> - } /> - } /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> } /> diff --git a/frontend/src/components/DashboardLayout.tsx b/frontend/src/components/DashboardLayout.tsx index a325052..4b5457e 100644 --- a/frontend/src/components/DashboardLayout.tsx +++ b/frontend/src/components/DashboardLayout.tsx @@ -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: "💬" }, ]; diff --git a/frontend/src/pages/ActivityPage.tsx b/frontend/src/pages/ActivityPage.tsx new file mode 100644 index 0000000..ebde861 --- /dev/null +++ b/frontend/src/pages/ActivityPage.tsx @@ -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("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 ( +
+ Loading activity... +
+ ); + } + + return ( +
+
+
+
+

📝 Activity Log

+

+ {allActivity.length} updates across {tasks.length} tasks +

+
+ +
+
+ +
+ {groupedActivity.length === 0 ? ( +
No activity found
+ ) : ( +
+ {groupedActivity.map((group) => ( +
+
+

{group.date}

+
+
+ {group.items.map((item, i) => ( +
+ {/* Timeline dot */} +
+
+ {i < group.items.length - 1 && ( +
+ )} +
+ +
+
+ + HQ-{item.task.taskNumber} + + + {formatDate(item.note.timestamp)} + + + ({timeAgo(item.note.timestamp)}) + +
+

+ {item.note.note} +

+

+ {item.task.title} +

+
+
+ ))} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index 628c671..846389e 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -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(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 (
@@ -73,7 +94,8 @@ function ThreadList({
- {threads.length === 0 ? ( + {/* Local threads */} + {threads.length === 0 && !showGatewaySessions ? (
No threads yet
@@ -134,6 +156,64 @@ function ThreadList({
)) )} + + {/* Gateway sessions browser */} +
+ + {showGatewaySessions && ( +
+ {loadingGatewaySessions ? ( +
Loading sessions...
+ ) : !gatewaySessions || gatewaySessions.length === 0 ? ( +
No sessions found
+ ) : ( + gatewaySessions.filter(s => !localSessionKeys.has(s.sessionKey)).map((session) => ( +
onSelect(session.sessionKey)} + > +
+ + {session.channel === "telegram" ? "📱" : session.kind === "cron" ? "⏰" : "💬"} + + + {session.sessionKey} + +
+
+ {session.channel && ( + {session.channel} + )} + {session.kind && ( + {session.kind} + )} + {session.messageCount != null && ( + {session.messageCount} msgs + )} +
+
+ )) + )} +
+ )} +
); @@ -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([]); + 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)} />