diff --git a/package-lock.json b/package-lock.json index 1e143341..51b7bfb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,9 @@ "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.53.0", "@tailwindcss/typography": "^0.5.16", + "@tanstack/query-async-storage-persister": "^5.83.1", "@tanstack/react-query": "^5.83.0", + "@tanstack/react-query-persist-client": "^5.85.0", "@vercel/analytics": "^1.5.0", "axios": "^1.10.0", "class-variance-authority": "^0.7.1", @@ -2522,6 +2524,19 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@tanstack/query-async-storage-persister": { + "version": "5.83.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.83.1.tgz", + "integrity": "sha512-P3QzGBn9/nxaCst1hNYDQbbJDKFxTBsGTMjf0YtsKs7sSeNp7wVgUKjOVJpbvhdpFKHqTDYOdjOf2Qeb4bJ9tQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-persist-client-core": "5.83.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "5.83.1", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.1.tgz", @@ -2543,10 +2558,23 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/query-persist-client-core": { + "version": "5.83.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.83.1.tgz", + "integrity": "sha512-GPWt1tj8kmo3LA1WPpSmJA3JGCdQfaggb1LheFEfr3RuwbTchWd09xD/fZ40m9ai0pJupvyguLiWF8On8sQWPw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.83.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/react-query": { - "version": "5.84.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.84.2.tgz", - "integrity": "sha512-cZadySzROlD2+o8zIfbD978p0IphuQzRWiiH3I2ugnTmz4jbjc0+TdibpwqxlzynEen8OulgAg+rzdNF37s7XQ==", + "version": "5.85.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.0.tgz", + "integrity": "sha512-t1HMfToVMGfwEJRya6GG7gbK0luZJd+9IySFNePL1BforU1F3LqQ3tBC2Rpvr88bOrlU6PXyMLgJD0Yzn4ztUw==", "license": "MIT", "dependencies": { "@tanstack/query-core": "5.83.1" @@ -2577,6 +2605,23 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-query-persist-client": { + "version": "5.85.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.85.0.tgz", + "integrity": "sha512-ZzkOwWa1k9KNCjiet70Kg447qCxImmXsRdYuIHuc3ctgyq+o8n4RXDfza23tA0riM1iR5ouYeSRVoDZNegXZ/w==", + "license": "MIT", + "dependencies": { + "@tanstack/query-persist-client-core": "5.83.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.85.0", + "react": "^18 || ^19" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", diff --git a/package.json b/package.json index 247e7d4e..bcd436fa 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,9 @@ "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.53.0", "@tailwindcss/typography": "^0.5.16", + "@tanstack/query-async-storage-persister": "^5.83.1", "@tanstack/react-query": "^5.83.0", + "@tanstack/react-query-persist-client": "^5.85.0", "@vercel/analytics": "^1.5.0", "axios": "^1.10.0", "class-variance-authority": "^0.7.1", diff --git a/src/app/[workspace-id]/client-layout.tsx b/src/app/[workspace-id]/client-layout.tsx new file mode 100644 index 00000000..5848fc91 --- /dev/null +++ b/src/app/[workspace-id]/client-layout.tsx @@ -0,0 +1,233 @@ +'use client'; + +import { Bell, X } from 'lucide-react'; +import { usePathname } from 'next/navigation'; +import { type ReactNode, useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +import { MobileBottomNav } from '@/components/mobile/mobile-bottom-nav'; +import { ProfilePanel } from '@/components/profile-panel'; +import { NotificationsSidebar } from '@/components/side-nav/notifications-sidebar'; +import { Sidebar } from '@/components/side-nav/sidebar'; +import { WorkspaceSidebar } from '@/components/side-nav/workspace-sidebar'; +import { Toolbar } from '@/components/toolbar'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; +import { useCurrentUser } from '@/features/auth'; +import { Thread } from '@/features/messages/component/thread'; +import { useNotificationPermissions } from '@/features/notifications/hooks/use-notification-permissions'; +import { useRealtimeNotifications } from '@/features/notifications/hooks/use-realtime-notifications'; +import { usePresence } from '@/hooks/use-presence'; +import { useWorkspaceId } from '@/hooks/use-workspace-id'; +import { useUIStore } from '@/stores/ui-store'; + +interface WorkspaceIdClientLayoutProps { + children: ReactNode; +} + +export const WorkspaceIdClientLayout = ({ children }: WorkspaceIdClientLayoutProps) => { + const { + setThreadOpen, + isThreadOpen, + openThreadMessageId, + isNotificationsPanelOpen, + setNotificationsPanelOpen, + isProfilePanelOpen, + profileMemberId, + } = useUIStore(); + + const workspaceId = useWorkspaceId(); + const { user, isLoading: isUserLoading } = useCurrentUser(workspaceId); + const [showPermissionBanner, setShowPermissionBanner] = useState(false); + const pathname = usePathname(); + + useRealtimeNotifications({ + workspaceMemberId: user?.workspace_member_id || '', + workspaceId: workspaceId || '', + enabled: !!user?.workspace_member_id && !!workspaceId, + }); + + usePresence({ + workspaceId: workspaceId || '', + userId: user?.id || '', + workspaceMemberId: user?.workspace_member_id || '', + enabled: !isUserLoading && !!user?.id && !!user?.workspace_member_id && !!workspaceId, + }); + + const { permission, requestPermission, isSupported, hasAskedBefore, setHasAskedBefore } = + useNotificationPermissions(); + + useEffect(() => { + // Show banner immediately when: + // 1. Notifications are supported + // 2. We have a user (notifications will start) + // 3. Permission is still default (not granted/denied) + // 4. We haven't asked before in this session + if (isSupported && user?.workspace_member_id && permission === 'default' && !hasAskedBefore) { + setShowPermissionBanner(true); + } else { + setShowPermissionBanner(false); + } + }, [isSupported, user?.workspace_member_id, permission, hasAskedBefore]); + + const handleEnableNotifications = async () => { + const result = await requestPermission(); + setShowPermissionBanner(false); + + if (result === 'granted') { + // You could show a success toast here + toast.success('Notifications enabled!'); + } else if (result === 'denied') { + // Ensure we don't show the banner again even if denied + localStorage.setItem('notification_permission_asked', 'true'); + setHasAskedBefore(true); + } + }; + + const handleDismissBanner = () => { + setShowPermissionBanner(false); + localStorage.setItem('notification_permission_asked', 'true'); + setHasAskedBefore(true); + }; + + const handleCloseNotifications = () => { + setNotificationsPanelOpen(false); + }; + + // Check if we're in a channel, DM, or agent conversation + const isInConversation = + pathname && + (pathname.includes('/c-') || pathname.includes('/d-') || pathname.includes('/agents')); + return ( +
+ {/* Desktop Toolbar */} +
+ +
+ + {/* Notification Permission Banner */} + {showPermissionBanner && ( +
+ + + + Enable Desktop Notifications + Enable Notifications + + + + + Get notified when you receive messages while away from the app + + Get notified when you receive messages + +
+ + +
+
+ +
+
+ )} + + {/* Desktop Layout */} +
+ + + + {isNotificationsPanelOpen && workspaceId ? ( + + ) : ( + + )} + + + + {children} + + {isThreadOpen() && openThreadMessageId && ( + <> + + + setThreadOpen(null)} /> + + + )} + {isProfilePanelOpen() && profileMemberId && ( + <> + + + + + + )} + +
+ + {/* Mobile Layout */} +
+ {/* Main content area with padding for fixed header and nav */} +
+ {children} +
+ + {/* Thread panel slides in from right on mobile */} + {isThreadOpen() && openThreadMessageId && ( +
+
+ setThreadOpen(null)} /> +
+
+ )} + + {/* Profile panel as full-screen overlay on mobile */} + {isProfilePanelOpen() && profileMemberId && ( +
+
+ +
+
+ )} + + {/* Notifications as full-screen overlay on mobile */} + {isNotificationsPanelOpen && workspaceId && ( +
+
+ +
+
+ )} + + {!isInConversation && } +
+
+ ); +}; + +export default WorkspaceIdClientLayout; diff --git a/src/app/[workspace-id]/layout.tsx b/src/app/[workspace-id]/layout.tsx index 84ccb5a0..74b972eb 100644 --- a/src/app/[workspace-id]/layout.tsx +++ b/src/app/[workspace-id]/layout.tsx @@ -1,233 +1,20 @@ -'use client'; - -import { Bell, X } from 'lucide-react'; -import { usePathname } from 'next/navigation'; -import { type ReactNode, useEffect, useState } from 'react'; -import { toast } from 'sonner'; - -import { MobileBottomNav } from '@/components/mobile/mobile-bottom-nav'; -import { ProfilePanel } from '@/components/profile-panel'; -import { NotificationsSidebar } from '@/components/side-nav/notifications-sidebar'; -import { Sidebar } from '@/components/side-nav/sidebar'; -import { WorkspaceSidebar } from '@/components/side-nav/workspace-sidebar'; -import { Toolbar } from '@/components/toolbar'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Button } from '@/components/ui/button'; -import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; -import { useCurrentUser } from '@/features/auth'; -import { Thread } from '@/features/messages/component/thread'; -import { useNotificationPermissions } from '@/features/notifications/hooks/use-notification-permissions'; -import { useRealtimeNotifications } from '@/features/notifications/hooks/use-realtime-notifications'; -import { usePresence } from '@/hooks/use-presence'; -import { useWorkspaceId } from '@/hooks/use-workspace-id'; -import { useUIStore } from '@/stores/ui-store'; +import { ReactNode } from 'react'; +import { ServerLayout } from './server-layout'; +import { WorkspaceIdClientLayout } from './client-layout'; interface WorkspaceIdLayoutProps { children: ReactNode; + params: Promise<{ 'workspace-id': string }>; } -const WorkspaceIdLayout = ({ children }: WorkspaceIdLayoutProps) => { - const { - setThreadOpen, - isThreadOpen, - openThreadMessageId, - isNotificationsPanelOpen, - setNotificationsPanelOpen, - isProfilePanelOpen, - profileMemberId, - } = useUIStore(); - - const workspaceId = useWorkspaceId(); - const { user, isLoading: isUserLoading } = useCurrentUser(workspaceId); - const [showPermissionBanner, setShowPermissionBanner] = useState(false); - const pathname = usePathname(); - - useRealtimeNotifications({ - workspaceMemberId: user?.workspace_member_id || '', - workspaceId: workspaceId || '', - enabled: !!user?.workspace_member_id && !!workspaceId, - }); - - usePresence({ - workspaceId: workspaceId || '', - userId: user?.id || '', - workspaceMemberId: user?.workspace_member_id || '', - enabled: !isUserLoading && !!user?.id && !!user?.workspace_member_id && !!workspaceId, - }); - - const { permission, requestPermission, isSupported, hasAskedBefore, setHasAskedBefore } = - useNotificationPermissions(); - - useEffect(() => { - // Show banner immediately when: - // 1. Notifications are supported - // 2. We have a user (notifications will start) - // 3. Permission is still default (not granted/denied) - // 4. We haven't asked before in this session - if (isSupported && user?.workspace_member_id && permission === 'default' && !hasAskedBefore) { - setShowPermissionBanner(true); - } else { - setShowPermissionBanner(false); - } - }, [isSupported, user?.workspace_member_id, permission, hasAskedBefore]); - - const handleEnableNotifications = async () => { - const result = await requestPermission(); - setShowPermissionBanner(false); - - if (result === 'granted') { - // You could show a success toast here - toast.success('Notifications enabled!'); - } else if (result === 'denied') { - // Ensure we don't show the banner again even if denied - localStorage.setItem('notification_permission_asked', 'true'); - setHasAskedBefore(true); - } - }; - - const handleDismissBanner = () => { - setShowPermissionBanner(false); - localStorage.setItem('notification_permission_asked', 'true'); - setHasAskedBefore(true); - }; +export default async function WorkspaceIdLayout({ children, params }: WorkspaceIdLayoutProps) { + const { 'workspace-id': workspaceId } = await params; - const handleCloseNotifications = () => { - setNotificationsPanelOpen(false); - }; - - // Check if we're in a channel, DM, or agent conversation - const isInConversation = - pathname && - (pathname.includes('/c-') || pathname.includes('/d-') || pathname.includes('/agents')); return ( -
- {/* Desktop Toolbar */} -
- -
- - {/* Notification Permission Banner */} - {showPermissionBanner && ( -
- - - - Enable Desktop Notifications - Enable Notifications - - - - - Get notified when you receive messages while away from the app - - Get notified when you receive messages - -
- - -
-
- -
-
- )} - - {/* Desktop Layout */} -
- - - - {isNotificationsPanelOpen && workspaceId ? ( - - ) : ( - - )} - - - - {children} - - {isThreadOpen() && openThreadMessageId && ( - <> - - - setThreadOpen(null)} /> - - - )} - {isProfilePanelOpen() && profileMemberId && ( - <> - - - - - - )} - -
- - {/* Mobile Layout */} -
- {/* Main content area with padding for fixed header and nav */} -
- {children} -
- - {/* Thread panel slides in from right on mobile */} - {isThreadOpen() && openThreadMessageId && ( -
-
- setThreadOpen(null)} /> -
-
- )} - - {/* Profile panel as full-screen overlay on mobile */} - {isProfilePanelOpen() && profileMemberId && ( -
-
- -
-
- )} - - {/* Notifications as full-screen overlay on mobile */} - {isNotificationsPanelOpen && workspaceId && ( -
-
- -
-
- )} - - {!isInConversation && } -
-
+ + + {children} + + ); -}; - -export default WorkspaceIdLayout; +} \ No newline at end of file diff --git a/src/app/[workspace-id]/page.tsx b/src/app/[workspace-id]/page.tsx index bca658dd..08330800 100644 --- a/src/app/[workspace-id]/page.tsx +++ b/src/app/[workspace-id]/page.tsx @@ -21,16 +21,19 @@ const WorkspaceIdPage = () => { const { data: currentMember, isLoading: isMemberLoading, + isFetching: isMemberFetching, error: memberError, } = useCurrentMember(workspaceId); const { data: workspace, isLoading: isWorkspaceLoading, + isFetching: isWorkspaceFetching, error: workspaceError, } = useGetWorkspace(workspaceId); const { data: channels, isLoading: isChannelsLoading, + isFetching: isChannelsFetching, error: channelsError, } = useGetUserChannels(workspaceId); @@ -40,7 +43,11 @@ const WorkspaceIdPage = () => { return currentMember?.role === 'admin'; }, [currentMember]); - const isLoading = isWorkspaceLoading || isChannelsLoading || isMemberLoading; + // Only show loading when we have no data AND are fetching + const isLoading = + (!workspace && (isWorkspaceLoading || isWorkspaceFetching)) || + (!channels && (isChannelsLoading || isChannelsFetching)) || + (!currentMember && (isMemberLoading || isMemberFetching)); const hasError = workspaceError || channelsError || memberError; // Note: Desktop-only redirect logic @@ -73,7 +80,8 @@ const WorkspaceIdPage = () => { ); } - if (hasError || !workspace || !currentMember) { + // Only show error state if we're not loading and there's an actual error or no data after loading + if (!isLoading && (hasError || !workspace || !currentMember)) { return (
diff --git a/src/app/[workspace-id]/server-layout.tsx b/src/app/[workspace-id]/server-layout.tsx new file mode 100644 index 00000000..ae0c3492 --- /dev/null +++ b/src/app/[workspace-id]/server-layout.tsx @@ -0,0 +1,93 @@ +import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'; +import { ReactNode } from 'react'; + +import { agentsApi } from '@/features/agents/api/agents-api'; +import { agentsQueryKeys } from '@/features/agents/query-keys'; +import { authApi } from '@/features/auth/api/auth-api'; +import { authQueryKeys } from '@/features/auth/query-keys'; +import { channelsApi } from '@/features/channels/api/channels-api'; +import { channelsQueryKeys } from '@/features/channels/query-keys'; +import { conversationsApi } from '@/features/conversations/api/conversations-api'; +import { conversationsQueryKeys } from '@/features/conversations/query-keys'; +import { membersApi } from '@/features/members/api/members-api'; +import { membersQueryKeys } from '@/features/members/query-keys'; +import { workspacesApi } from '@/features/workspaces/api/workspaces-api'; +import { workspacesQueryKeys } from '@/features/workspaces/query-keys'; + +interface ServerLayoutProps { + children: ReactNode; + workspaceId: string; +} + +/** + * Server component that prefetches all workspace data + */ +export async function ServerLayout({ children, workspaceId }: ServerLayoutProps) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 60 * 24, // 24 hours + }, + }, + }); + + try { + // Prefetch all workspace data in parallel + await Promise.allSettled([ + // Prefetch all workspaces (for instant workspace switching) + queryClient.prefetchQuery({ + queryKey: workspacesQueryKeys.workspaces(), + queryFn: () => workspacesApi.getWorkspaces(), + staleTime: 4 * 60 * 60 * 1000, // 4 hours + }), + + // Prefetch current workspace + queryClient.prefetchQuery({ + queryKey: workspacesQueryKeys.workspace(workspaceId), + queryFn: () => workspacesApi.getWorkspace(workspaceId), + staleTime: 4 * 60 * 60 * 1000, // 4 hours + }), + + // Prefetch user channels + queryClient.prefetchQuery({ + queryKey: channelsQueryKeys.userChannels(workspaceId, null), + queryFn: () => channelsApi.getUserChannels(workspaceId), + staleTime: 2 * 60 * 60 * 1000, // 2 hours + }), + + // Prefetch conversations + queryClient.prefetchQuery({ + queryKey: conversationsQueryKeys.conversations(workspaceId), + queryFn: () => conversationsApi.getConversations(workspaceId), + staleTime: 30 * 60 * 1000, // 30 minutes + }), + + // Prefetch agents + queryClient.prefetchQuery({ + queryKey: agentsQueryKeys.agents(workspaceId), + queryFn: () => agentsApi.getAgents(workspaceId), + staleTime: 2 * 60 * 60 * 1000, // 2 hours + }), + + // Prefetch workspace members (for member-related features) + queryClient.prefetchQuery({ + queryKey: membersQueryKeys.members(workspaceId), + queryFn: () => membersApi.getMembers(workspaceId), + staleTime: 1 * 60 * 60 * 1000, // 1 hour - members change more frequently + }), + + // Prefetch current user + queryClient.prefetchQuery({ + queryKey: authQueryKeys.currentUser(), + queryFn: () => authApi.getCurrentUser(workspaceId), + staleTime: 30 * 60 * 1000, // 30 minutes + }), + ]); + } catch (error) { + // Log error but don't crash - client will handle loading states + console.warn(`⚠️ Server prefetch failed for workspace ${workspaceId}:`, error); + } + + return {children}; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ffd83bf8..1d159611 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -38,7 +38,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac return ( - + diff --git a/src/components/chat/header.tsx b/src/components/chat/header.tsx index c367869d..cc2547d1 100644 --- a/src/components/chat/header.tsx +++ b/src/components/chat/header.tsx @@ -3,23 +3,22 @@ import { useRouter } from 'next/navigation'; import { type FC, useMemo, useState } from 'react'; import { toast } from 'sonner'; -import { useUIStore } from '@/stores/ui-store'; - import { ChannelDetailsModal } from '@/components/channel-details-modal'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; -import { Skeleton } from '@/components/ui/skeleton'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Skeleton } from '@/components/ui/skeleton'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useCurrentUser } from '@/features/auth'; -import { useRemoveChannelMembers } from '@/features/channels'; +import { useGetUserChannels, useRemoveChannelMembers } from '@/features/channels'; import { ChatMember } from '@/features/members'; import { useParamIds } from '@/hooks/use-param-ids'; +import { useUIStore } from '@/stores/ui-store'; import type { Channel } from '@/types/chat'; interface ChatHeaderProps { @@ -37,30 +36,35 @@ export const ChatHeader: FC = ({ conversationData, currentUser, }) => { - const isLoading = !channel.name; + const router = useRouter(); + const { workspaceId, id: channelId } = useParamIds(); const [isDetailsModalOpen, setDetailsModalOpen] = useState(false); const [modalInitialTab, setModalInitialTab] = useState<'members' | 'settings'>('members'); - const { workspaceId } = useParamIds(); const removeChannelMembers = useRemoveChannelMembers(); const { user } = useCurrentUser(workspaceId); - const router = useRouter(); + const { data: channels } = useGetUserChannels(workspaceId); const { setProfilePanelOpen } = useUIStore(); - // Show up to 4 avatars, then a +N indicator - const maxAvatars = 4; - const visibleMembers = members.slice(0, maxAvatars); - const extraCount = members.length - maxAvatars; + const memberAvatars = useMemo(() => { + const maxAvatars = 4; + const visibleMembers = members.slice(0, maxAvatars); + const extraCount = members.length - maxAvatars; - // Conversation display logic - const getConversationHeaderDisplay = () => { - if (!conversationData || !currentUser) return null; + return { + visibleMembers, + extraCount: extraCount > 0 ? extraCount : 0, + }; + }, [members]); + + const conversationDisplay = useMemo(() => { + if (chatType !== 'conversation' || !conversationData || !currentUser) return null; const { conversation, members: conversationMembers } = conversationData; const otherMembers = conversation.other_members || conversationMembers?.filter((member: any) => member.user.id !== currentUser.id); - if (conversation.is_group_conversation) { + if (conversation.is_group_conversation || otherMembers.length > 1) { // Group DM: show comma-separated names of other members const names = otherMembers?.map((m: any) => m.workspace_member?.user?.name || m.user?.name); const uniqueNames = Array.from(new Set(names)); @@ -79,43 +83,26 @@ export const ChatHeader: FC = ({ // DM: show other person's avatar const otherMember = otherMembers[0]; const user = otherMember.workspace_member?.user || otherMember.user; - // Use workspace_member_id field, not id const workspaceMemberId = otherMember.workspace_member?.id || otherMember.workspace_member_id; return { type: 'dm', user: user, workspaceMemberId: workspaceMemberId, }; - } else if (otherMembers.length === 0) { + } else { // Self-conversation: show current user's avatar const selfMember = conversationMembers.find( (member: any) => member.user.id === currentUser.id, ); const user = selfMember?.workspace_member?.user || selfMember?.user || currentUser; - // Use workspace_member_id field, not id const workspaceMemberId = selfMember?.workspace_member?.id || selfMember?.workspace_member_id; return { type: 'self', user: user, workspaceMemberId: workspaceMemberId, }; - } else { - // Group conversation (2+ other members): show comma-separated names - const names = otherMembers?.map((m: any) => m.workspace_member?.user?.name || m.user?.name); - const uniqueNames = Array.from(new Set(names)); - const displayNames = - uniqueNames.length < names.length - ? `${uniqueNames.join(', ')} (${names.length} members)` - : uniqueNames.join(', '); - - return { - type: 'group', - names: displayNames, - }; } - }; - - const conversationDisplay = chatType === 'conversation' ? getConversationHeaderDisplay() : null; + }, [chatType, conversationData, currentUser]); const currentChannelMember = useMemo(() => { return members.find((member) => member.workspace_member.user.id === user?.id); @@ -156,6 +143,11 @@ export const ChatHeader: FC = ({ setProfilePanelOpen(workspaceMemberId); }; + const selectedChannel = useMemo( + () => channels?.find((channel) => channel.id === channelId), + [channels, channelId], + ); + return (
@@ -163,11 +155,7 @@ export const ChatHeader: FC = ({ {chatType === 'conversation' ? ( - // For conversations, show avatar only for 1-on-1 or self conversations - conversationDisplay?.type === 'group' ? ( - // Group DM with 3+ people: no avatar, just names in the title - <> - ) : ( + conversationDisplay?.type === 'group' ? null : conversationDisplay ? ( // DM or Self: show avatar = ({ {conversationDisplay?.user?.name?.[0]?.toUpperCase() || '?'} + ) : ( + ) - ) : // For channels, show lock/hash icon - channel.isPrivate ? ( - - ) : ( - - )} - {isLoading ? ( - - ) : ( + ) : chatType === 'channel' ? ( + channel.isPrivate ? ( + + ) : ( + + ) + ) : null} + + {chatType === 'channel' ? (

- {chatType === 'conversation' && conversationDisplay?.type === 'group' - ? conversationDisplay.names - : channel.name} + {selectedChannel?.name || channel.name}

+ ) : chatType === 'conversation' ? ( + conversationDisplay ? ( +

+ {conversationDisplay?.type === 'group' + ? conversationDisplay?.names + : conversationDisplay?.user?.name || ''} +

+ ) : ( + + ) + ) : ( +

{channel.name}

)}
@@ -217,7 +217,7 @@ export const ChatHeader: FC = ({ className="flex items-center -space-x-2 focus:outline-none group relative px-2 py-1 rounded-md hover:bg-muted/50 transition-colors" title="Channel details" > - {visibleMembers.map((member) => ( + {memberAvatars.visibleMembers.map((member) => ( = ({ ))} - {extraCount > 0 && ( + {memberAvatars.extraCount > 0 && (
- +{extraCount} + +{memberAvatars.extraCount}
)} )} - {/* Kebab Menu (only for channels) */} {chatType === 'channel' && ( @@ -286,7 +285,6 @@ export const ChatHeader: FC = ({ )}
- {/* Channel Details Modal (only for channels) */} {chatType === 'channel' && ( { // Don't retry on 4xx client errors (except 401 which might be token related) if (error?.response?.status) { @@ -28,6 +30,70 @@ const queryClient = new QueryClient({ }, }); +// Create async persister for localStorage with optimized configuration +const persister = createAsyncStoragePersister({ + storage: { + getItem: (key: string) => { + if (typeof window === 'undefined') return Promise.resolve(null); + return Promise.resolve(window.localStorage.getItem(key)); + }, + setItem: (key: string, value: string) => { + if (typeof window === 'undefined') return Promise.resolve(); + window.localStorage.setItem(key, value); + return Promise.resolve(); + }, + removeItem: (key: string) => { + if (typeof window === 'undefined') return Promise.resolve(); + window.localStorage.removeItem(key); + return Promise.resolve(); + }, + }, + throttleTime: 1000, // Throttle persistence writes to every 1 second for better performance +}); + export default function ReactQueryProvider({ children }: { children: ReactNode }) { - return {children}; + return ( + { + // Only persist specific queries that benefit from caching + const persistKeys = [ + 'workspaces', + 'workspace', + 'channels', + 'user-channels', + 'conversations', + 'agents', + 'members', + 'currentUser', + ]; + + // Don't persist queries with errors or that are currently errored + if (query.state.status === 'error') { + return false; + } + + return persistKeys.some( + (key) => Array.isArray(query.queryKey) && query.queryKey.includes(key), + ); + }, + }, + hydrateOptions: { + // Immediately make persisted data available to avoid loading states + defaultOptions: { + queries: { + gcTime: 1000 * 60 * 60 * 24, + }, + }, + }, + }} + > + {children} + + ); } diff --git a/src/components/side-nav/workspace-sidebar.tsx b/src/components/side-nav/workspace-sidebar.tsx index df8418af..e97bec15 100644 --- a/src/components/side-nav/workspace-sidebar.tsx +++ b/src/components/side-nav/workspace-sidebar.tsx @@ -3,13 +3,13 @@ import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; -import { Skeleton } from '@/components/ui/skeleton'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Skeleton } from '@/components/ui/skeleton'; import { useAgents } from '@/features/agents/hooks/use-agents'; import { useCurrentUser } from '@/features/auth/hooks/use-current-user'; import { useGetUserChannels } from '@/features/channels/hooks/use-channels-mutations'; @@ -44,22 +44,19 @@ export const WorkspaceSidebar = () => { const { getWorkspaceDrafts } = useDraftsStore(); const { setThreadOpen } = useUIStore(); - const [isHydrated, setIsHydrated] = useState(false); - const draftCount = Object.keys(getWorkspaceDrafts(workspaceId)).length; + const [draftCount, setDraftCount] = useState(0); + // prevent hydration mismatch useEffect(() => { - setIsHydrated(true); - }, []); + setDraftCount(Object.keys(getWorkspaceDrafts(workspaceId)).length); + }, [getWorkspaceDrafts, workspaceId]); const { startConversationCreation } = useConversationCreateStore(); const setOpen = useCreateChannelModal((state) => state.setOpen); const [inviteOpen, setInviteOpen] = useState(false); - const isLoading = - getWorkspace.isLoading || getUserChannels.isLoading || getWorkspaceAgents.isLoading; - - if (!getWorkspace.isLoading && !getWorkspace.data) { + if (getWorkspace.error) { return (
@@ -70,17 +67,15 @@ export const WorkspaceSidebar = () => { return (
- {getWorkspace.isLoading ? ( + {getWorkspace.data ? ( + + ) : (
- ) : ( - getWorkspace.data && ( - - ) )}
@@ -89,7 +84,7 @@ export const WorkspaceSidebar = () => { icon={Pencil} id="drafts" variant={entityId === 'drafts' ? 'active' : 'default'} - count={isHydrated ? draftCount : undefined} + count={draftCount > 0 ? draftCount : undefined} />
{ hint="New channel" onNew={getWorkspace.data?.user_role === 'admin' ? () => setOpen(true) : undefined} > - {getUserChannels.isLoading ? ( - <> - - - - - ) : ( - (getUserChannels.data || [])?.map((item: any) => ( - - )) - )} - {!getUserChannels.isLoading && ( + {(getUserChannels.data || []).map((item: any) => ( + + ))} + {getUserChannels.data && (
@@ -145,29 +132,21 @@ export const WorkspaceSidebar = () => { hint="New direct message" onNew={startConversationCreation} > - {getUserChannels.isLoading || !conversations ? ( - <> - - - - - ) : ( - conversations?.map((conversation: any) => ( - 0} - /> - )) - )} + {(conversations || []).map((conversation: any) => ( + 0} + /> + ))} {currentUser?.role === 'admin' && ( <> @@ -180,20 +159,13 @@ export const WorkspaceSidebar = () => { )} - {getWorkspaceAgents.isLoading ? ( - <> - - - - ) : ( - (getWorkspaceAgents.data || [])?.map((agent: any) => ( - - )) - )} + {(getWorkspaceAgents.data || []).map((agent: any) => ( + + ))}
diff --git a/src/features/agents/hooks/use-agents.ts b/src/features/agents/hooks/use-agents.ts index 7ec7872b..c0552d6a 100644 --- a/src/features/agents/hooks/use-agents.ts +++ b/src/features/agents/hooks/use-agents.ts @@ -9,6 +9,11 @@ export const useAgents = (workspaceId: string, filters?: Partial) queryKey: agentsQueryKeys.agents(workspaceId), queryFn: () => agentsApi.getAgents(workspaceId, filters), enabled: !!workspaceId, + refetchOnMount: false, // Prioritize showing cached data immediately + refetchOnReconnect: true, + refetchOnWindowFocus: false, + staleTime: 2 * 60 * 60 * 1000, // 2 hours - agents don't change frequently + gcTime: 24 * 60 * 60 * 1000, // 24 hours - keep in memory longer }); }; diff --git a/src/features/channels/hooks/use-channels-mutations.ts b/src/features/channels/hooks/use-channels-mutations.ts index 070eeb9a..75248182 100644 --- a/src/features/channels/hooks/use-channels-mutations.ts +++ b/src/features/channels/hooks/use-channels-mutations.ts @@ -31,7 +31,11 @@ export const useGetUserChannels = (workspaceId: string, filters?: Partial channelsApi.getUserChannels(workspaceId, filters), enabled: !!workspaceId, - staleTime: 10 * 60 * 60 * 1000, + refetchOnMount: true, // Prioritize showing cached data immediately + refetchOnReconnect: true, + refetchOnWindowFocus: true, + staleTime: 2 * 60 * 60 * 1000, // 2 hours - channels don't change that frequently + gcTime: 24 * 60 * 60 * 1000, // 24 hours - keep in memory longer }); }; diff --git a/src/features/conversations/hooks/use-conversations.ts b/src/features/conversations/hooks/use-conversations.ts index 6fa1731e..8b562ea5 100644 --- a/src/features/conversations/hooks/use-conversations.ts +++ b/src/features/conversations/hooks/use-conversations.ts @@ -14,6 +14,11 @@ export const useConversations = (workspaceId: string, filters?: Partial conversationsApi.getConversations(workspaceId, filters), enabled: !!workspaceId, + refetchOnMount: 'always', // Prioritize showing cached data immediately + refetchOnReconnect: true, + refetchOnWindowFocus: true, + staleTime: 30 * 60 * 1000, // 30 minutes - conversations are more stable + gcTime: 24 * 60 * 60 * 1000, // 24 hours - keep in memory longer }); const createConversation = useMutation({ diff --git a/src/features/workspaces/hooks/use-workspaces.ts b/src/features/workspaces/hooks/use-workspaces.ts index 9ba7fb64..50852454 100644 --- a/src/features/workspaces/hooks/use-workspaces.ts +++ b/src/features/workspaces/hooks/use-workspaces.ts @@ -22,8 +22,8 @@ export const useGetWorkspaces = () => { queryKey: workspacesQueryKeys.workspaces(), queryFn: () => workspacesApi.getWorkspaces(), refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, + refetchOnMount: 'always', // Always check for updates but show cached data first + refetchOnReconnect: true, staleTime: 4 * 60 * 60 * 1000, }); }; @@ -35,9 +35,10 @@ export const useGetWorkspace = (id: string) => { queryFn: () => workspacesApi.getWorkspace(id), enabled: !!id, refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, - staleTime: 2 * 60 * 60 * 1000, + refetchOnMount: false, // Don't refetch on mount, prioritize showing cached data first + refetchOnReconnect: true, + staleTime: 4 * 60 * 60 * 1000, // 4 hours - workspace data doesn't change frequently + gcTime: 24 * 60 * 60 * 1000, // 24 hours - keep in memory longer }); }; diff --git a/src/lib/api/axios-client.ts b/src/lib/api/axios-client.ts index 81b29a44..913da8db 100644 --- a/src/lib/api/axios-client.ts +++ b/src/lib/api/axios-client.ts @@ -1,10 +1,11 @@ +import { supabase } from '@/lib/supabase/client'; +import { createClient as createServerClient } from '@/lib/supabase/server'; import axios, { type AxiosError, AxiosHeaders, type AxiosInstance, type InternalAxiosRequestConfig, } from 'axios'; -import { supabase } from '@/lib/supabase/client'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL!; @@ -36,35 +37,56 @@ const processQueue = (error: Error | null, token: string | null = null) => { failedQueue.length = 0; }; +/** + * Check if we're running on the server + */ +const isServer = typeof window === 'undefined'; + /** * Get a fresh access token, refreshing if necessary + * Works in both client and server contexts */ async function getAccessToken(): Promise { try { - const { - data: { session }, - error, - } = await supabase.auth.getSession(); + if (isServer) { + // Server-side: create server Supabase client + const supabaseServer = await createServerClient(); + const { + data: { session }, + error, + } = await supabaseServer.auth.getSession(); + + if (error || !session?.access_token) { + return null; + } + return session.access_token; + } else { + // Client-side: use existing client logic + const { + data: { session }, + error, + } = await supabase.auth.getSession(); - if (error || !session) { - return null; - } + if (error || !session) { + return null; + } - // Check if token needs refresh (5 minutes before expiry) - const now = Math.floor(Date.now() / 1000); - const expiresAt = session.expires_at || 0; - const shouldRefresh = expiresAt - now < 300; // 5 minutes buffer + // Check if token needs refresh (5 minutes before expiry) + const now = Math.floor(Date.now() / 1000); + const expiresAt = session.expires_at || 0; + const shouldRefresh = expiresAt - now < 300; // 5 minutes buffer - if (shouldRefresh) { - const { data: refreshData, error: refreshError } = await supabase.auth.refreshSession(); - if (refreshError || !refreshData.session) { - console.error('Token refresh failed:', refreshError); - return null; + if (shouldRefresh) { + const { data: refreshData, error: refreshError } = await supabase.auth.refreshSession(); + if (refreshError || !refreshData.session) { + console.error('Token refresh failed:', refreshError); + return null; + } + return refreshData.session.access_token; } - return refreshData.session.access_token; - } - return session.access_token; + return session.access_token; + } } catch (error) { console.error('Failed to get access token:', error); return null;