mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-16 23:35:46 +00:00
Compare commits
6 Commits
v2.9.6
...
fix/chat-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3e9ac0c4c | ||
|
|
8d3ac7804c | ||
|
|
47abe4d219 | ||
|
|
d72ca3040b | ||
|
|
36a12ea10b | ||
|
|
4777305960 |
331
web/src/app/chat/components/ChatHeader.tsx
Normal file
331
web/src/app/chat/components/ChatHeader.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import { cn, noProp } from "@/lib/utils";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { useCallback, useMemo, useState, useEffect } from "react";
|
||||
import ShareChatSessionModal from "@/app/chat/components/modal/ShareChatSessionModal";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
|
||||
import useChatSessions from "@/hooks/useChatSessions";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import {
|
||||
handleMoveOperation,
|
||||
shouldShowMoveModal,
|
||||
showErrorNotification,
|
||||
} from "@/sections/sidebar/sidebarUtils";
|
||||
import { LOCAL_STORAGE_KEYS } from "@/sections/sidebar/constants";
|
||||
import { deleteChatSession } from "@/app/chat/services/lib";
|
||||
import { useRouter } from "next/navigation";
|
||||
import MoveCustomAgentChatModal from "@/components/modals/MoveCustomAgentChatModal";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import { PopoverMenu } from "@/refresh-components/Popover";
|
||||
import { PopoverSearchInput } from "@/sections/sidebar/ChatButton";
|
||||
import SimplePopover from "@/refresh-components/SimplePopover";
|
||||
import { useAppSidebarContext } from "@/refresh-components/contexts/AppSidebarContext";
|
||||
import useScreenSize from "@/hooks/useScreenSize";
|
||||
import {
|
||||
SvgFolderIn,
|
||||
SvgMoreHorizontal,
|
||||
SvgShare,
|
||||
SvgSidebar,
|
||||
SvgTrash,
|
||||
} from "@opal/icons";
|
||||
import { useSettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import { StickyHeader } from "@/layouts/app-layouts";
|
||||
|
||||
/**
|
||||
* Chat Header Component
|
||||
*
|
||||
* Sticky header for chat pages with share, move, and delete actions.
|
||||
* Rendered inside ChatUI's scroll container for sticky behavior.
|
||||
*
|
||||
* Features:
|
||||
* - Sticky positioning within scroll container
|
||||
* - Transparent on large screens, solid on small screens
|
||||
* - Share chat functionality
|
||||
* - Move chat to project (with confirmation for custom agents)
|
||||
* - Delete chat with confirmation
|
||||
* - Responsive sidebar toggle for small screens
|
||||
*/
|
||||
export default function ChatHeader() {
|
||||
const settings = useSettingsContext();
|
||||
const { isMobile } = useScreenSize();
|
||||
const { setFolded } = useAppSidebarContext();
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [showMoveCustomAgentModal, setShowMoveCustomAgentModal] =
|
||||
useState(false);
|
||||
const [pendingMoveProjectId, setPendingMoveProjectId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [showMoveOptions, setShowMoveOptions] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [popoverItems, setPopoverItems] = useState<React.ReactNode[]>([]);
|
||||
const {
|
||||
projects,
|
||||
fetchProjects,
|
||||
refreshCurrentProjectDetails,
|
||||
currentProjectId,
|
||||
} = useProjectsContext();
|
||||
const { currentChatSession, refreshChatSessions, currentChatSessionId } =
|
||||
useChatSessions();
|
||||
const { popup, setPopup } = usePopup();
|
||||
const router = useRouter();
|
||||
|
||||
const customHeaderContent =
|
||||
settings?.enterpriseSettings?.custom_header_content;
|
||||
|
||||
const availableProjects = useMemo(() => {
|
||||
if (!projects) return [];
|
||||
return projects.filter((project) => project.id !== currentProjectId);
|
||||
}, [projects, currentProjectId]);
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!searchTerm) return availableProjects;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return availableProjects.filter((project) =>
|
||||
project.name.toLowerCase().includes(term)
|
||||
);
|
||||
}, [availableProjects, searchTerm]);
|
||||
|
||||
const resetMoveState = useCallback(() => {
|
||||
setShowMoveOptions(false);
|
||||
setSearchTerm("");
|
||||
setPendingMoveProjectId(null);
|
||||
setShowMoveCustomAgentModal(false);
|
||||
}, []);
|
||||
|
||||
const performMove = useCallback(
|
||||
async (targetProjectId: number) => {
|
||||
if (!currentChatSession) return;
|
||||
try {
|
||||
await handleMoveOperation(
|
||||
{
|
||||
chatSession: currentChatSession,
|
||||
targetProjectId,
|
||||
refreshChatSessions,
|
||||
refreshCurrentProjectDetails,
|
||||
fetchProjects,
|
||||
currentProjectId,
|
||||
},
|
||||
setPopup
|
||||
);
|
||||
resetMoveState();
|
||||
setPopoverOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to move chat session:", error);
|
||||
}
|
||||
},
|
||||
[
|
||||
currentChatSession,
|
||||
refreshChatSessions,
|
||||
refreshCurrentProjectDetails,
|
||||
fetchProjects,
|
||||
currentProjectId,
|
||||
setPopup,
|
||||
resetMoveState,
|
||||
]
|
||||
);
|
||||
|
||||
const handleMoveClick = useCallback(
|
||||
(projectId: number) => {
|
||||
if (!currentChatSession) return;
|
||||
if (shouldShowMoveModal(currentChatSession)) {
|
||||
setPendingMoveProjectId(projectId);
|
||||
setShowMoveCustomAgentModal(true);
|
||||
return;
|
||||
}
|
||||
void performMove(projectId);
|
||||
},
|
||||
[currentChatSession, performMove]
|
||||
);
|
||||
|
||||
const handleDeleteChat = useCallback(async () => {
|
||||
if (!currentChatSession) return;
|
||||
try {
|
||||
const response = await deleteChatSession(currentChatSession.id);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete chat session");
|
||||
}
|
||||
await Promise.all([refreshChatSessions(), fetchProjects()]);
|
||||
router.replace("/chat");
|
||||
setDeleteModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete chat:", error);
|
||||
showErrorNotification(
|
||||
setPopup,
|
||||
"Failed to delete chat. Please try again."
|
||||
);
|
||||
}
|
||||
}, [
|
||||
currentChatSession,
|
||||
refreshChatSessions,
|
||||
fetchProjects,
|
||||
router,
|
||||
setPopup,
|
||||
]);
|
||||
|
||||
const setDeleteConfirmationModalOpen = useCallback((open: boolean) => {
|
||||
setDeleteModalOpen(open);
|
||||
if (open) {
|
||||
setPopoverOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const items = showMoveOptions
|
||||
? [
|
||||
<PopoverSearchInput
|
||||
key="search"
|
||||
setShowMoveOptions={setShowMoveOptions}
|
||||
onSearch={setSearchTerm}
|
||||
/>,
|
||||
...filteredProjects.map((project) => (
|
||||
<LineItem
|
||||
key={project.id}
|
||||
icon={SvgFolderIn}
|
||||
onClick={noProp(() => handleMoveClick(project.id))}
|
||||
>
|
||||
{project.name}
|
||||
</LineItem>
|
||||
)),
|
||||
]
|
||||
: [
|
||||
<LineItem
|
||||
key="move"
|
||||
icon={SvgFolderIn}
|
||||
onClick={noProp(() => setShowMoveOptions(true))}
|
||||
>
|
||||
Move to Project
|
||||
</LineItem>,
|
||||
<LineItem
|
||||
key="delete"
|
||||
icon={SvgTrash}
|
||||
onClick={noProp(() => setDeleteConfirmationModalOpen(true))}
|
||||
danger
|
||||
>
|
||||
Delete
|
||||
</LineItem>,
|
||||
];
|
||||
|
||||
setPopoverItems(items);
|
||||
}, [
|
||||
showMoveOptions,
|
||||
filteredProjects,
|
||||
currentChatSession,
|
||||
setDeleteConfirmationModalOpen,
|
||||
handleMoveClick,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{popup}
|
||||
|
||||
{showShareModal && currentChatSession && (
|
||||
<ShareChatSessionModal
|
||||
chatSession={currentChatSession}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showMoveCustomAgentModal && (
|
||||
<MoveCustomAgentChatModal
|
||||
onCancel={resetMoveState}
|
||||
onConfirm={async (doNotShowAgain: boolean) => {
|
||||
if (doNotShowAgain && typeof window !== "undefined") {
|
||||
window.localStorage.setItem(
|
||||
LOCAL_STORAGE_KEYS.HIDE_MOVE_CUSTOM_AGENT_MODAL,
|
||||
"true"
|
||||
);
|
||||
}
|
||||
if (pendingMoveProjectId != null) {
|
||||
await performMove(pendingMoveProjectId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteModalOpen && (
|
||||
<ConfirmationModalLayout
|
||||
title="Delete Chat"
|
||||
icon={SvgTrash}
|
||||
onClose={() => setDeleteModalOpen(false)}
|
||||
submit={
|
||||
<Button danger onClick={handleDeleteChat}>
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Are you sure you want to delete this chat? This action cannot be
|
||||
undone.
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{(isMobile || customHeaderContent || currentChatSessionId) && (
|
||||
<StickyHeader
|
||||
className={cn(!customHeaderContent && "2xl:bg-transparent")}
|
||||
>
|
||||
{/* Left - contains the icon-button to fold the AppSidebar on small screens */}
|
||||
<div className="flex-1">
|
||||
<IconButton
|
||||
icon={SvgSidebar}
|
||||
onClick={() => setFolded(false)}
|
||||
className={cn(!isMobile && "invisible")}
|
||||
internal
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center - contains the custom-header-content */}
|
||||
<div className="flex-1 flex flex-col items-center overflow-hidden">
|
||||
<Text
|
||||
as="p"
|
||||
text03
|
||||
mainUiBody
|
||||
className="text-center break-words w-full"
|
||||
>
|
||||
{customHeaderContent}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Right - contains the share and more-options buttons */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex flex-row items-center justify-end px-1",
|
||||
!currentChatSessionId && "invisible"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
leftIcon={SvgShare}
|
||||
transient={showShareModal}
|
||||
tertiary
|
||||
onClick={() => setShowShareModal(true)}
|
||||
>
|
||||
Share Chat
|
||||
</Button>
|
||||
<SimplePopover
|
||||
trigger={
|
||||
<IconButton
|
||||
icon={SvgMoreHorizontal}
|
||||
className="ml-2"
|
||||
transient={popoverOpen}
|
||||
tertiary
|
||||
/>
|
||||
}
|
||||
onOpenChange={(state) => {
|
||||
setPopoverOpen(state);
|
||||
if (!state) setShowMoveOptions(false);
|
||||
}}
|
||||
side="bottom"
|
||||
align="end"
|
||||
>
|
||||
<PopoverMenu>{popoverItems}</PopoverMenu>
|
||||
</SimplePopover>
|
||||
</div>
|
||||
</StickyHeader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -640,20 +640,18 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
|
||||
)}
|
||||
|
||||
{/* ChatUI */}
|
||||
{!!currentChatSessionId && (
|
||||
<ChatUI
|
||||
ref={chatUiRef}
|
||||
liveAssistant={liveAssistant}
|
||||
llmManager={llmManager}
|
||||
deepResearchEnabled={deepResearchEnabled}
|
||||
currentMessageFiles={currentMessageFiles}
|
||||
setPresentingDocument={setPresentingDocument}
|
||||
onSubmit={onSubmit}
|
||||
onMessageSelection={onMessageSelection}
|
||||
stopGenerating={stopGenerating}
|
||||
handleResubmitLastMessage={handleResubmitLastMessage}
|
||||
/>
|
||||
)}
|
||||
<ChatUI
|
||||
ref={chatUiRef}
|
||||
liveAssistant={liveAssistant}
|
||||
llmManager={llmManager}
|
||||
deepResearchEnabled={deepResearchEnabled}
|
||||
currentMessageFiles={currentMessageFiles}
|
||||
setPresentingDocument={setPresentingDocument}
|
||||
onSubmit={onSubmit}
|
||||
onMessageSelection={onMessageSelection}
|
||||
stopGenerating={stopGenerating}
|
||||
handleResubmitLastMessage={handleResubmitLastMessage}
|
||||
/>
|
||||
|
||||
{!currentChatSessionId && !currentProjectId && (
|
||||
<div className="w-full flex-1 flex flex-col items-center justify-end">
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
/**
|
||||
* App Page Layout Component
|
||||
* App Page Layout Components
|
||||
*
|
||||
* Primary layout component for chat/application pages. Handles white-labeling,
|
||||
* chat session actions (share, move, delete), and responsive header/footer rendering.
|
||||
*
|
||||
* Features:
|
||||
* - Custom header/footer content from enterprise settings
|
||||
* - Share chat functionality
|
||||
* - Move chat to project (with confirmation for custom agents)
|
||||
* - Delete chat with confirmation
|
||||
* - Mobile-responsive sidebar toggle
|
||||
* - Conditional rendering based on chat state
|
||||
* Layout components for chat/application pages including:
|
||||
* - AppRoot: Main layout wrapper with custom footer
|
||||
* - StickyHeader: Reusable sticky header wrapper
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import AppLayouts from "@/layouts/app-layouts";
|
||||
* import AppLayouts, { StickyHeader } from "@/layouts/app-layouts";
|
||||
*
|
||||
* export default function ChatPage() {
|
||||
* return (
|
||||
@@ -28,39 +21,9 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { cn, ensureHrefProtocol, noProp } from "@/lib/utils";
|
||||
import { cn, ensureHrefProtocol } from "@/lib/utils";
|
||||
import type { Components } from "react-markdown";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { useCallback, useMemo, useState, useEffect } from "react";
|
||||
import ShareChatSessionModal from "@/app/chat/components/modal/ShareChatSessionModal";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
|
||||
import useChatSessions from "@/hooks/useChatSessions";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import {
|
||||
handleMoveOperation,
|
||||
shouldShowMoveModal,
|
||||
showErrorNotification,
|
||||
} from "@/sections/sidebar/sidebarUtils";
|
||||
import { LOCAL_STORAGE_KEYS } from "@/sections/sidebar/constants";
|
||||
import { deleteChatSession } from "@/app/chat/services/lib";
|
||||
import { useRouter } from "next/navigation";
|
||||
import MoveCustomAgentChatModal from "@/components/modals/MoveCustomAgentChatModal";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import { PopoverMenu } from "@/refresh-components/Popover";
|
||||
import { PopoverSearchInput } from "@/sections/sidebar/ChatButton";
|
||||
import SimplePopover from "@/refresh-components/SimplePopover";
|
||||
import { useAppSidebarContext } from "@/refresh-components/contexts/AppSidebarContext";
|
||||
import useScreenSize from "@/hooks/useScreenSize";
|
||||
import {
|
||||
SvgFolderIn,
|
||||
SvgMoreHorizontal,
|
||||
SvgShare,
|
||||
SvgSidebar,
|
||||
SvgTrash,
|
||||
} from "@opal/icons";
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import { useSettingsContext } from "@/components/settings/SettingsProvider";
|
||||
|
||||
@@ -89,282 +52,27 @@ const footerMarkdownComponents = {
|
||||
},
|
||||
} satisfies Partial<Components>;
|
||||
|
||||
function AppHeader() {
|
||||
const settings = useSettingsContext();
|
||||
const { isMobile } = useScreenSize();
|
||||
const { setFolded } = useAppSidebarContext();
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [showMoveCustomAgentModal, setShowMoveCustomAgentModal] =
|
||||
useState(false);
|
||||
const [pendingMoveProjectId, setPendingMoveProjectId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [showMoveOptions, setShowMoveOptions] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [popoverItems, setPopoverItems] = useState<React.ReactNode[]>([]);
|
||||
const {
|
||||
projects,
|
||||
fetchProjects,
|
||||
refreshCurrentProjectDetails,
|
||||
currentProjectId,
|
||||
} = useProjectsContext();
|
||||
const { currentChatSession, refreshChatSessions, currentChatSessionId } =
|
||||
useChatSessions();
|
||||
const { popup, setPopup } = usePopup();
|
||||
const router = useRouter();
|
||||
|
||||
const customHeaderContent =
|
||||
settings?.enterpriseSettings?.custom_header_content;
|
||||
|
||||
const availableProjects = useMemo(() => {
|
||||
if (!projects) return [];
|
||||
return projects.filter((project) => project.id !== currentProjectId);
|
||||
}, [projects, currentProjectId]);
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!searchTerm) return availableProjects;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return availableProjects.filter((project) =>
|
||||
project.name.toLowerCase().includes(term)
|
||||
);
|
||||
}, [availableProjects, searchTerm]);
|
||||
|
||||
const resetMoveState = useCallback(() => {
|
||||
setShowMoveOptions(false);
|
||||
setSearchTerm("");
|
||||
setPendingMoveProjectId(null);
|
||||
setShowMoveCustomAgentModal(false);
|
||||
}, []);
|
||||
|
||||
const performMove = useCallback(
|
||||
async (targetProjectId: number) => {
|
||||
if (!currentChatSession) return;
|
||||
try {
|
||||
await handleMoveOperation(
|
||||
{
|
||||
chatSession: currentChatSession,
|
||||
targetProjectId,
|
||||
refreshChatSessions,
|
||||
refreshCurrentProjectDetails,
|
||||
fetchProjects,
|
||||
currentProjectId,
|
||||
},
|
||||
setPopup
|
||||
);
|
||||
resetMoveState();
|
||||
setPopoverOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to move chat session:", error);
|
||||
}
|
||||
},
|
||||
[
|
||||
currentChatSession,
|
||||
refreshChatSessions,
|
||||
refreshCurrentProjectDetails,
|
||||
fetchProjects,
|
||||
currentProjectId,
|
||||
setPopup,
|
||||
resetMoveState,
|
||||
]
|
||||
);
|
||||
|
||||
const handleMoveClick = useCallback(
|
||||
(projectId: number) => {
|
||||
if (!currentChatSession) return;
|
||||
if (shouldShowMoveModal(currentChatSession)) {
|
||||
setPendingMoveProjectId(projectId);
|
||||
setShowMoveCustomAgentModal(true);
|
||||
return;
|
||||
}
|
||||
void performMove(projectId);
|
||||
},
|
||||
[currentChatSession, performMove]
|
||||
);
|
||||
|
||||
const handleDeleteChat = useCallback(async () => {
|
||||
if (!currentChatSession) return;
|
||||
try {
|
||||
const response = await deleteChatSession(currentChatSession.id);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete chat session");
|
||||
}
|
||||
await Promise.all([refreshChatSessions(), fetchProjects()]);
|
||||
router.replace("/chat");
|
||||
setDeleteModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete chat:", error);
|
||||
showErrorNotification(
|
||||
setPopup,
|
||||
"Failed to delete chat. Please try again."
|
||||
);
|
||||
}
|
||||
}, [
|
||||
currentChatSession,
|
||||
refreshChatSessions,
|
||||
fetchProjects,
|
||||
router,
|
||||
setPopup,
|
||||
]);
|
||||
|
||||
const setDeleteConfirmationModalOpen = useCallback((open: boolean) => {
|
||||
setDeleteModalOpen(open);
|
||||
if (open) {
|
||||
setPopoverOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const items = showMoveOptions
|
||||
? [
|
||||
<PopoverSearchInput
|
||||
key="search"
|
||||
setShowMoveOptions={setShowMoveOptions}
|
||||
onSearch={setSearchTerm}
|
||||
/>,
|
||||
...filteredProjects.map((project) => (
|
||||
<LineItem
|
||||
key={project.id}
|
||||
icon={SvgFolderIn}
|
||||
onClick={noProp(() => handleMoveClick(project.id))}
|
||||
>
|
||||
{project.name}
|
||||
</LineItem>
|
||||
)),
|
||||
]
|
||||
: [
|
||||
<LineItem
|
||||
key="move"
|
||||
icon={SvgFolderIn}
|
||||
onClick={noProp(() => setShowMoveOptions(true))}
|
||||
>
|
||||
Move to Project
|
||||
</LineItem>,
|
||||
<LineItem
|
||||
key="delete"
|
||||
icon={SvgTrash}
|
||||
onClick={noProp(() => setDeleteConfirmationModalOpen(true))}
|
||||
danger
|
||||
>
|
||||
Delete
|
||||
</LineItem>,
|
||||
];
|
||||
|
||||
setPopoverItems(items);
|
||||
}, [
|
||||
showMoveOptions,
|
||||
filteredProjects,
|
||||
currentChatSession,
|
||||
setDeleteConfirmationModalOpen,
|
||||
handleMoveClick,
|
||||
]);
|
||||
interface StickyHeaderProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky Header Component
|
||||
*
|
||||
* Reusable sticky header wrapper for chat pages.
|
||||
* Provides consistent sticky positioning and base styling.
|
||||
*/
|
||||
function StickyHeader({ children, className }: StickyHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
{popup}
|
||||
|
||||
{showShareModal && currentChatSession && (
|
||||
<ShareChatSessionModal
|
||||
chatSession={currentChatSession}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
/>
|
||||
<header
|
||||
className={cn(
|
||||
"sticky top-0 z-sticky w-full flex flex-row justify-center items-center py-3 px-4 h-16 bg-background-tint-01",
|
||||
className
|
||||
)}
|
||||
|
||||
{showMoveCustomAgentModal && (
|
||||
<MoveCustomAgentChatModal
|
||||
onCancel={resetMoveState}
|
||||
onConfirm={async (doNotShowAgain: boolean) => {
|
||||
if (doNotShowAgain && typeof window !== "undefined") {
|
||||
window.localStorage.setItem(
|
||||
LOCAL_STORAGE_KEYS.HIDE_MOVE_CUSTOM_AGENT_MODAL,
|
||||
"true"
|
||||
);
|
||||
}
|
||||
if (pendingMoveProjectId != null) {
|
||||
await performMove(pendingMoveProjectId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteModalOpen && (
|
||||
<ConfirmationModalLayout
|
||||
title="Delete Chat"
|
||||
icon={SvgTrash}
|
||||
onClose={() => setDeleteModalOpen(false)}
|
||||
submit={
|
||||
<Button danger onClick={handleDeleteChat}>
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Are you sure you want to delete this chat? This action cannot be
|
||||
undone.
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{(isMobile || customHeaderContent || currentChatSessionId) && (
|
||||
<header className="w-full flex flex-row justify-center items-center py-3 px-4 h-16">
|
||||
{/* Left - contains the icon-button to fold the AppSidebar on mobile */}
|
||||
<div className="flex-1">
|
||||
<IconButton
|
||||
icon={SvgSidebar}
|
||||
onClick={() => setFolded(false)}
|
||||
className={cn(!isMobile && "invisible")}
|
||||
internal
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center - contains the custom-header-content */}
|
||||
<div className="flex-1 flex flex-col items-center overflow-hidden">
|
||||
<Text
|
||||
as="p"
|
||||
text03
|
||||
mainUiBody
|
||||
className="text-center break-words w-full"
|
||||
>
|
||||
{customHeaderContent}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Right - contains the share and more-options buttons */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex flex-row items-center justify-end px-1",
|
||||
!currentChatSessionId && "invisible"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
leftIcon={SvgShare}
|
||||
transient={showShareModal}
|
||||
tertiary
|
||||
onClick={() => setShowShareModal(true)}
|
||||
>
|
||||
Share Chat
|
||||
</Button>
|
||||
<SimplePopover
|
||||
trigger={
|
||||
<IconButton
|
||||
icon={SvgMoreHorizontal}
|
||||
className="ml-2"
|
||||
transient={popoverOpen}
|
||||
tertiary
|
||||
/>
|
||||
}
|
||||
onOpenChange={(state) => {
|
||||
setPopoverOpen(state);
|
||||
if (!state) setShowMoveOptions(false);
|
||||
}}
|
||||
side="bottom"
|
||||
align="end"
|
||||
>
|
||||
<PopoverMenu>{popoverItems}</PopoverMenu>
|
||||
</SimplePopover>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
</>
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -391,14 +99,11 @@ function AppFooter() {
|
||||
/**
|
||||
* App Root Component
|
||||
*
|
||||
* Wraps chat pages with white-labeling chrome (custom header/footer) and
|
||||
* provides chat session management actions.
|
||||
* Wraps chat pages with custom footer.
|
||||
*
|
||||
* Layout Structure:
|
||||
* ```
|
||||
* ┌──────────────────────────────────┐
|
||||
* │ Header (custom or with actions) │
|
||||
* ├──────────────────────────────────┤
|
||||
* │ │
|
||||
* │ Content Area (children) │
|
||||
* │ │
|
||||
@@ -407,36 +112,8 @@ function AppFooter() {
|
||||
* └──────────────────────────────────┘
|
||||
* ```
|
||||
*
|
||||
* Features:
|
||||
* - Renders custom header content from enterprise settings
|
||||
* - Shows sidebar toggle on mobile
|
||||
* - "Share Chat" button for current chat session
|
||||
* - Kebab menu with "Move to Project" and "Delete" options
|
||||
* - Move confirmation modal for custom agent chats
|
||||
* - Delete confirmation modal
|
||||
* - Renders custom footer disclaimer from enterprise settings
|
||||
*
|
||||
* State Management:
|
||||
* - Manages multiple modals (share, move, delete)
|
||||
* - Handles project search/filtering in move modal
|
||||
* - Integrates with projects context for chat operations
|
||||
* - Uses settings context for white-labeling
|
||||
* - Uses chat sessions hook for current session
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic usage in a chat page
|
||||
* <AppLayouts.Root>
|
||||
* <ChatInterface />
|
||||
* </AppLayouts.Root>
|
||||
*
|
||||
* // The header will show:
|
||||
* // - Mobile: Sidebar toggle button
|
||||
* // - Desktop: Share button + kebab menu (when chat session exists)
|
||||
* // - Custom header text (if configured)
|
||||
*
|
||||
* // The footer will show custom disclaimer (if configured)
|
||||
* ```
|
||||
* Note: ChatHeader is rendered inside ChatUI's scroll container
|
||||
* for sticky behavior, not in this root component.
|
||||
*/
|
||||
export interface AppRootProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -448,11 +125,10 @@ function AppRoot({ children }: AppRootProps) {
|
||||
breakout of their immediate containers using cqw units.
|
||||
*/
|
||||
<div className="@container flex flex-col h-full w-full">
|
||||
<AppHeader />
|
||||
<div className="flex-1 overflow-auto h-full w-full">{children}</div>
|
||||
<AppFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { AppRoot as Root };
|
||||
export { AppRoot as Root, StickyHeader };
|
||||
|
||||
@@ -33,6 +33,8 @@ import { useUser } from "@/components/user/UserProvider";
|
||||
import { HORIZON_DISTANCE_PX } from "@/lib/constants";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
import { SvgChevronDown } from "@opal/icons";
|
||||
import ChatHeader from "@/app/chat/components/ChatHeader";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ChatUIHandle {
|
||||
scrollToBottom: () => boolean;
|
||||
@@ -234,7 +236,12 @@ const ChatUI = React.memo(
|
||||
if (!liveAssistant) return <div className="flex-1" />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 w-full relative overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col w-full relative overflow-hidden",
|
||||
currentChatSessionId && "flex-1"
|
||||
)}
|
||||
>
|
||||
{aboveHorizon && (
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 z-sticky">
|
||||
<IconButton icon={SvgChevronDown} onClick={scrollToBottom} />
|
||||
@@ -246,119 +253,122 @@ const ChatUI = React.memo(
|
||||
<div
|
||||
key={currentChatSessionId}
|
||||
ref={scrollContainerRef}
|
||||
className="flex flex-1 justify-center min-h-0 overflow-y-auto overflow-x-hidden default-scrollbar"
|
||||
className="flex flex-col flex-1 min-h-0 overflow-y-auto overflow-x-hidden default-scrollbar"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="w-[min(50rem,100%)] px-4">
|
||||
{messages.map((message, i) => {
|
||||
const messageReactComponentKey = `message-${message.nodeId}`;
|
||||
const parentMessage = message.parentNodeId
|
||||
? messageTree?.get(message.parentNodeId)
|
||||
: null;
|
||||
<ChatHeader />
|
||||
{currentChatSessionId && (
|
||||
<div className="w-[min(50rem,100%)] px-4 mx-auto">
|
||||
{messages.map((message, i) => {
|
||||
const messageReactComponentKey = `message-${message.nodeId}`;
|
||||
const parentMessage = message.parentNodeId
|
||||
? messageTree?.get(message.parentNodeId)
|
||||
: null;
|
||||
|
||||
if (message.type === "user") {
|
||||
const nextMessage =
|
||||
messages.length > i + 1 ? messages[i + 1] : null;
|
||||
if (message.type === "user") {
|
||||
const nextMessage =
|
||||
messages.length > i + 1 ? messages[i + 1] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={messageReactComponentKey}
|
||||
key={messageReactComponentKey}
|
||||
>
|
||||
<HumanMessage
|
||||
disableSwitchingForStreaming={
|
||||
(nextMessage && nextMessage.is_generating) || false
|
||||
}
|
||||
stopGenerating={stopGenerating}
|
||||
content={message.message}
|
||||
files={message.files}
|
||||
messageId={message.messageId}
|
||||
nodeId={message.nodeId}
|
||||
onEdit={handleEditWithMessageId}
|
||||
otherMessagesCanSwitchTo={
|
||||
parentMessage?.childrenNodeIds ?? emptyChildrenIds
|
||||
}
|
||||
onMessageSelection={onMessageSelection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (message.type === "assistant") {
|
||||
if ((error || loadError) && i === messages.length - 1) {
|
||||
return (
|
||||
<div key={`error-${message.nodeId}`} className="p-4">
|
||||
<ErrorBanner
|
||||
resubmit={handleResubmitLastMessage}
|
||||
error={error || loadError || ""}
|
||||
errorCode={message.errorCode || undefined}
|
||||
isRetryable={message.isRetryable ?? true}
|
||||
details={message.errorDetails || undefined}
|
||||
stackTrace={message.stackTrace || undefined}
|
||||
<div
|
||||
id={messageReactComponentKey}
|
||||
key={messageReactComponentKey}
|
||||
>
|
||||
<HumanMessage
|
||||
disableSwitchingForStreaming={
|
||||
(nextMessage && nextMessage.is_generating) || false
|
||||
}
|
||||
stopGenerating={stopGenerating}
|
||||
content={message.message}
|
||||
files={message.files}
|
||||
messageId={message.messageId}
|
||||
nodeId={message.nodeId}
|
||||
onEdit={handleEditWithMessageId}
|
||||
otherMessagesCanSwitchTo={
|
||||
parentMessage?.childrenNodeIds ?? emptyChildrenIds
|
||||
}
|
||||
onMessageSelection={onMessageSelection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (message.type === "assistant") {
|
||||
if ((error || loadError) && i === messages.length - 1) {
|
||||
return (
|
||||
<div key={`error-${message.nodeId}`} className="p-4">
|
||||
<ErrorBanner
|
||||
resubmit={handleResubmitLastMessage}
|
||||
error={error || loadError || ""}
|
||||
errorCode={message.errorCode || undefined}
|
||||
isRetryable={message.isRetryable ?? true}
|
||||
details={message.errorDetails || undefined}
|
||||
stackTrace={message.stackTrace || undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: it's fine to use the previous entry in messageHistory
|
||||
// since this is a "parsed" version of the message tree
|
||||
// so the previous message is guaranteed to be the parent of the current message
|
||||
const previousMessage = i !== 0 ? messages[i - 1] : null;
|
||||
const chatStateData = {
|
||||
assistant: liveAssistant,
|
||||
docs: message.documents ?? emptyDocs,
|
||||
citations: message.citations,
|
||||
setPresentingDocument,
|
||||
overriddenModel: llmManager.currentLlm?.modelName,
|
||||
researchType: message.researchType,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
id={`message-${message.nodeId}`}
|
||||
key={messageReactComponentKey}
|
||||
>
|
||||
<AIMessage
|
||||
rawPackets={message.packets}
|
||||
chatState={chatStateData}
|
||||
nodeId={message.nodeId}
|
||||
messageId={message.messageId}
|
||||
currentFeedback={message.currentFeedback}
|
||||
llmManager={llmManager}
|
||||
otherMessagesCanSwitchTo={
|
||||
parentMessage?.childrenNodeIds ?? emptyChildrenIds
|
||||
}
|
||||
onMessageSelection={onMessageSelection}
|
||||
onRegenerate={createRegenerator}
|
||||
parentMessage={previousMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
// NOTE: it's fine to use the previous entry in messageHistory
|
||||
// since this is a "parsed" version of the message tree
|
||||
// so the previous message is guaranteed to be the parent of the current message
|
||||
const previousMessage = i !== 0 ? messages[i - 1] : null;
|
||||
const chatStateData = {
|
||||
assistant: liveAssistant,
|
||||
docs: message.documents ?? emptyDocs,
|
||||
citations: message.citations,
|
||||
setPresentingDocument,
|
||||
overriddenModel: llmManager.currentLlm?.modelName,
|
||||
researchType: message.researchType,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
id={`message-${message.nodeId}`}
|
||||
key={messageReactComponentKey}
|
||||
>
|
||||
<AIMessage
|
||||
rawPackets={message.packets}
|
||||
chatState={chatStateData}
|
||||
nodeId={message.nodeId}
|
||||
messageId={message.messageId}
|
||||
currentFeedback={message.currentFeedback}
|
||||
llmManager={llmManager}
|
||||
otherMessagesCanSwitchTo={
|
||||
parentMessage?.childrenNodeIds ?? emptyChildrenIds
|
||||
}
|
||||
onMessageSelection={onMessageSelection}
|
||||
onRegenerate={createRegenerator}
|
||||
parentMessage={previousMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{(((error !== null || loadError !== null) &&
|
||||
messages[messages.length - 1]?.type === "user") ||
|
||||
messages[messages.length - 1]?.type === "error") && (
|
||||
<div className="p-4">
|
||||
<ErrorBanner
|
||||
resubmit={handleResubmitLastMessage}
|
||||
error={error || loadError || ""}
|
||||
errorCode={
|
||||
messages[messages.length - 1]?.errorCode || undefined
|
||||
}
|
||||
isRetryable={
|
||||
messages[messages.length - 1]?.isRetryable ?? true
|
||||
}
|
||||
details={
|
||||
messages[messages.length - 1]?.errorDetails || undefined
|
||||
}
|
||||
stackTrace={
|
||||
messages[messages.length - 1]?.stackTrace || undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(((error !== null || loadError !== null) &&
|
||||
messages[messages.length - 1]?.type === "user") ||
|
||||
messages[messages.length - 1]?.type === "error") && (
|
||||
<div className="p-4">
|
||||
<ErrorBanner
|
||||
resubmit={handleResubmitLastMessage}
|
||||
error={error || loadError || ""}
|
||||
errorCode={
|
||||
messages[messages.length - 1]?.errorCode || undefined
|
||||
}
|
||||
isRetryable={
|
||||
messages[messages.length - 1]?.isRetryable ?? true
|
||||
}
|
||||
details={
|
||||
messages[messages.length - 1]?.errorDetails || undefined
|
||||
}
|
||||
stackTrace={
|
||||
messages[messages.length - 1]?.stackTrace || undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={endDivRef} />
|
||||
</div>
|
||||
<div ref={endDivRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user