mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-15 18:25:18 +02:00
refactor: enhance chat tab management by implementing fallback navigation and preventing race conditions during deletion for improved user experience
This commit is contained in:
parent
b54aa517a8
commit
69b8eef5ce
7 changed files with 65 additions and 93 deletions
|
|
@ -33,7 +33,7 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||||
import { type AgentCreatedDocument, agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
|
import { type AgentCreatedDocument, agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
|
||||||
import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import { updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
|
import { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
|
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
|
||||||
import { Thread } from "@/components/assistant-ui/thread";
|
import { Thread } from "@/components/assistant-ui/thread";
|
||||||
|
|
@ -70,6 +70,7 @@ import {
|
||||||
getThreadMessages,
|
getThreadMessages,
|
||||||
type ThreadRecord,
|
type ThreadRecord,
|
||||||
} from "@/lib/chat/thread-persistence";
|
} from "@/lib/chat/thread-persistence";
|
||||||
|
import { NotFoundError } from "@/lib/error";
|
||||||
import {
|
import {
|
||||||
trackChatCreated,
|
trackChatCreated,
|
||||||
trackChatError,
|
trackChatError,
|
||||||
|
|
@ -194,6 +195,7 @@ export default function NewChatPage() {
|
||||||
const closeReportPanel = useSetAtom(closeReportPanelAtom);
|
const closeReportPanel = useSetAtom(closeReportPanelAtom);
|
||||||
const closeEditorPanel = useSetAtom(closeEditorPanelAtom);
|
const closeEditorPanel = useSetAtom(closeEditorPanelAtom);
|
||||||
const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom);
|
const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom);
|
||||||
|
const removeChatTab = useSetAtom(removeChatTabAtom);
|
||||||
const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom);
|
const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom);
|
||||||
|
|
||||||
// Get current user for author info in shared chats
|
// Get current user for author info in shared chats
|
||||||
|
|
@ -323,6 +325,14 @@ export default function NewChatPage() {
|
||||||
// This improves UX (instant load) and avoids orphan threads
|
// This improves UX (instant load) and avoids orphan threads
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[NewChatPage] Failed to initialize thread:", error);
|
console.error("[NewChatPage] Failed to initialize thread:", error);
|
||||||
|
if (urlChatId > 0 && error instanceof NotFoundError) {
|
||||||
|
removeChatTab(urlChatId);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
|
||||||
|
}
|
||||||
|
toast.error("This chat was deleted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Keep threadId as null - don't use Date.now() as it creates an invalid ID
|
// Keep threadId as null - don't use Date.now() as it creates an invalid ID
|
||||||
// that will cause 404 errors on subsequent API calls
|
// that will cause 404 errors on subsequent API calls
|
||||||
setThreadId(null);
|
setThreadId(null);
|
||||||
|
|
@ -338,12 +348,14 @@ export default function NewChatPage() {
|
||||||
setSidebarDocuments,
|
setSidebarDocuments,
|
||||||
closeReportPanel,
|
closeReportPanel,
|
||||||
closeEditorPanel,
|
closeEditorPanel,
|
||||||
|
removeChatTab,
|
||||||
|
searchSpaceId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same)
|
// Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeThread();
|
initializeThread();
|
||||||
}, [initializeThread, searchSpaceId]);
|
}, [initializeThread]);
|
||||||
|
|
||||||
// Prefetch document titles for @ mention picker
|
// Prefetch document titles for @ mention picker
|
||||||
// Runs when user lands on page so data is ready when they type @
|
// Runs when user lands on page so data is ready when they type @
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ const initialState: TabsState = {
|
||||||
activeTabId: "chat-new",
|
activeTabId: "chat-new",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Prevent race conditions where route-sync recreates a just-deleted chat tab.
|
||||||
|
const deletedChatIdsAtom = atom<Set<number>>(new Set<number>());
|
||||||
|
|
||||||
const sessionStorageAdapter = createJSONStorage<TabsState>(
|
const sessionStorageAdapter = createJSONStorage<TabsState>(
|
||||||
() => (typeof window !== "undefined" ? sessionStorage : undefined) as Storage
|
() => (typeof window !== "undefined" ? sessionStorage : undefined) as Storage
|
||||||
);
|
);
|
||||||
|
|
@ -71,6 +74,10 @@ export const syncChatTabAtom = atom(
|
||||||
set,
|
set,
|
||||||
{ chatId, title, chatUrl }: { chatId: number | null; title?: string; chatUrl?: string }
|
{ chatId, title, chatUrl }: { chatId: number | null; title?: string; chatUrl?: string }
|
||||||
) => {
|
) => {
|
||||||
|
if (chatId && get(deletedChatIdsAtom).has(chatId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const state = get(tabsStateAtom);
|
const state = get(tabsStateAtom);
|
||||||
const tabId = makeChatTabId(chatId);
|
const tabId = makeChatTabId(chatId);
|
||||||
const existing = state.tabs.find((t) => t.id === tabId);
|
const existing = state.tabs.find((t) => t.id === tabId);
|
||||||
|
|
@ -235,6 +242,9 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => {
|
||||||
const idx = state.tabs.findIndex((t) => t.id === tabId);
|
const idx = state.tabs.findIndex((t) => t.id === tabId);
|
||||||
if (idx === -1) return null;
|
if (idx === -1) return null;
|
||||||
|
|
||||||
|
const deletedChatIds = get(deletedChatIdsAtom);
|
||||||
|
set(deletedChatIdsAtom, new Set([...deletedChatIds, chatId]));
|
||||||
|
|
||||||
const remaining = state.tabs.filter((t) => t.id !== tabId);
|
const remaining = state.tabs.filter((t) => t.id !== tabId);
|
||||||
|
|
||||||
// Always keep at least one tab available.
|
// Always keep at least one tab available.
|
||||||
|
|
@ -259,4 +269,5 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => {
|
||||||
/** Reset tabs when switching search spaces. */
|
/** Reset tabs when switching search spaces. */
|
||||||
export const resetTabsAtom = atom(null, (_get, set) => {
|
export const resetTabsAtom = atom(null, (_get, set) => {
|
||||||
set(tabsStateAtom, { ...initialState });
|
set(tabsStateAtom, { ...initialState });
|
||||||
|
set(deletedChatIdsAtom, new Set<number>());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -639,10 +639,13 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
setIsDeletingChat(true);
|
setIsDeletingChat(true);
|
||||||
try {
|
try {
|
||||||
await deleteThread(chatToDelete.id);
|
await deleteThread(chatToDelete.id);
|
||||||
removeChatTab(chatToDelete.id);
|
const fallbackTab = removeChatTab(chatToDelete.id);
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||||
if (currentChatId === chatToDelete.id) {
|
if (currentChatId === chatToDelete.id) {
|
||||||
resetCurrentThread();
|
resetCurrentThread();
|
||||||
|
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
|
||||||
|
router.push(fallbackTab.chatUrl);
|
||||||
|
} else {
|
||||||
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
|
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
|
||||||
if (isOutOfSync) {
|
if (isOutOfSync) {
|
||||||
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
|
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
|
||||||
|
|
@ -651,6 +654,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting thread:", error);
|
console.error("Error deleting thread:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ export function AllPrivateChatsSidebarContent({
|
||||||
setDeletingThreadId(threadId);
|
setDeletingThreadId(threadId);
|
||||||
try {
|
try {
|
||||||
await deleteThread(threadId);
|
await deleteThread(threadId);
|
||||||
removeChatTab(threadId);
|
const fallbackTab = removeChatTab(threadId);
|
||||||
toast.success(t("chat_deleted") || "Chat deleted successfully");
|
toast.success(t("chat_deleted") || "Chat deleted successfully");
|
||||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||||
|
|
@ -170,6 +170,10 @@ export function AllPrivateChatsSidebarContent({
|
||||||
if (currentChatId === threadId) {
|
if (currentChatId === threadId) {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
|
||||||
|
router.push(fallbackTab.chatUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ export function AllSharedChatsSidebarContent({
|
||||||
setDeletingThreadId(threadId);
|
setDeletingThreadId(threadId);
|
||||||
try {
|
try {
|
||||||
await deleteThread(threadId);
|
await deleteThread(threadId);
|
||||||
removeChatTab(threadId);
|
const fallbackTab = removeChatTab(threadId);
|
||||||
toast.success(t("chat_deleted") || "Chat deleted successfully");
|
toast.success(t("chat_deleted") || "Chat deleted successfully");
|
||||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||||
|
|
@ -170,6 +170,10 @@ export function AllSharedChatsSidebarContent({
|
||||||
if (currentChatId === threadId) {
|
if (currentChatId === threadId) {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
|
||||||
|
router.push(fallbackTab.chatUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,15 @@ import { useParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
|
||||||
import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
|
import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
|
||||||
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||||
|
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||||
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
|
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
|
||||||
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||||
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
|
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
|
||||||
import { openDocumentTabAtom } from "@/atoms/tabs/tabs.atom";
|
|
||||||
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
|
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
|
||||||
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
|
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
|
||||||
import type { FolderDisplay } from "@/components/documents/FolderNode";
|
import type { FolderDisplay } from "@/components/documents/FolderNode";
|
||||||
|
|
@ -35,22 +34,13 @@ import {
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Drawer,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerHandle,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerTitle,
|
|
||||||
} from "@/components/ui/drawer";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
|
||||||
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
|
||||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
import { queries } from "@/zero/queries/index";
|
import { queries } from "@/zero/queries/index";
|
||||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||||
|
|
@ -95,12 +85,10 @@ export function DocumentsSidebar({
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
const searchSpaceId = Number(params.search_space_id);
|
||||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||||
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
||||||
const openDocumentTab = useSetAtom(openDocumentTabAtom);
|
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||||
const connectorCount = connectors?.length ?? 0;
|
const connectorCount = connectors?.length ?? 0;
|
||||||
|
|
||||||
const isMobileLayout = useIsMobile();
|
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const debouncedSearch = useDebouncedValue(search, 250);
|
const debouncedSearch = useDebouncedValue(search, 250);
|
||||||
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
|
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
|
||||||
|
|
@ -374,31 +362,6 @@ export function DocumentsSidebar({
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Document popup viewer state (for tree view "Open" and mobile preview)
|
|
||||||
const [viewingDoc, setViewingDoc] = useState<DocumentNodeDoc | null>(null);
|
|
||||||
const [viewingContent, setViewingContent] = useState<string>("");
|
|
||||||
const [viewingLoading, setViewingLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleViewDocumentPopup = useCallback(async (doc: DocumentNodeDoc) => {
|
|
||||||
setViewingDoc(doc);
|
|
||||||
setViewingLoading(true);
|
|
||||||
try {
|
|
||||||
const fullDoc = await documentsApiService.getDocument({ id: doc.id });
|
|
||||||
setViewingContent(fullDoc.content);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[DocumentsSidebar] Failed to fetch document content:", err);
|
|
||||||
setViewingContent("Failed to load document content.");
|
|
||||||
} finally {
|
|
||||||
setViewingLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCloseViewer = useCallback(() => {
|
|
||||||
setViewingDoc(null);
|
|
||||||
setViewingContent("");
|
|
||||||
setViewingLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleChatMention = useCallback(
|
const handleToggleChatMention = useCallback(
|
||||||
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
||||||
if (isMentioned) {
|
if (isMentioned) {
|
||||||
|
|
@ -716,24 +679,18 @@ export function DocumentsSidebar({
|
||||||
onCreateFolder={handleCreateFolder}
|
onCreateFolder={handleCreateFolder}
|
||||||
searchQuery={debouncedSearch.trim() || undefined}
|
searchQuery={debouncedSearch.trim() || undefined}
|
||||||
onPreviewDocument={(doc) => {
|
onPreviewDocument={(doc) => {
|
||||||
if (isMobileLayout) {
|
openEditorPanel({
|
||||||
handleViewDocumentPopup(doc);
|
|
||||||
} else {
|
|
||||||
openDocumentTab({
|
|
||||||
documentId: doc.id,
|
documentId: doc.id,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onEditDocument={(doc) => {
|
onEditDocument={(doc) => {
|
||||||
if (!isMobileLayout) {
|
openEditorPanel({
|
||||||
openDocumentTab({
|
|
||||||
documentId: doc.id,
|
documentId: doc.id,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
|
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
|
||||||
onMoveDocument={handleMoveDocument}
|
onMoveDocument={handleMoveDocument}
|
||||||
|
|
@ -761,26 +718,6 @@ export function DocumentsSidebar({
|
||||||
onConfirm={handleCreateFolderConfirm}
|
onConfirm={handleCreateFolderConfirm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Drawer open={!!viewingDoc} onOpenChange={(open) => !open && handleCloseViewer()}>
|
|
||||||
<DrawerContent className="max-h-[85vh] flex flex-col">
|
|
||||||
<DrawerHandle />
|
|
||||||
<DrawerHeader className="text-left shrink-0">
|
|
||||||
<DrawerTitle className="text-base leading-tight break-words">
|
|
||||||
{viewingDoc?.title}
|
|
||||||
</DrawerTitle>
|
|
||||||
</DrawerHeader>
|
|
||||||
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-6 select-text text-xs [&_h1]:text-base! [&_h1]:mt-3! [&_h2]:text-sm! [&_h2]:mt-2! [&_h3]:text-xs! [&_h3]:mt-2!">
|
|
||||||
{viewingLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Spinner size="lg" className="text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<MarkdownViewer content={viewingContent} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={bulkDeleteConfirmOpen}
|
open={bulkDeleteConfirmOpen}
|
||||||
onOpenChange={(open) => !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)}
|
onOpenChange={(open) => !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { FileText, MessageSquare, Plus, X } from "lucide-react";
|
import { Plus, X } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
activeTabIdAtom,
|
activeTabIdAtom,
|
||||||
|
|
@ -65,7 +65,6 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = tab.id === activeTabId;
|
const isActive = tab.id === activeTabId;
|
||||||
const Icon = tab.type === "document" ? FileText : MessageSquare;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -74,15 +73,16 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
|
||||||
data-tab-id={tab.id}
|
data-tab-id={tab.id}
|
||||||
onClick={() => handleTabClick(tab)}
|
onClick={() => handleTabClick(tab)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex h-full items-center self-stretch gap-1.5 px-3 min-w-0 max-w-[200px] text-xs font-medium border-r transition-colors shrink-0",
|
"group relative flex h-full w-[170px] items-center self-stretch px-3 min-w-0 overflow-hidden text-sm font-medium border-r transition-colors shrink-0",
|
||||||
isActive
|
isActive
|
||||||
? "bg-muted/40 text-foreground"
|
? "bg-muted/40 text-foreground"
|
||||||
: "bg-transparent text-muted-foreground hover:bg-muted/25 hover:text-foreground"
|
: "bg-transparent text-muted-foreground hover:bg-muted/25 hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isActive && <span className="absolute bottom-0 left-0 right-0 h-[2px] bg-primary" />}
|
{isActive && <span className="absolute bottom-0 left-0 right-0 h-[2px] bg-primary" />}
|
||||||
<Icon className="size-3.5 shrink-0" />
|
<span className="block min-w-0 flex-1 truncate text-left transition-[padding-right] duration-150 group-hover:pr-5 group-focus-within:pr-5">
|
||||||
<span className="truncate">{tab.title}</span>
|
{tab.title}
|
||||||
|
</span>
|
||||||
{/* biome-ignore lint/a11y/useSemanticElements: cannot nest button inside button */}
|
{/* biome-ignore lint/a11y/useSemanticElements: cannot nest button inside button */}
|
||||||
<span
|
<span
|
||||||
role="button"
|
role="button"
|
||||||
|
|
@ -95,10 +95,10 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto shrink-0 rounded-sm p-0.5 transition-colors",
|
"absolute right-2 top-1/2 -translate-y-1/2 shrink-0 rounded-sm p-0.5 transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "opacity-60 hover:opacity-100 hover:bg-muted"
|
? "opacity-0 group-hover:opacity-70 group-focus-within:opacity-70 hover:opacity-100 hover:bg-muted"
|
||||||
: "opacity-0 group-hover:opacity-60 hover:opacity-100! hover:bg-muted"
|
: "opacity-0 group-hover:opacity-60 group-focus-within:opacity-60 hover:opacity-100! hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue