diff --git a/package.json b/package.json index 83a8ef4b3..1ef85ac84 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "qrcode.react": "^4.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.0", "redux-logger": "^3.0.6", diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index f1ef2ff2b..46a58e40b 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -108,6 +108,14 @@ const AppRoutes = () => { } /> + + + + } + /> {/* Invites */} ), }, - // { - // id: 'conversations', - // label: 'Conversations', - // path: '/conversations', - // icon: ( - // - // - // - // ), - // }, + { + id: 'conversations', + label: 'Conversations', + path: '/conversations', + icon: ( + + + + ), + }, { id: 'intelligence', label: 'Intelligence', @@ -91,6 +91,17 @@ const MiniSidebar = () => { const navigate = useNavigate(); const token = useAppSelector(state => state.auth.token); + // Unread count for Conversations: threads with lastMessageAt > lastViewedAt (must be before early return) + const conversationsUnreadCount = useAppSelector(state => { + const { threads, lastViewedAt } = state.thread; + if (threads.length === 0) return 0; + return threads.filter(t => { + const viewed = lastViewedAt[t.id]; + const lastMsg = new Date(t.lastMessageAt || t.createdAt).getTime(); + return viewed == null || lastMsg > viewed; + }).length; + }); + // Hide sidebar when not authenticated or on public/onboarding routes const hiddenPaths = ['/', '/login', '/onboarding']; if (!token || hiddenPaths.includes(location.pathname)) { @@ -107,6 +118,7 @@ const MiniSidebar = () => {
{navItems.map(item => { const active = isActive(item.path); + const showUnreadBadge = item.id === 'conversations' && conversationsUnreadCount > 0; return (
+ {showUnreadBadge && ( + + {conversationsUnreadCount > 99 ? '99+' : conversationsUnreadCount} + + )} {/* Tooltip - appears to the right */}
{item.label} diff --git a/src/index.css b/src/index.css index 68ce3b159..ab0607de8 100644 --- a/src/index.css +++ b/src/index.css @@ -259,6 +259,15 @@ background-color: rgb(115 115 115); } + /* Hide scrollbar while keeping scroll behavior */ + .scrollbar-hide { + scrollbar-width: none; + -ms-overflow-style: none; + } + .scrollbar-hide::-webkit-scrollbar { + display: none; + } + /* Text selection styling */ .select-primary::selection { @apply bg-primary-600 text-white; diff --git a/src/pages/Conversations.tsx b/src/pages/Conversations.tsx index 44aac4fb2..664fd4907 100644 --- a/src/pages/Conversations.tsx +++ b/src/pages/Conversations.tsx @@ -2,25 +2,35 @@ import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, + useMemo, useRef, useState, } from 'react'; +import Markdown from 'react-markdown'; +import { useNavigate, useParams } from 'react-router-dom'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { addOptimisticMessage, + clearCreateStatus, + clearDeleteStatus, + clearPurgeStatus, clearSelectedThread, + clearSendError, createThread, + deleteThread, + fetchSuggestedQuestions, fetchThreadMessages, fetchThreads, purgeThreads, sendMessage, + setLastViewed, + setPanelWidth, setSelectedThread, } from '../store/threadSlice'; const MIN_PANEL_WIDTH = 200; const MAX_PANEL_WIDTH = 480; -const DEFAULT_PANEL_WIDTH = 320; function formatRelativeTime(dateStr: string): string { const now = Date.now(); @@ -37,23 +47,62 @@ function formatRelativeTime(dateStr: string): string { const Conversations = () => { const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const { threadId: urlThreadId } = useParams<{ threadId?: string }>(); const { threads, isLoading, + error, selectedThreadId, messages, isLoadingMessages, + messagesError, createStatus, + deleteStatus, purgeStatus, sendStatus, sendError, + panelWidth, + lastViewedAt, + suggestedQuestions, + isLoadingSuggestions, } = useAppSelector(state => state.thread); const [showPurgeConfirm, setShowPurgeConfirm] = useState(false); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [inputValue, setInputValue] = useState(''); - const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH); + const [searchQuery, setSearchQuery] = useState(''); + const [copiedMessageId, setCopiedMessageId] = useState(null); const isDragging = useRef(false); const messagesEndRef = useRef(null); + const lastPanelWidthRef = useRef(panelWidth); + + // Filtered threads based on search query (#13) + const filteredThreads = useMemo(() => { + if (!searchQuery.trim()) return threads; + const q = searchQuery.toLowerCase(); + return threads.filter(t => (t.title || 'Untitled Thread').toLowerCase().includes(q)); + }, [threads, searchQuery]); + + // Unread: thread has messages since last view (#15) + const isThreadUnread = useCallback( + (thread: { id: string; lastMessageAt?: string | null; createdAt: string }) => { + const viewed = lastViewedAt[thread.id]; + const lastMsg = new Date(thread.lastMessageAt || thread.createdAt).getTime(); + return viewed == null || lastMsg > viewed; + }, + [lastViewedAt] + ); + + // Mobile: detect small screens for responsive layout (#12) + const [isMobile, setIsMobile] = useState(false); + useEffect(() => { + const mq = window.matchMedia('(max-width: 767px)'); + setIsMobile(mq.matches); + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); const handleResizePointerDown = useCallback( (e: ReactPointerEvent) => { @@ -65,7 +114,8 @@ const Conversations = () => { const onPointerMove = (ev: globalThis.PointerEvent) => { const delta = ev.clientX - startX; const newWidth = Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, startWidth + delta)); - setPanelWidth(newWidth); + lastPanelWidthRef.current = newWidth; + dispatch(setPanelWidth(newWidth)); }; const onPointerUp = () => { @@ -81,7 +131,7 @@ const Conversations = () => { document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }, - [panelWidth] + [panelWidth, dispatch] ); // Fetch threads on mount @@ -89,6 +139,20 @@ const Conversations = () => { dispatch(fetchThreads()); }, [dispatch]); + // Sync URL → Redux: when URL has a threadId param, select that thread + useEffect(() => { + if (urlThreadId && urlThreadId !== selectedThreadId) { + dispatch(setSelectedThread(urlThreadId)); + } else if (!urlThreadId && selectedThreadId) { + dispatch(clearSelectedThread()); + } + }, [urlThreadId]); // eslint-disable-line react-hooks/exhaustive-deps + + // Mark thread as viewed when selected (#15) — stored in Redux (persisted via redux-persist) + useEffect(() => { + if (selectedThreadId) dispatch(setLastViewed(selectedThreadId)); + }, [selectedThreadId, dispatch]); + // Fetch messages when a thread is selected useEffect(() => { if (selectedThreadId) { @@ -96,6 +160,13 @@ const Conversations = () => { } }, [dispatch, selectedThreadId]); + // Fetch suggested questions when thread is empty (beginning of new thread) + useEffect(() => { + if (selectedThreadId && messages.length === 0) { + dispatch(fetchSuggestedQuestions(selectedThreadId)); + } + }, [selectedThreadId, messages.length, dispatch]); + // Auto-scroll to bottom when messages load useEffect(() => { if (messages.length > 0) { @@ -103,25 +174,62 @@ const Conversations = () => { } }, [messages]); + // Clear transient status flags after they settle + useEffect(() => { + if (createStatus === 'success' || createStatus === 'error') { + dispatch(clearCreateStatus()); + } + }, [createStatus, dispatch]); + + useEffect(() => { + if (deleteStatus === 'success' || deleteStatus === 'error') { + dispatch(clearDeleteStatus()); + } + }, [deleteStatus, dispatch]); + + useEffect(() => { + if (purgeStatus === 'success' || purgeStatus === 'error') { + dispatch(clearPurgeStatus()); + } + }, [purgeStatus, dispatch]); + + // Clear send error when user starts typing again + useEffect(() => { + if (sendError && inputValue.length > 0) { + dispatch(clearSendError()); + } + }, [inputValue, sendError, dispatch]); + const handleSelectThread = (threadId: string) => { if (threadId === selectedThreadId) return; - dispatch(setSelectedThread(threadId)); + navigate(`/conversations/${threadId}`, { replace: true }); }; - const handleNewThread = () => { - dispatch(createThread(undefined)); + const handleNewThread = async () => { + const result = await dispatch(createThread(undefined)); + if (createThread.fulfilled.match(result)) { + navigate(`/conversations/${result.payload.id}`, { replace: true }); + } + }; + + const handleDeleteThread = async (threadId: string) => { + const result = await dispatch(deleteThread(threadId)); + setConfirmDeleteId(null); + if (deleteThread.fulfilled.match(result) && threadId === selectedThreadId) { + navigate('/conversations', { replace: true }); + } }; const handlePurge = async () => { const result = await dispatch(purgeThreads()); if (purgeThreads.fulfilled.match(result)) { setShowPurgeConfirm(false); - dispatch(clearSelectedThread()); + navigate('/conversations', { replace: true }); } }; - const handleSendMessage = () => { - const trimmed = inputValue.trim(); + const handleSendMessage = (text?: string) => { + const trimmed = text ?? inputValue.trim(); if (!trimmed || !selectedThreadId || sendStatus === 'loading') return; dispatch(addOptimisticMessage({ content: trimmed })); setInputValue(''); @@ -135,91 +243,556 @@ const Conversations = () => { } }; + // Copy message to clipboard (#10) + const handleCopyMessage = async (messageId: string, content: string) => { + try { + await navigator.clipboard.writeText(content); + setCopiedMessageId(messageId); + setTimeout(() => setCopiedMessageId(null), 1500); + } catch { + // Clipboard API not available — silently fail + } + }; + + // Mobile: back to thread list + const handleMobileBack = () => { + navigate('/conversations', { replace: true }); + }; + const selectedThread = threads.find(t => t.id === selectedThreadId); + // Mobile layout: show only one panel at a time (#12) + const showThreadList = !isMobile || !selectedThreadId; + const showMessages = !isMobile || !!selectedThreadId; + return (
{/* Left Panel: Thread List */} -
- {/* Header */} -
-

Conversations

- -
+ {showThreadList && ( +
+ {/* Header */} +
+

Conversations

+ +
- {/* Thread list */} -
- {isLoading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( -
- ))} + {/* Search bar (#13) */} + {threads.length > 0 && ( +
+
+ + + + setSearchQuery(e.target.value)} + placeholder="Search threads..." + className="w-full bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all" + /> + {searchQuery && ( + + )} +
- ) : threads.length > 0 ? ( -
- {threads.map(thread => ( + )} + + {/* Thread list */} +
+ {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : error ? ( +
+ + + +

Failed to load conversations

+

{error}

- ))} +
+ ) : filteredThreads.length > 0 ? ( +
+ {filteredThreads.map(thread => ( +
+ {confirmDeleteId === thread.id ? ( +
+ + Delete this thread? + +
+ + +
+
+ ) : ( + <> + + {/* Delete button — visible on hover */} + + + )} +
+ ))} +
+ ) : threads.length > 0 && searchQuery ? ( +
+

No matching threads

+ +
+ ) : ( +
+ + + +

No conversations yet

+ +
+ )} +
+ + {/* Footer: Delete All */} + {threads.length > 0 && ( +
+ {showPurgeConfirm ? ( +
+ Delete all threads? +
+ + +
+
+ ) : ( + + )}
+ )} +
+ )} + + {/* Resize Handle — desktop only */} + {!isMobile && ( +
+ )} + + {/* Right Panel: Messages */} + {showMessages && ( +
+ {selectedThread ? ( + <> + {/* Thread header */} +
+ {/* Mobile back button (#12) */} + {isMobile && ( + + )} +
+
+

+ {selectedThread.title || 'Untitled Thread'} +

+ {selectedThread.isActive && ( + + Active + + )} +
+

+ Created {formatRelativeTime(selectedThread.createdAt)} +

+
+
+ + {/* Messages */} +
+ {isLoadingMessages ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+ ))} +
+ ) : messagesError ? ( +
+ + + +

Failed to load messages

+

{messagesError}

+ +
+ ) : messages.length > 0 ? ( +
+ {messages.map(msg => ( +
+
+
+ {msg.sender === 'agent' ? ( +
+ {msg.content} +
+ ) : ( +

+ {msg.content} +

+ )} +

+ {formatRelativeTime(msg.createdAt)} +

+
+ {/* Copy button (#10) */} + +
+
+ ))} + {/* Typing indicator (#14) */} + {sendStatus === 'loading' && ( +
+
+
+ + + +
+
+
+ )} +
+
+ ) : ( +
+

No messages in this thread

+
+ )} +
+ + {/* Suggested questions — only at start of new thread (no messages yet); horizontal scroll */} + {messages.length === 0 && suggestedQuestions.length > 0 && !isLoadingSuggestions && ( +
+
+ {suggestedQuestions.map((s, i) => ( + + ))} +
+
+ )} + + {/* Message Input */} +
+ {sendError && ( +
+

{sendError}

+ +
+ )} +
+