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

@ -37,7 +37,7 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { type AgentCreatedDocument, agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
import { removeChatTabAtom, syncChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import {
EditMessageDialog,
@ -450,6 +450,7 @@ export default function NewChatPage() {
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
const closeReportPanel = useSetAtom(closeReportPanelAtom);
const closeEditorPanel = useSetAtom(closeEditorPanelAtom);
const syncChatTab = useSetAtom(syncChatTabAtom);
const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom);
const removeChatTab = useSetAtom(removeChatTabAtom);
const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom);
@ -726,9 +727,18 @@ export default function NewChatPage() {
return;
}
if (threadDetailQuery.data?.id === activeThreadId) {
setCurrentThread(threadDetailQuery.data);
const thread = threadDetailQuery.data;
setCurrentThread(thread);
syncChatTab({
chatId: thread.id,
title: thread.title,
chatUrl: `/dashboard/${thread.search_space_id ?? searchSpaceId}/new-chat/${thread.id}`,
searchSpaceId: thread.search_space_id ?? searchSpaceId,
visibility: thread.visibility,
hasComments: thread.has_comments ?? false,
});
}
}, [activeThreadId, threadDetailQuery.data]);
}, [activeThreadId, searchSpaceId, syncChatTab, threadDetailQuery.data]);
useEffect(() => {
const messagesResponse = threadMessagesQuery.data;
@ -856,6 +866,7 @@ export default function NewChatPage() {
}
setCurrentThreadMetadata({
id: null,
searchSpaceId: null,
visibility: null,
hasComments: false,
});
@ -869,6 +880,7 @@ export default function NewChatPage() {
setCurrentThreadMetadata({
id: currentThread.id,
searchSpaceId: currentThread.search_space_id ?? searchSpaceId,
visibility,
hasComments: currentThread.has_comments ?? false,
});
@ -877,6 +889,7 @@ export default function NewChatPage() {
currentThread,
currentThreadState.id,
currentThreadState.visibility,
searchSpaceId,
setCurrentThreadMetadata,
]);

View file

@ -4,12 +4,14 @@ import { reportPanelAtom } from "./report-panel.atom";
interface CurrentThreadState {
id: number | null;
searchSpaceId: number | null;
visibility: ChatVisibility | null;
hasComments: boolean;
}
interface CurrentThreadMetadataPatch {
id: number | null;
searchSpaceId?: number | null;
visibility?: ChatVisibility | null;
hasComments?: boolean;
}
@ -22,6 +24,7 @@ interface CurrentThreadMetadataUpdate {
const initialState: CurrentThreadState = {
id: null,
searchSpaceId: null,
visibility: null,
hasComments: false,
};
@ -32,21 +35,33 @@ export const commentsEnabledAtom = atom(
(get) => get(currentThreadAtom).visibility === "SEARCH_SPACE"
);
export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: ChatVisibility) => {
set(currentThreadAtom, { ...get(currentThreadAtom), visibility: newVisibility });
});
export const setCurrentThreadMetadataAtom = atom(
null,
(get, set, metadata: CurrentThreadMetadataPatch) => {
const current = get(currentThreadAtom);
const isSameThread = current.id === metadata.id;
set(currentThreadAtom, {
...current,
id: metadata.id,
visibility: "visibility" in metadata ? (metadata.visibility ?? null) : current.visibility,
searchSpaceId:
"searchSpaceId" in metadata
? (metadata.searchSpaceId ?? null)
: isSameThread
? current.searchSpaceId
: null,
visibility:
"visibility" in metadata
? (metadata.visibility ?? null)
: isSameThread
? current.visibility
: null,
hasComments:
"hasComments" in metadata ? (metadata.hasComments ?? false) : current.hasComments,
"hasComments" in metadata
? (metadata.hasComments ?? false)
: isSameThread
? current.hasComments
: false,
});
}
);

View file

@ -1,5 +1,6 @@
import { atom } from "jotai";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
export type TabType = "chat" | "document";
@ -10,6 +11,8 @@ export interface Tab {
/** For chat tabs */
chatId?: number | null;
chatUrl?: string;
visibility?: ChatVisibility;
hasComments?: boolean;
/** For document tabs */
documentId?: number;
searchSpaceId?: number;
@ -79,11 +82,15 @@ export const syncChatTabAtom = atom(
title,
chatUrl,
searchSpaceId,
visibility,
hasComments,
}: {
chatId: number | null;
title?: string;
chatUrl?: string;
searchSpaceId: number;
visibility?: ChatVisibility;
hasComments?: boolean;
}
) => {
if (chatId && get(deletedChatIdsAtom).has(chatId)) {
@ -105,6 +112,8 @@ export const syncChatTabAtom = atom(
title: title || t.title,
chatUrl: chatUrl || t.chatUrl,
searchSpaceId: searchSpaceId ?? t.searchSpaceId,
...(visibility !== undefined ? { visibility } : {}),
...(hasComments !== undefined ? { hasComments } : {}),
}
: t
),
@ -140,6 +149,8 @@ export const syncChatTabAtom = atom(
chatId,
chatUrl,
searchSpaceId,
...(visibility !== undefined ? { visibility } : {}),
...(hasComments !== undefined ? { hasComments } : {}),
};
let updatedTabs: Tab[];

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;
}

View file

@ -0,0 +1,79 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import { useRouter } from "next/navigation";
import { useCallback } from "react";
import { setCurrentThreadMetadataAtom } from "@/atoms/chat/current-thread.atom";
import { syncChatTabAtom } from "@/atoms/tabs/tabs.atom";
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
import { prefetchThreadData } from "./use-thread-queries";
interface ActivateChatThreadInput {
id: number | null;
title?: string;
url?: string;
searchSpaceId: number | string;
visibility?: ChatVisibility;
hasComments?: boolean;
}
function getSearchSpaceId(searchSpaceId: number | string): number {
const parsed =
typeof searchSpaceId === "number" ? searchSpaceId : Number.parseInt(searchSpaceId, 10);
return Number.isNaN(parsed) ? 0 : parsed;
}
function getChatUrl(searchSpaceId: number | string, threadId: number | null): string {
return threadId
? `/dashboard/${searchSpaceId}/new-chat/${threadId}`
: `/dashboard/${searchSpaceId}/new-chat`;
}
export function useActivateChatThread() {
const router = useRouter();
const queryClient = useQueryClient();
const syncChatTab = useSetAtom(syncChatTabAtom);
const setCurrentThreadMetadata = useSetAtom(setCurrentThreadMetadataAtom);
const prefetchChatThread = useCallback(
(threadId: number | null | undefined) => {
if (typeof threadId === "number" && threadId > 0) {
prefetchThreadData(queryClient, threadId);
}
},
[queryClient]
);
const activateChatThread = useCallback(
({ id, title, url, searchSpaceId, visibility, hasComments }: ActivateChatThreadInput) => {
const numericSearchSpaceId = getSearchSpaceId(searchSpaceId);
const chatUrl = url ?? getChatUrl(searchSpaceId, id);
syncChatTab({
chatId: id,
title: id ? title : (title ?? "New Chat"),
chatUrl,
searchSpaceId: numericSearchSpaceId,
...(visibility !== undefined ? { visibility } : {}),
...(hasComments !== undefined ? { hasComments } : {}),
});
setCurrentThreadMetadata({
id,
searchSpaceId: numericSearchSpaceId,
...(visibility !== undefined ? { visibility } : {}),
...(hasComments !== undefined ? { hasComments } : {}),
});
if (id) {
prefetchThreadData(queryClient, id);
}
router.push(chatUrl);
},
[queryClient, router, setCurrentThreadMetadata, syncChatTab]
);
return { activateChatThread, prefetchChatThread };
}

View file

@ -19,11 +19,8 @@ function stableEntries(obj: Record<string, unknown> | null | undefined): unknown
export const cacheKeys = {
// New chat threads (assistant-ui)
threads: {
list: (searchSpaceId: number) => ["threads", searchSpaceId] as const,
detail: (threadId: number) => ["threads", "detail", threadId] as const,
messages: (threadId: number) => ["threads", "messages", threadId] as const,
search: (searchSpaceId: number, query: string) =>
["threads", "search", searchSpaceId, query] as const,
},
documents: {
globalQueryParams: (queries: GetDocumentsRequest["queryParams"]) =>