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
+
+
+
+ Not now
+
+
+ Enable notifications
+ Enable
+
+
+
+
+
+ Close
+
+
+
+ )}
+
+ {/* 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
-
-
-
- Not now
-
-
- Enable notifications
- Enable
-
-
-
-
-
- Close
-
-
-
- )}
-
- {/* 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' && (
<>
setInviteOpen(true)}
- disabled={isLoading}
+ disabled={false}
>
+ Invite People
@@ -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;