Paramjit Singh commited on
Commit
5eaf1b5
·
unverified ·
2 Parent(s): 524f12e2d379f7

Merge pull request #305 from saurabhhhcodes/frontend/skeleton-loading-states-275

Browse files
frontend/src/app/dashboard/page.tsx CHANGED
@@ -66,6 +66,7 @@ export default function DashboardPage() {
66
  const [sidebarOpen, setSidebarOpen] = useState(true);
67
  const [viewerOpen, setViewerOpen] = useState(true);
68
  const [connectionError, setConnectionError] = useState("");
 
69
 
70
  // Auth guard
71
  useEffect(() => {
@@ -86,6 +87,7 @@ export default function DashboardPage() {
86
 
87
  // Load documents
88
  const loadDocuments = useCallback(async () => {
 
89
  try {
90
  const data = await api.get<{ documents?: DocInfo[]; items?: DocInfo[] }>(
91
  "/api/v1/documents/"
@@ -99,6 +101,8 @@ export default function DashboardPage() {
99
  ? CONNECTION_ERROR_BANNER_MESSAGE
100
  : `⚠️ ${message}`
101
  );
 
 
102
  }
103
  }, []);
104
 
@@ -152,6 +156,7 @@ export default function DashboardPage() {
152
  <DocumentSidebar
153
  documents={documents}
154
  activeDoc={activeDoc}
 
155
  onSelectDoc={(doc) => {
156
  setActiveDoc(doc);
157
  setPdfPage(1);
 
66
  const [sidebarOpen, setSidebarOpen] = useState(true);
67
  const [viewerOpen, setViewerOpen] = useState(true);
68
  const [connectionError, setConnectionError] = useState("");
69
+ const [documentsLoading, setDocumentsLoading] = useState(true);
70
 
71
  // Auth guard
72
  useEffect(() => {
 
87
 
88
  // Load documents
89
  const loadDocuments = useCallback(async () => {
90
+ setDocumentsLoading(true);
91
  try {
92
  const data = await api.get<{ documents?: DocInfo[]; items?: DocInfo[] }>(
93
  "/api/v1/documents/"
 
101
  ? CONNECTION_ERROR_BANNER_MESSAGE
102
  : `⚠️ ${message}`
103
  );
104
+ } finally {
105
+ setDocumentsLoading(false);
106
  }
107
  }, []);
108
 
 
156
  <DocumentSidebar
157
  documents={documents}
158
  activeDoc={activeDoc}
159
+ loading={documentsLoading}
160
  onSelectDoc={(doc) => {
161
  setActiveDoc(doc);
162
  setPdfPage(1);
frontend/src/components/chat/ChatPanel.tsx CHANGED
@@ -6,6 +6,7 @@ import type { DocInfo } from "@/app/dashboard/page";
6
  import { api, API_BASE } from "@/lib/api";
7
  import { useChatStore, type ChatMsg, type SourceChunk } from "@/store/chat-store";
8
  import { Button } from "@/components/ui/button";
 
9
  import { Textarea } from "@/components/ui/textarea";
10
  import MessageBubble from "./MessageBubble";
11
  import SourceCard from "./SourceCard";
@@ -57,6 +58,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
57
  const input = useChatStore((state) => state.input);
58
  const streaming = useChatStore((state) => state.streaming);
59
  const isTyping = useChatStore((state) => state.isTyping);
 
60
  const activeSessionId = useChatStore((state) => state.activeSessionId);
61
  const setMessages = useChatStore((state) => state.setMessages);
62
  const setInput = useChatStore((state) => state.setInput);
@@ -74,6 +76,8 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
74
  const prevDocId = useRef<string | null>(null);
75
  const exportMenuRef = useRef<HTMLDivElement>(null);
76
 
 
 
77
  useEffect(() => {
78
  const textarea = textareaRef.current;
79
  if (!textarea) return;
@@ -397,8 +401,24 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
397
  return (
398
  <div className="h-full flex flex-col">
399
  {/* ── Chat Messages ──────────────────────────── */}
400
- <div className="flex-1 px-4 overflow-y-auto custom-scrollbar">
401
- {messages.length === 0 && !isTyping ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  <div className="h-full flex flex-col items-center justify-center py-20">
403
  <div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
404
  <MessageSquare className="w-8 h-8 text-primary/60" />
 
6
  import { api, API_BASE } from "@/lib/api";
7
  import { useChatStore, type ChatMsg, type SourceChunk } from "@/store/chat-store";
8
  import { Button } from "@/components/ui/button";
9
+ import { Skeleton } from "@/components/ui/skeleton";
10
  import { Textarea } from "@/components/ui/textarea";
11
  import MessageBubble from "./MessageBubble";
12
  import SourceCard from "./SourceCard";
 
58
  const input = useChatStore((state) => state.input);
59
  const streaming = useChatStore((state) => state.streaming);
60
  const isTyping = useChatStore((state) => state.isTyping);
61
+ const historyLoading = useChatStore((state) => state.historyLoading);
62
  const activeSessionId = useChatStore((state) => state.activeSessionId);
63
  const setMessages = useChatStore((state) => state.setMessages);
64
  const setInput = useChatStore((state) => state.setInput);
 
76
  const prevDocId = useRef<string | null>(null);
77
  const exportMenuRef = useRef<HTMLDivElement>(null);
78
 
79
+ const showEmptyState = messages.length === 0 && !isTyping && !historyLoading;
80
+
81
  useEffect(() => {
82
  const textarea = textareaRef.current;
83
  if (!textarea) return;
 
401
  return (
402
  <div className="h-full flex flex-col">
403
  {/* ── Chat Messages ──────────────────────────── */}
404
+ <div className="flex-1 px-4 overflow-y-auto custom-scrollbar" aria-busy={historyLoading}>
405
+ {historyLoading ? (
406
+ <div className="py-6 space-y-5 max-w-3xl mx-auto" aria-label="Loading chat history">
407
+ {Array.from({ length: 4 }).map((_, index) => (
408
+ <div
409
+ key={index}
410
+ className={cn("flex gap-3", index % 2 === 0 ? "justify-end" : "justify-start")}
411
+ >
412
+ {index % 2 !== 0 && <Skeleton className="mt-1 h-8 w-8 rounded-full" />}
413
+ <div className={cn("space-y-2", index % 2 === 0 ? "w-2/3" : "w-3/4")}>
414
+ <Skeleton className="h-4 w-full" />
415
+ <Skeleton className="h-4 w-5/6" />
416
+ <Skeleton className="h-4 w-2/3" />
417
+ </div>
418
+ </div>
419
+ ))}
420
+ </div>
421
+ ) : showEmptyState ? (
422
  <div className="h-full flex flex-col items-center justify-center py-20">
423
  <div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
424
  <MessageSquare className="w-8 h-8 text-primary/60" />
frontend/src/components/document/DocumentSidebar.tsx CHANGED
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
5
  import type { DocInfo } from "@/app/dashboard/page";
6
  import { api } from "@/lib/api";
7
  import { ScrollArea } from "@/components/ui/scroll-area";
 
8
  import { Button } from "@/components/ui/button";
9
  import { Badge } from "@/components/ui/badge";
10
  import { Progress } from "@/components/ui/progress";
@@ -19,11 +20,34 @@ import { toast } from "sonner";
19
  interface Props {
20
  documents: DocInfo[];
21
  activeDoc: DocInfo | null;
 
22
  onSelectDoc: (doc: DocInfo) => void;
23
  onDocumentsChange: () => void;
24
  }
25
 
26
- export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc, onDocumentsChange }: Props) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  const { t } = useTranslation();
28
  const [uploading, setUploading] = useState(false);
29
  const [uploadProgress, setUploadProgress] = useState(0);
@@ -155,12 +179,16 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc
155
  {/* ── Documents List ──────────────────────────── */}
156
  <div className="px-3 pt-3 pb-1">
157
  <h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">
158
- {t("documents.documentsTitle", { count: documents.length })}
 
 
159
  </h3>
160
  </div>
161
 
162
- <ScrollArea className="flex-1 px-3 overflow-auto">
163
- {documents.length === 0 ? (
 
 
164
  <div className="text-center py-12">
165
  <FolderOpen className="w-8 h-8 mx-auto text-muted-foreground/40 mb-3" />
166
  <p className="text-sm text-muted-foreground">{t("documents.noDocuments")}</p>
 
5
  import type { DocInfo } from "@/app/dashboard/page";
6
  import { api } from "@/lib/api";
7
  import { ScrollArea } from "@/components/ui/scroll-area";
8
+ import { Skeleton } from "@/components/ui/skeleton";
9
  import { Button } from "@/components/ui/button";
10
  import { Badge } from "@/components/ui/badge";
11
  import { Progress } from "@/components/ui/progress";
 
20
  interface Props {
21
  documents: DocInfo[];
22
  activeDoc: DocInfo | null;
23
+ loading?: boolean;
24
  onSelectDoc: (doc: DocInfo) => void;
25
  onDocumentsChange: () => void;
26
  }
27
 
28
+ function DocumentListSkeleton() {
29
+ return (
30
+ <div className="space-y-2 pb-3" aria-hidden="true">
31
+ {Array.from({ length: 5 }).map((_, index) => (
32
+ <div key={index} className="rounded-lg border border-sidebar-border/60 p-2.5">
33
+ <div className="flex items-start gap-2.5">
34
+ <Skeleton className="mt-0.5 h-4 w-4 rounded-full bg-sidebar-accent" />
35
+ <div className="min-w-0 flex-1 space-y-2">
36
+ <Skeleton className="h-4 w-4/5 bg-sidebar-accent" />
37
+ <Skeleton className="h-3 w-full bg-sidebar-accent/80" />
38
+ <div className="flex gap-2">
39
+ <Skeleton className="h-3 w-10 bg-sidebar-accent/70" />
40
+ <Skeleton className="h-3 w-12 bg-sidebar-accent/70" />
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ ))}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ export default function DocumentSidebar({ documents = [], activeDoc, loading = false, onSelectDoc, onDocumentsChange }: Props) {
51
  const { t } = useTranslation();
52
  const [uploading, setUploading] = useState(false);
53
  const [uploadProgress, setUploadProgress] = useState(0);
 
179
  {/* ── Documents List ──────────────────────────── */}
180
  <div className="px-3 pt-3 pb-1">
181
  <h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">
182
+ {loading
183
+ ? t("documents.documentsTitle", { count: "..." })
184
+ : t("documents.documentsTitle", { count: documents.length })}
185
  </h3>
186
  </div>
187
 
188
+ <ScrollArea className="flex-1 px-3 overflow-auto" aria-busy={loading}>
189
+ {loading ? (
190
+ <DocumentListSkeleton />
191
+ ) : documents.length === 0 ? (
192
  <div className="text-center py-12">
193
  <FolderOpen className="w-8 h-8 mx-auto text-muted-foreground/40 mb-3" />
194
  <p className="text-sm text-muted-foreground">{t("documents.noDocuments")}</p>
frontend/src/components/layout/Header.tsx CHANGED
@@ -4,6 +4,7 @@ import { useState } from "react";
4
  import { useAuth } from "@/lib/auth";
5
  import { useRouter } from "next/navigation";
6
  import { Button } from "@/components/ui/button";
 
7
  import { Avatar, AvatarFallback } from "@/components/ui/avatar";
8
  import {
9
  DropdownMenu,
@@ -56,6 +57,7 @@ export default function Header({
56
  const { theme, setTheme } = useTheme();
57
  const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
58
  const [sheetOpen, setSheetOpen] = useState(false);
 
59
  const workspace = useWorkspaceStore((s) => s.workspace);
60
  const setWorkspace = useWorkspaceStore((s) => s.setWorkspace);
61
 
@@ -69,6 +71,7 @@ export default function Header({
69
 
70
  const fetchDocumentsForWorkspace = async (id: string) => {
71
  // Placeholder: simulate fetching documents for the selected workspace
 
72
  try {
73
  // Attempt a real API call if available; otherwise this will fail silently
74
  const res = await api.get(`/api/v1/documents?workspace=${encodeURIComponent(id)}`).catch(() => null);
@@ -77,6 +80,8 @@ export default function Header({
77
  // e.g. documentStore.setDocuments(res || [])
78
  } catch (err) {
79
  console.warn("Failed to fetch documents for workspace", id, err);
 
 
80
  }
81
  };
82
 
@@ -152,9 +157,18 @@ export default function Header({
152
  {/* Workspace switcher */}
153
  <DropdownMenu>
154
  <DropdownMenuTrigger className="flex items-center h-8 gap-2 px-2 rounded-md hover:bg-accent transition-colors cursor-pointer">
155
- <Briefcase className="w-4 h-4" />
156
- <span className="text-sm hidden sm:inline">{WORKSPACES.find((w) => w.id === workspace)?.label}</span>
157
- <ChevronDown className="w-3 h-3" />
 
 
 
 
 
 
 
 
 
158
  </DropdownMenuTrigger>
159
  <DropdownMenuContent align="end" className="w-40">
160
  {WORKSPACES.map((w) => (
@@ -247,4 +261,4 @@ export default function Header({
247
  </aside>
248
  </>
249
  );
250
- }
 
4
  import { useAuth } from "@/lib/auth";
5
  import { useRouter } from "next/navigation";
6
  import { Button } from "@/components/ui/button";
7
+ import { Skeleton } from "@/components/ui/skeleton";
8
  import { Avatar, AvatarFallback } from "@/components/ui/avatar";
9
  import {
10
  DropdownMenu,
 
57
  const { theme, setTheme } = useTheme();
58
  const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
59
  const [sheetOpen, setSheetOpen] = useState(false);
60
+ const [workspaceLoading, setWorkspaceLoading] = useState(false);
61
  const workspace = useWorkspaceStore((s) => s.workspace);
62
  const setWorkspace = useWorkspaceStore((s) => s.setWorkspace);
63
 
 
71
 
72
  const fetchDocumentsForWorkspace = async (id: string) => {
73
  // Placeholder: simulate fetching documents for the selected workspace
74
+ setWorkspaceLoading(true);
75
  try {
76
  // Attempt a real API call if available; otherwise this will fail silently
77
  const res = await api.get(`/api/v1/documents?workspace=${encodeURIComponent(id)}`).catch(() => null);
 
80
  // e.g. documentStore.setDocuments(res || [])
81
  } catch (err) {
82
  console.warn("Failed to fetch documents for workspace", id, err);
83
+ } finally {
84
+ setWorkspaceLoading(false);
85
  }
86
  };
87
 
 
157
  {/* Workspace switcher */}
158
  <DropdownMenu>
159
  <DropdownMenuTrigger className="flex items-center h-8 gap-2 px-2 rounded-md hover:bg-accent transition-colors cursor-pointer">
160
+ {workspaceLoading ? (
161
+ <>
162
+ <Skeleton className="h-4 w-4 rounded-sm" />
163
+ <Skeleton className="hidden h-4 w-16 sm:block" />
164
+ </>
165
+ ) : (
166
+ <>
167
+ <Briefcase className="w-4 h-4" />
168
+ <span className="text-sm hidden sm:inline">{WORKSPACES.find((w) => w.id === workspace)?.label}</span>
169
+ <ChevronDown className="w-3 h-3" />
170
+ </>
171
+ )}
172
  </DropdownMenuTrigger>
173
  <DropdownMenuContent align="end" className="w-40">
174
  {WORKSPACES.map((w) => (
 
261
  </aside>
262
  </>
263
  );
264
+ }
frontend/src/store/chat-store.ts CHANGED
@@ -32,12 +32,14 @@ interface ChatStore {
32
  input: string;
33
  streaming: boolean;
34
  isTyping: boolean;
 
35
  sessions: ChatSession[];
36
  activeSessionId: string | null;
37
  setMessages: (value: Setter<ChatMsg[]>) => void;
38
  setInput: (value: Setter<string>) => void;
39
  setStreaming: (value: Setter<boolean>) => void;
40
  setIsTyping: (value: Setter<boolean>) => void;
 
41
  setSessions: (value: Setter<ChatSession[]>) => void;
42
  setActiveSessionId: (value: Setter<string | null>) => void;
43
  fetchSessions: () => Promise<void>;
@@ -56,6 +58,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
56
  input: "",
57
  streaming: false,
58
  isTyping: false,
 
59
  sessions: [],
60
  activeSessionId: null,
61
 
@@ -75,6 +78,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
75
  set((state) => ({ isTyping: resolveValue(value, state.isTyping) }));
76
  },
77
 
 
 
 
 
78
  setSessions(value) {
79
  set((state) => ({ sessions: resolveValue(value, state.sessions) }));
80
  },
@@ -150,11 +157,14 @@ export const useChatStore = create<ChatStore>((set, get) => ({
150
  },
151
 
152
  async fetchSessionHistory(id) {
 
153
  try {
154
  const data = await api.get<{ messages: ChatMsg[] }>(`/api/v1/chat/history/session/${id}`);
155
  set({ messages: data.messages });
156
  } catch (err) {
157
  console.error("Failed to fetch session history:", err);
 
 
158
  }
159
  },
160
 
@@ -164,6 +174,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
164
  input: "",
165
  streaming: false,
166
  isTyping: false,
 
167
  sessions: [],
168
  activeSessionId: null,
169
  });
 
32
  input: string;
33
  streaming: boolean;
34
  isTyping: boolean;
35
+ historyLoading: boolean;
36
  sessions: ChatSession[];
37
  activeSessionId: string | null;
38
  setMessages: (value: Setter<ChatMsg[]>) => void;
39
  setInput: (value: Setter<string>) => void;
40
  setStreaming: (value: Setter<boolean>) => void;
41
  setIsTyping: (value: Setter<boolean>) => void;
42
+ setHistoryLoading: (value: Setter<boolean>) => void;
43
  setSessions: (value: Setter<ChatSession[]>) => void;
44
  setActiveSessionId: (value: Setter<string | null>) => void;
45
  fetchSessions: () => Promise<void>;
 
58
  input: "",
59
  streaming: false,
60
  isTyping: false,
61
+ historyLoading: false,
62
  sessions: [],
63
  activeSessionId: null,
64
 
 
78
  set((state) => ({ isTyping: resolveValue(value, state.isTyping) }));
79
  },
80
 
81
+ setHistoryLoading(value) {
82
+ set((state) => ({ historyLoading: resolveValue(value, state.historyLoading) }));
83
+ },
84
+
85
  setSessions(value) {
86
  set((state) => ({ sessions: resolveValue(value, state.sessions) }));
87
  },
 
157
  },
158
 
159
  async fetchSessionHistory(id) {
160
+ set({ historyLoading: true });
161
  try {
162
  const data = await api.get<{ messages: ChatMsg[] }>(`/api/v1/chat/history/session/${id}`);
163
  set({ messages: data.messages });
164
  } catch (err) {
165
  console.error("Failed to fetch session history:", err);
166
+ } finally {
167
+ set({ historyLoading: false });
168
  }
169
  },
170
 
 
174
  input: "",
175
  streaming: false,
176
  isTyping: false,
177
+ historyLoading: false,
178
  sessions: [],
179
  activeSessionId: null,
180
  });