diff --git a/ui/src/bichat/components/AllChatsList.tsx b/ui/src/bichat/components/AllChatsList.tsx index 1e13ba2..5eaba46 100644 --- a/ui/src/bichat/components/AllChatsList.tsx +++ b/ui/src/bichat/components/AllChatsList.tsx @@ -5,8 +5,8 @@ */ import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; -import { motion } from 'framer-motion'; -import { Archive } from '@phosphor-icons/react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Archive, CaretRight } from '@phosphor-icons/react'; import { UserAvatar } from './UserAvatar'; import { UserFilter } from './UserFilter'; import SessionSkeleton from './SessionSkeleton'; @@ -140,6 +140,71 @@ export default function AllChatsList({ dataSource, onSessionSelect, activeSessio return Array.from(userMap.values()); }, [chats, users, dataSource.listUsers]); + // Expanded state for user groups (tracks expanded owner IDs; collapsed by default) + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + + const toggleGroup = useCallback((ownerId: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(ownerId)) { + next.delete(ownerId); + } else { + next.add(ownerId); + } + return next; + }); + }, []); + + // Auto-expand the group containing the active session (e.g. deep-link) + useEffect(() => { + if (!activeSessionId || selectedUser) { return; } + const chat = chats.find(c => c.id === activeSessionId); + if (chat?.owner?.id) { + setExpandedGroups(prev => { + if (prev.has(chat.owner!.id)) { return prev; } + return new Set([...prev, chat.owner!.id]); + }); + } + }, [activeSessionId, chats, selectedUser]); + + // Group chats by owner when no specific user is selected + const groupedChats = useMemo(() => { + if (selectedUser) {return null;} // flat list when user is selected + + const groupMap = new Map(); + + chats.forEach((chat) => { + const owner = chat.owner ?? { + id: '__unknown__', + firstName: t('BiChat.Common.Untitled'), + lastName: '', + initials: '?', + }; + const ownerId = owner.id; + + if (!groupMap.has(ownerId)) { + groupMap.set(ownerId, { + owner, + chats: [], + latestUpdatedAt: chat.updatedAt, + }); + } + + const group = groupMap.get(ownerId)!; + group.chats.push(chat); + + // Track the most recent updatedAt for sorting groups + if (chat.updatedAt > group.latestUpdatedAt) { + group.latestUpdatedAt = chat.updatedAt; + } + }); + + // Sort groups by most recently active first + return Array.from(groupMap.values()).sort( + (a, b) => b.latestUpdatedAt.localeCompare(a.latestUpdatedAt), + ); + }, [chats, selectedUser, t]); + return (
- {chats.map((chat) => { - const owner = chat.owner ?? { - id: '', - firstName: '', - lastName: '', - initials: 'U', - }; - const ownerName = [owner.firstName, owner.lastName].filter(Boolean).join(' '); - return ( - -
onSessionSelect(chat.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onSessionSelect(chat.id); - } - }} - className={` - block px-3 py-2 rounded-lg transition-smooth group cursor-pointer - ${ - chat.id === activeSessionId - ? 'bg-primary-50/50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400 border-l-4 border-primary-400 dark:border-primary-600' - : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 border-l-4 border-transparent' - } - `} - aria-current={chat.id === activeSessionId ? 'page' : undefined} - > -
- {/* Owner avatar */} - - - {/* Chat info */} -
-

- {chat.title || t('BiChat.Common.Untitled')} -

- {ownerName && ( -

- {ownerName} -

- )} - {chat.status === 'archived' && ( - - - {t('BiChat.Chat.Archived')} - - )} + {groupedChats ? ( + /* ── Grouped view (no user selected) ── */ + groupedChats.map((group) => { + const ownerId = group.owner.id; + const ownerName = [group.owner.firstName, group.owner.lastName].filter(Boolean).join(' '); + const isCollapsed = !expandedGroups.has(ownerId); + + return ( +
+ {/* Group header */} +
toggleGroup(ownerId)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleGroup(ownerId); + } + }} + className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-smooth select-none" + aria-expanded={!isCollapsed} + > + + + + {ownerName || t('BiChat.Common.Untitled')} + + + {group.chats.length} +
+ + {/* Group items */} + + {!isCollapsed && ( + +
+ {group.chats.map((chat) => ( + +
onSessionSelect(chat.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSessionSelect(chat.id); + } + }} + className={` + block px-3 py-2 rounded-lg transition-smooth group cursor-pointer + ${ + chat.id === activeSessionId + ? 'bg-primary-50/50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400 border-l-4 border-primary-400 dark:border-primary-600' + : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 border-l-4 border-transparent' + } + `} + aria-current={chat.id === activeSessionId ? 'page' : undefined} + > +
+

+ {chat.title || t('BiChat.Common.Untitled')} +

+
+ {chat.isGroup && chat.memberCount && chat.memberCount > 1 && ( + + {chat.memberCount} + + )} + {chat.status === 'archived' && ( + + + {t('BiChat.Chat.Archived')} + + )} +
+
+
+
+ ))} +
+
+ )} +
-
- - ); - })} + ); + }) + ) : ( + /* ── Flat view (user selected) ── */ + chats.map((chat) => { + const owner = chat.owner ?? { + id: '', + firstName: '', + lastName: '', + initials: 'U', + }; + const ownerName = [owner.firstName, owner.lastName].filter(Boolean).join(' '); + return ( + +
onSessionSelect(chat.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSessionSelect(chat.id); + } + }} + className={` + block px-3 py-2 rounded-lg transition-smooth group cursor-pointer + ${ + chat.id === activeSessionId + ? 'bg-primary-50/50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400 border-l-4 border-primary-400 dark:border-primary-600' + : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 border-l-4 border-transparent' + } + `} + aria-current={chat.id === activeSessionId ? 'page' : undefined} + > +
+ {/* Owner avatar */} + + + {/* Chat info */} +
+

+ {chat.title || t('BiChat.Common.Untitled')} +

+ {ownerName && ( +

+ {ownerName} +

+ )} + {chat.status === 'archived' && ( + + + {t('BiChat.Chat.Archived')} + + )} +
+
+
+
+ ); + }) + )} {/* Load more trigger */} {hasMore && ( diff --git a/ui/src/bichat/components/Sidebar.tsx b/ui/src/bichat/components/Sidebar.tsx index 5f34cf5..649a306 100644 --- a/ui/src/bichat/components/Sidebar.tsx +++ b/ui/src/bichat/components/Sidebar.tsx @@ -112,7 +112,7 @@ function useSidebarCollapse() { return { isCollapsed, isCollapsedRef, toggle, expand, collapse }; } -type ActiveTab = 'my-chats' | 'all-chats' +export type ActiveTab = 'my-chats' | 'all-chats' export interface SidebarProps { dataSource: ChatDataSource @@ -127,6 +127,10 @@ export interface SidebarProps { headerSlot?: React.ReactNode footerSlot?: React.ReactNode className?: string + /** Controlled active tab. When provided, overrides internal state. */ + activeTab?: ActiveTab + /** Called when tab changes. Use with activeTab for controlled mode. */ + onTabChange?: (tab: ActiveTab) => void } export default function Sidebar({ @@ -142,6 +146,8 @@ export default function Sidebar({ headerSlot, footerSlot, className = '', + activeTab: controlledActiveTab, + onTabChange, }: SidebarProps) { const { t } = useTranslation(); const toast = useToast(); @@ -209,8 +215,15 @@ export default function Sidebar({ return () => clearTimeout(timer); }, [showCollapsed]); - // View state (my chats vs all chats) - const [activeTab, setActiveTab] = useState('my-chats'); + // View state (my chats vs all chats) — controlled or uncontrolled + const [internalActiveTab, setInternalActiveTab] = useState('my-chats'); + const activeTab = controlledActiveTab ?? internalActiveTab; + const handleTabChange = useCallback((tab: ActiveTab) => { + if (controlledActiveTab === undefined) { + setInternalActiveTab(tab); + } + onTabChange?.(tab); + }, [controlledActiveTab, onTabChange]); // Search state const [searchQuery, setSearchQuery] = useState(''); @@ -936,7 +949,7 @@ export default function Sidebar({ onClick={(e) => { e.preventDefault(); e.stopPropagation(); - setActiveTab('all-chats'); + handleTabChange('all-chats'); close(); }} className={`cursor-pointer flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-[13px] text-gray-600 dark:text-gray-300 transition-colors ${ @@ -957,7 +970,7 @@ export default function Sidebar({ onClick={(e) => { e.preventDefault(); e.stopPropagation(); - setActiveTab('my-chats'); + handleTabChange('my-chats'); close(); }} className={`cursor-pointer flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-[13px] text-gray-600 dark:text-gray-300 transition-colors ${ diff --git a/ui/src/bichat/components/UserAvatar.tsx b/ui/src/bichat/components/UserAvatar.tsx index 2b1da20..ae70e58 100644 --- a/ui/src/bichat/components/UserAvatar.tsx +++ b/ui/src/bichat/components/UserAvatar.tsx @@ -33,20 +33,21 @@ function hashString(str: string): number { } /** - * Color palette using Tailwind colors - * Selected for good contrast with white text + * Color palette using hex values for inline styles. + * Inline styles ensure colors render correctly in portaled content + * (e.g. Headless UI dropdowns) outside the shadow DOM. */ const colorPalette = [ - { bg: 'bg-blue-500', text: 'text-white' }, - { bg: 'bg-green-500', text: 'text-white' }, - { bg: 'bg-purple-500', text: 'text-white' }, - { bg: 'bg-pink-500', text: 'text-white' }, - { bg: 'bg-indigo-500', text: 'text-white' }, - { bg: 'bg-teal-500', text: 'text-white' }, - { bg: 'bg-orange-500', text: 'text-white' }, - { bg: 'bg-cyan-500', text: 'text-white' }, - { bg: 'bg-amber-500', text: 'text-white' }, - { bg: 'bg-lime-500', text: 'text-white' }, + { bg: '#3b82f6', text: '#ffffff' }, // blue-500 + { bg: '#22c55e', text: '#111827' }, // green-500 (light bg) + { bg: '#a855f7', text: '#ffffff' }, // purple-500 + { bg: '#ec4899', text: '#ffffff' }, // pink-500 + { bg: '#6366f1', text: '#ffffff' }, // indigo-500 + { bg: '#14b8a6', text: '#111827' }, // teal-500 (light bg) + { bg: '#f97316', text: '#ffffff' }, // orange-500 + { bg: '#06b6d4', text: '#111827' }, // cyan-500 (light bg) + { bg: '#f59e0b', text: '#111827' }, // amber-500 (light bg) + { bg: '#84cc16', text: '#111827' }, // lime-500 (light bg) ]; /** @@ -85,8 +86,6 @@ function UserAvatar({
diff --git a/ui/src/bichat/hooks/useBichatRouter.ts b/ui/src/bichat/hooks/useBichatRouter.ts index c6f6916..98ac1bd 100644 --- a/ui/src/bichat/hooks/useBichatRouter.ts +++ b/ui/src/bichat/hooks/useBichatRouter.ts @@ -29,6 +29,12 @@ export interface UseBichatRouterReturn { onArchivedView: () => void; /** Navigate back (e.g. to home) */ onBack: () => void; + /** Navigate to all-chats view */ + onAllChatsView: () => void; + /** Current sidebar tab derived from URL */ + sidebarTab: 'my-chats' | 'all-chats'; + /** Handler to change sidebar tab (navigates to appropriate URL) */ + onSidebarTabChange: (tab: 'my-chats' | 'all-chats') => void; } const SESSION_PATH_REGEX = /\/session\/([^/]+)/; @@ -43,11 +49,18 @@ export function useBichatRouter({ pathname, onNavigate, }: UseBichatRouterParams): UseBichatRouterReturn { + const isAllChats = pathname.startsWith('/all-chats'); + const activeSessionId = useMemo( () => pathname.match(SESSION_PATH_REGEX)?.[1], [pathname] ); + const sidebarTab = useMemo<'my-chats' | 'all-chats'>( + () => (isAllChats ? 'all-chats' : 'my-chats'), + [isAllChats] + ); + const maybeClose = useCallback(() => { onNavigate?.(); }, [onNavigate]); @@ -55,35 +68,56 @@ export function useBichatRouter({ const onSessionSelect = useCallback( (sessionId: string) => { if (sessionId) { - navigate(`/session/${sessionId}`); + const prefix = isAllChats ? '/all-chats' : ''; + navigate(`${prefix}/session/${sessionId}`); } else { - navigate('/'); + navigate(isAllChats ? '/all-chats' : '/'); } maybeClose(); }, - [navigate, maybeClose] + [navigate, maybeClose, isAllChats] ); const onNewChat = useCallback(() => { - navigate('/'); + navigate(isAllChats ? '/all-chats' : '/'); maybeClose(); - }, [navigate, maybeClose]); + }, [navigate, maybeClose, isAllChats]); const onArchivedView = useCallback(() => { navigate('/archived'); maybeClose(); }, [navigate, maybeClose]); + const onAllChatsView = useCallback(() => { + navigate('/all-chats'); + maybeClose(); + }, [navigate, maybeClose]); + const onBack = useCallback(() => { navigate('/'); maybeClose(); }, [navigate, maybeClose]); + const onSidebarTabChange = useCallback( + (tab: 'my-chats' | 'all-chats') => { + if (tab === 'all-chats') { + navigate('/all-chats'); + } else { + navigate('/'); + } + maybeClose(); + }, + [navigate, maybeClose] + ); + return { activeSessionId, onSessionSelect, onNewChat, onArchivedView, onBack, + onAllChatsView, + sidebarTab, + onSidebarTabChange, }; } diff --git a/ui/src/bichat/index.ts b/ui/src/bichat/index.ts index 2c5c8a1..c3e7b94 100644 --- a/ui/src/bichat/index.ts +++ b/ui/src/bichat/index.ts @@ -78,7 +78,7 @@ export { ActivityTrace, type ActivityTraceProps } from './components/ActivityTra // Session management components export { default as Sidebar } from './components/Sidebar'; -export type { SidebarProps } from './components/Sidebar'; +export type { SidebarProps, ActiveTab } from './components/Sidebar'; export { default as SessionItem } from './components/SessionItem'; export { default as ArchivedChatList } from './components/ArchivedChatList'; export { default as AllChatsList } from './components/AllChatsList';