feat(chat): implement chat tab synchronization and enhance thread activation with new hooks for improved navigation and metadata management

This commit is contained in:
Anish Sarkar 2026-06-04 18:16:33 +05:30
parent 168c0d2f89
commit 08801fe3e8
13 changed files with 276 additions and 85 deletions

View file

@ -8,11 +8,7 @@ import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
currentThreadAtom,
resetCurrentThreadAtom,
setCurrentThreadMetadataAtom,
} from "@/atoms/chat/current-thread.atom";
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom";
@ -45,6 +41,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
import { useAnnouncements } from "@/hooks/use-announcements";
import { useInbox } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile";
@ -98,9 +95,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
const currentThreadState = useAtomValue(currentThreadAtom);
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
const setCurrentThreadMetadata = useSetAtom(setCurrentThreadMetadataAtom);
const syncChatTab = useSetAtom(syncChatTabAtom);
const removeChatTab = useSetAtom(removeChatTabAtom);
const { activateChatThread, prefetchChatThread } = useActivateChatThread();
const { mutateAsync: archiveThread } = useArchiveThread(searchSpaceId);
const { mutateAsync: deleteThread } = useDeleteThread(searchSpaceId);
const { mutateAsync: renameThread } = useRenameThread(searchSpaceId);
@ -309,6 +306,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
title: chatId ? (thread?.title ?? undefined) : "New Chat",
chatUrl,
searchSpaceId: Number(searchSpaceId),
...(thread?.visibility !== undefined ? { visibility: thread.visibility } : {}),
});
}, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]);
@ -469,12 +467,34 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const handleTabSwitch = useCallback(
(tab: Tab) => {
if (tab.type === "chat") {
const url = tab.chatUrl || `/dashboard/${searchSpaceId}/new-chat`;
router.push(url);
activateChatThread({
id: tab.chatId ?? null,
title: tab.title,
url: tab.chatUrl,
searchSpaceId: tab.searchSpaceId ?? searchSpaceId,
...(tab.visibility !== undefined ? { visibility: tab.visibility } : {}),
...(tab.hasComments !== undefined ? { hasComments: tab.hasComments } : {}),
});
}
// Document tabs are handled in-place by LayoutShell — no navigation needed
},
[router, searchSpaceId]
[activateChatThread, searchSpaceId]
);
const handleTabPrefetch = useCallback(
(tab: Tab) => {
if (tab.type === "chat") {
prefetchChatThread(tab.chatId);
}
},
[prefetchChatThread]
);
const handleChatPrefetch = useCallback(
(chat: ChatItem) => {
prefetchChatThread(chat.id);
},
[prefetchChatThread]
);
const handleNavItemClick = useCallback(
@ -526,20 +546,15 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const handleChatSelect = useCallback(
(chat: ChatItem) => {
syncChatTab({
chatId: chat.id,
title: chat.name,
chatUrl: chat.url,
searchSpaceId: Number(searchSpaceId),
});
setCurrentThreadMetadata({
activateChatThread({
id: chat.id,
visibility: chat.visibility ?? "PRIVATE",
hasComments: false,
title: chat.name,
url: chat.url,
searchSpaceId,
...(chat.visibility !== undefined ? { visibility: chat.visibility } : {}),
});
router.push(chat.url);
},
[router, searchSpaceId, setCurrentThreadMetadata, syncChatTab]
[activateChatThread, searchSpaceId]
);
const handleChatDelete = useCallback((chat: ChatItem) => {
@ -611,7 +626,16 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
if (currentChatId === chatToDelete.id) {
resetCurrentThread();
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
router.push(fallbackTab.chatUrl);
activateChatThread({
id: fallbackTab.chatId ?? null,
title: fallbackTab.title,
url: fallbackTab.chatUrl,
searchSpaceId: fallbackTab.searchSpaceId ?? searchSpaceId,
...(fallbackTab.visibility !== undefined ? { visibility: fallbackTab.visibility } : {}),
...(fallbackTab.hasComments !== undefined
? { hasComments: fallbackTab.hasComments }
: {}),
});
} else {
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
if (isOutOfSync) {
@ -639,6 +663,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
params?.chat_id,
router,
removeChatTab,
activateChatThread,
]);
// Rename handler
@ -693,6 +718,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
activeChatId={currentChatId}
onNewChat={handleNewChat}
onChatSelect={handleChatSelect}
onChatPrefetch={handleChatPrefetch}
onChatRename={handleChatRename}
onChatDelete={handleChatDelete}
onChatArchive={handleChatArchive}
@ -759,6 +785,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
onOpenChange: setIsDocumentsSidebarOpen,
}}
onTabSwitch={handleTabSwitch}
onTabPrefetch={handleTabPrefetch}
>
<Fragment key={chatResetKey}>{children}</Fragment>
</LayoutShell>

View file

@ -26,6 +26,14 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const currentThreadState = useAtomValue(currentThreadAtom);
const hasThread = isChatPage && !isDocumentTab && currentThreadState.id !== null;
const activeSearchSpaceId = searchSpaceId ? Number(searchSpaceId) : null;
const canRenderShareButton =
hasThread &&
currentThreadState.id !== null &&
currentThreadState.visibility !== null &&
currentThreadState.searchSpaceId !== null &&
activeSearchSpaceId !== null &&
currentThreadState.searchSpaceId === activeSearchSpaceId;
// Free chat pages have their own header with model selector; only render mobile trigger
if (isFreePage) {
@ -37,19 +45,24 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
);
}
const threadForButton: ThreadRecord | null =
hasThread && currentThreadState.id !== null && searchSpaceId
? {
id: currentThreadState.id,
visibility: currentThreadState.visibility ?? "PRIVATE",
created_by_id: null,
search_space_id: Number(searchSpaceId),
title: "",
archived: false,
created_at: "",
updated_at: "",
}
: null;
let threadForButton: ThreadRecord | null = null;
if (
canRenderShareButton &&
currentThreadState.id !== null &&
currentThreadState.visibility !== null &&
currentThreadState.searchSpaceId !== null
) {
threadForButton = {
id: currentThreadState.id,
visibility: currentThreadState.visibility,
created_by_id: null,
search_space_id: currentThreadState.searchSpaceId,
title: "",
archived: false,
created_at: "",
updated_at: "",
};
}
return (
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-main-panel/95 backdrop-blur supports-backdrop-filter:bg-main-panel/60 px-4">
@ -64,7 +77,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
{/* Right side - Actions */}
<div className="ml-auto flex items-center gap-2">
{hasThread && <ActionLogButton threadId={currentThreadState.id} />}
{hasThread && <ChatShareButton thread={threadForButton} />}
{threadForButton && <ChatShareButton thread={threadForButton} />}
</div>
</header>
);

View file

@ -117,6 +117,7 @@ interface LayoutShellProps {
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatPrefetch?: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
@ -153,11 +154,13 @@ interface LayoutShellProps {
onOpenChange: (open: boolean) => void;
};
onTabSwitch?: (tab: Tab) => void;
onTabPrefetch?: (tab: Tab) => void;
}
function MainContentPanel({
isChatPage,
onTabSwitch,
onTabPrefetch,
onNewChat,
showRightPanelExpandButton = true,
showTopBorder = false,
@ -165,6 +168,7 @@ function MainContentPanel({
}: {
isChatPage: boolean;
onTabSwitch?: (tab: Tab) => void;
onTabPrefetch?: (tab: Tab) => void;
onNewChat?: () => void;
showRightPanelExpandButton?: boolean;
showTopBorder?: boolean;
@ -179,6 +183,7 @@ function MainContentPanel({
>
<TabBar
onTabSwitch={onTabSwitch}
onTabPrefetch={onTabPrefetch}
onNewChat={onNewChat}
rightActions={showRightPanelExpandButton ? <RightPanelExpandButton /> : null}
className="min-w-0"
@ -223,6 +228,7 @@ export function LayoutShell({
activeChatId,
onNewChat,
onChatSelect,
onChatPrefetch,
onChatRename,
onChatDelete,
onChatArchive,
@ -251,6 +257,7 @@ export function LayoutShell({
allChatsPanel,
documentsPanel,
onTabSwitch,
onTabPrefetch,
}: LayoutShellProps) {
const isMobile = useIsMobile();
const electronAPI = useElectronAPI();
@ -305,6 +312,7 @@ export function LayoutShell({
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatPrefetch={onChatPrefetch}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
@ -447,6 +455,7 @@ export function LayoutShell({
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatPrefetch={onChatPrefetch}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
@ -551,6 +560,7 @@ export function LayoutShell({
<MainContentPanel
isChatPage={isChatPage}
onTabSwitch={onTabSwitch}
onTabPrefetch={onTabPrefetch}
onNewChat={onNewChat}
showRightPanelExpandButton={!isMacDesktop}
showTopBorder={isMacDesktop}

View file

@ -19,8 +19,7 @@ import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { setCurrentThreadMetadataAtom } from "@/atoms/chat/current-thread.atom";
import { removeChatTabAtom, syncChatTabAtom } from "@/atoms/tabs/tabs.atom";
import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import {
@ -41,11 +40,11 @@ import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile";
import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations";
import { prefetchThreadData } from "@/hooks/use-thread-queries";
import { fetchThreads, searchThreads, type ThreadListItem } from "@/lib/chat/thread-persistence";
import { formatThreadTimestamp } from "@/lib/format-date";
import { cn } from "@/lib/utils";
@ -72,8 +71,7 @@ export function AllChatsSidebarContent({
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const removeChatTab = useSetAtom(removeChatTabAtom);
const syncChatTab = useSetAtom(syncChatTabAtom);
const setCurrentThreadMetadata = useSetAtom(setCurrentThreadMetadataAtom);
const { activateChatThread, prefetchChatThread } = useActivateChatThread();
const { mutateAsync: deleteThread } = useDeleteThread(searchSpaceId);
const { mutateAsync: archiveThread } = useArchiveThread(searchSpaceId);
const { mutateAsync: renameThread } = useRenameThread(searchSpaceId);
@ -146,30 +144,16 @@ export function AllChatsSidebarContent({
const handleThreadClick = useCallback(
(thread: ThreadListItem) => {
const chatUrl = `/dashboard/${searchSpaceId}/new-chat/${thread.id}`;
syncChatTab({
chatId: thread.id,
title: thread.title || "New Chat",
chatUrl,
searchSpaceId: Number(searchSpaceId),
});
setCurrentThreadMetadata({
activateChatThread({
id: thread.id,
title: thread.title || "New Chat",
searchSpaceId,
visibility: thread.visibility,
hasComments: false,
});
router.push(chatUrl);
onOpenChange(false);
onCloseMobileSidebar?.();
},
[
router,
onOpenChange,
searchSpaceId,
onCloseMobileSidebar,
setCurrentThreadMetadata,
syncChatTab,
]
[activateChatThread, onOpenChange, searchSpaceId, onCloseMobileSidebar]
);
const handleDeleteThread = useCallback(
@ -183,8 +167,23 @@ export function AllChatsSidebarContent({
if (currentChatId === threadId) {
onOpenChange(false);
setTimeout(() => {
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
router.push(fallbackTab.chatUrl);
if (
fallbackTab?.type === "chat" &&
fallbackTab.chatUrl &&
fallbackTab.chatId !== undefined
) {
activateChatThread({
id: fallbackTab.chatId ?? null,
title: fallbackTab.title,
url: fallbackTab.chatUrl,
searchSpaceId: fallbackTab.searchSpaceId ?? searchSpaceId,
...(fallbackTab.visibility !== undefined
? { visibility: fallbackTab.visibility }
: {}),
...(fallbackTab.hasComments !== undefined
? { hasComments: fallbackTab.hasComments }
: {}),
});
return;
}
router.push(`/dashboard/${searchSpaceId}/new-chat`);
@ -197,7 +196,16 @@ export function AllChatsSidebarContent({
setDeletingThreadId(null);
}
},
[deleteThread, t, currentChatId, router, onOpenChange, removeChatTab, searchSpaceId]
[
activateChatThread,
deleteThread,
t,
currentChatId,
router,
onOpenChange,
removeChatTab,
searchSpaceId,
]
);
const handleToggleArchive = useCallback(
@ -362,8 +370,8 @@ export function AllChatsSidebarContent({
if (wasLongPress()) return;
handleThreadClick(thread);
}}
onMouseEnter={() => prefetchThreadData(queryClient, thread.id)}
onFocus={() => prefetchThreadData(queryClient, thread.id)}
onMouseEnter={() => prefetchChatThread(thread.id)}
onFocus={() => prefetchChatThread(thread.id)}
onTouchStart={() => {
pendingThreadIdRef.current = thread.id;
longPressHandlers.onTouchStart();
@ -389,8 +397,8 @@ export function AllChatsSidebarContent({
type="button"
variant="ghost"
onClick={() => handleThreadClick(thread)}
onMouseEnter={() => prefetchThreadData(queryClient, thread.id)}
onFocus={() => prefetchThreadData(queryClient, thread.id)}
onMouseEnter={() => prefetchChatThread(thread.id)}
onFocus={() => prefetchChatThread(thread.id)}
disabled={isBusy}
className={cn(
"h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-left font-normal",

View file

@ -22,6 +22,7 @@ interface MobileSidebarProps {
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatPrefetch?: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
@ -69,6 +70,7 @@ export function MobileSidebar({
activeChatId,
onNewChat,
onChatSelect,
onChatPrefetch,
onChatRename,
onChatDelete,
onChatArchive,
@ -152,6 +154,7 @@ export function MobileSidebar({
onOpenChange(false);
}}
onChatSelect={handleChatSelect}
onChatPrefetch={onChatPrefetch}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}

View file

@ -1,6 +1,5 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { CreditCard, Dot, SquarePen, Zap } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
@ -11,7 +10,6 @@ import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsAnonymous } from "@/contexts/anonymous-mode";
import { prefetchThreadData } from "@/hooks/use-thread-queries";
import { cn } from "@/lib/utils";
import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
@ -72,6 +70,7 @@ interface SidebarProps {
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatPrefetch?: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
@ -108,6 +107,7 @@ export function Sidebar({
activeChatId,
onNewChat,
onChatSelect,
onChatPrefetch,
onChatRename,
onChatDelete,
onChatArchive,
@ -134,7 +134,6 @@ export function Sidebar({
collapsedHeaderContent,
}: SidebarProps) {
const t = useTranslations("sidebar");
const queryClient = useQueryClient();
const [openDropdownChatId, setOpenDropdownChatId] = useState<number | null>(null);
// Inbox, Automations, and Documents are rendered explicitly right below
@ -296,7 +295,7 @@ export function Sidebar({
dropdownOpen={openDropdownChatId === chat.id}
onDropdownOpenChange={(open) => setOpenDropdownChatId(open ? chat.id : null)}
onClick={() => onChatSelect(chat)}
onPrefetch={() => prefetchThreadData(queryClient, chat.id)}
onPrefetch={() => onChatPrefetch?.(chat)}
onRename={() => onChatRename?.(chat)}
onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}

View file

@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
interface TabBarProps {
onTabSwitch?: (tab: Tab) => void;
onTabPrefetch?: (tab: Tab) => void;
onNewChat?: () => void;
leftActions?: React.ReactNode;
rightActions?: React.ReactNode;
@ -36,6 +37,7 @@ function nextTabListScrollLeft(input: {
export function TabBar({
onTabSwitch,
onTabPrefetch,
onNewChat,
leftActions,
rightActions,
@ -71,6 +73,15 @@ export function TabBar({
[activeTabId, switchTab, onTabSwitch]
);
const handleTabPrefetch = useCallback(
(tab: Tab) => {
if (tab.type === "chat") {
onTabPrefetch?.(tab);
}
},
[onTabPrefetch]
);
const handleTabClose = useCallback(
(e: React.MouseEvent, tabId: string) => {
e.stopPropagation();
@ -195,7 +206,11 @@ export function TabBar({
type="button"
variant="ghost"
onClick={() => handleTabClick(tab)}
onMouseEnter={() => setHoveredTabIndex(index)}
onMouseEnter={() => {
setHoveredTabIndex(index);
handleTabPrefetch(tab);
}}
onFocus={() => handleTabPrefetch(tab)}
onMouseLeave={() => setHoveredTabIndex(null)}
className={cn(
"h-full w-full justify-start overflow-hidden px-3 text-left text-[13px] font-medium transition-colors duration-150",

View file

@ -77,8 +77,9 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
});
const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0;
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop.
// Unknown visibility should not be presented as private while thread detail is still resolving.
const currentVisibility = currentThreadState.visibility ?? thread?.visibility;
const handleVisibilityChange = useCallback(
async (newVisibility: ChatVisibility) => {
@ -120,7 +121,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
}, [thread, createSnapshot, queryClient]);
// Don't show if no thread (new chat that hasn't been created yet)
if (!thread) {
if (!thread || currentVisibility === undefined) {
return null;
}