diff --git a/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContainer.tsx b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContainer.tsx index bdd2bcd64..1990846f9 100644 --- a/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContainer.tsx +++ b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContainer.tsx @@ -5,22 +5,24 @@ import { useScroll } from 'ahooks'; interface ChatContainerProps {} -export const ChatContainer: React.FC = React.memo(({}) => { - const chatContentRef = useRef(null); - const scroll = useScroll(chatContentRef); +export const ChatContainer = React.memo( + React.forwardRef((props, ref) => { + const chatContentRef = useRef(null); + const scroll = useScroll(chatContentRef); - const showScrollOverflow = useMemo(() => { - if (!chatContentRef.current || !scroll) return false; - const trigger = 25; - return scroll.top > trigger; - }, [chatContentRef, scroll?.top]); + const showScrollOverflow = useMemo(() => { + if (!chatContentRef.current || !scroll) return false; + const trigger = 25; + return scroll.top > trigger; + }, [chatContentRef, scroll?.top]); - return ( -
- - -
- ); -}); + return ( +
+ + +
+ ); + }) +); ChatContainer.displayName = 'ChatContainer'; diff --git a/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_Thought.tsx b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_Thought.tsx index 7a9e4705e..e41193cea 100644 --- a/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_Thought.tsx +++ b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_Thought.tsx @@ -10,8 +10,6 @@ import { CircleSpinnerLoader } from '@/components/loaders/CircleSpinnerLoader'; import { Text } from '@/components/text'; import { createStyles } from 'antd-style'; import { AppMaterialIcons } from '@/components'; -import { useHotkeys } from 'react-hotkeys-hook'; -import { faker } from '@faker-js/faker'; import { PillContainer } from './ChatResponseMessage_ThoughtPills'; export const ChatResponseMessage_Thought: React.FC = React.memo( @@ -21,19 +19,6 @@ export const ChatResponseMessage_Thought: React.FC = R const { styles, cx } = useStyles(); const hasPills = thought_pills && thought_pills.length > 0; - const [myPills, setMyPills] = useState(thought_pills || []); - - useHotkeys('j', () => { - const fourRandomPills: BusterChatMessage_thoughtPill[] = Array.from({ length: 5 }, () => { - return { - text: faker.lorem.word(), - type: 'term', - id: faker.string.uuid() - }; - }); - setMyPills(fourRandomPills); - }); - return ( @@ -51,7 +36,7 @@ export const ChatResponseMessage_Thought: React.FC = R - + diff --git a/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_ThoughtPills.tsx b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_ThoughtPills.tsx index 43cc2d590..0b6db0a06 100644 --- a/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_ThoughtPills.tsx +++ b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_ThoughtPills.tsx @@ -3,21 +3,21 @@ import { BusterChatMessage_thoughtPill } from '@/api/buster_socket/chats'; import { createStyles } from 'antd-style'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { calculateTextWidth } from '@/utils'; import { useDebounce, useMemoizedFn, useSize } from 'ahooks'; import { AppPopover } from '@/components'; -import { useChatContextSelector } from '../../../ChatContext'; import { useChatLayoutContextSelector } from '../../../ChatLayoutContext'; const duration = 0.25; + const containerVariants = { hidden: { height: 0 }, visible: { - height: '28px', + height: '30px', transition: { duration: duration, staggerChildren: 0.035, @@ -38,96 +38,103 @@ const pillVariants = { } }; -export const PillContainer: React.FC<{ pills: BusterChatMessage_thought['thought_pills'] }> = - React.memo(({ pills = [] }) => { - const { styles, cx } = useStyles(); - const onSetSelectedFile = useChatLayoutContextSelector((x) => x.onSetSelectedFile); - const [visiblePills, setVisiblePills] = useState([]); - const [hiddenCount, setHiddenCount] = useState(0); - const [hasDoneInitialAnimation, setHasDoneInitialAnimation] = useState(false); - const containerRef = useRef(null); - const useAnimation = !hasDoneInitialAnimation; +export const PillContainer: React.FC<{ + pills: BusterChatMessage_thought['thought_pills']; + isCompletedStream: boolean; +}> = React.memo(({ pills = [], isCompletedStream }) => { + const { styles, cx } = useStyles(); + const onSetSelectedFile = useChatLayoutContextSelector((x) => x.onSetSelectedFile); + const [visiblePills, setVisiblePills] = useState([]); + const [hiddenCount, setHiddenCount] = useState(0); + const [hasDoneInitialAnimation, setHasDoneInitialAnimation] = useState(false); + const containerRef = useRef(null); + const useAnimation = !hasDoneInitialAnimation && !isCompletedStream; + const chatContentWidth = useChatLayoutContextSelector((x) => x.chatContentWidth); - const size = useSize(containerRef); - const debouncedWidth = useDebounce(size?.width, { - wait: 150, - leading: true - }); - - const hiddenPills: BusterChatMessage_thoughtPill[] = useMemo(() => { - return pills.slice(visiblePills.length, visiblePills.length + hiddenCount); - }, [pills, visiblePills, hiddenCount]); - - const handlePillClick = useMemoizedFn( - (pill: Pick) => { - onSetSelectedFile(pill); - } - ); - - useEffect(() => { - if (!containerRef.current || !pills) return; - - const containerWidth = containerRef.current.offsetWidth; - const pillPadding = 8; // 4px left + 4px right - const pillMargin = 6; // 1.5 * 4 for gap - const pillBorder = 1; // 0.5px * 2 - const font = '11px -apple-system, BlinkMacSystemFont, sans-serif'; // Match your app's font - - let currentLineWidth = 0; - const visible: BusterChatMessage_thoughtPill[] = []; - let hidden = 0; - - // Calculate width needed for "+X more" pill - const moreTextWidth = calculateTextWidth('+99 more', font) + pillPadding + pillBorder; - - for (let i = 0; i < pills.length; i++) { - const pill = pills[i]; - const textWidth = calculateTextWidth(pill.text, font); - const pillWidth = textWidth + pillPadding + pillBorder; - - // Check if adding this pill would exceed container width - if ( - currentLineWidth + pillWidth + (visible.length > 0 ? pillMargin : 0) + moreTextWidth <= - containerWidth - ) { - visible.push(pill); - currentLineWidth += pillWidth + (visible.length > 0 ? pillMargin : 0); - } else { - hidden++; - } - } - - setVisiblePills(visible); - setHiddenCount(hidden); - - setTimeout(() => { - visiblePills.length > 0 && setHasDoneInitialAnimation(true); - }, 300); - }, [pills, containerRef.current, debouncedWidth]); - - return ( - - 0 ? 'visible' : 'hidden'} - className={cx('flex w-full flex-wrap flex-nowrap gap-1.5 overflow-hidden pb-2.5')}> - {visiblePills.map((pill) => ( - - ))} - {hiddenCount > 0 && ( - - )} - - - ); + const size = useSize(containerRef); + const thoughtContainerWidth = size?.width || chatContentWidth - 85; + const debouncedWidth = useDebounce(thoughtContainerWidth, { + wait: 150, + leading: true }); + const hiddenPills: BusterChatMessage_thoughtPill[] = useMemo(() => { + return pills.slice(visiblePills.length, visiblePills.length + hiddenCount); + }, [pills, visiblePills, hiddenCount]); + + const handlePillClick = useMemoizedFn( + (pill: Pick) => { + onSetSelectedFile(pill); + } + ); + + useLayoutEffect(() => { + if (!pills) return; + + const containerWidth = containerRef.current?.offsetWidth || thoughtContainerWidth; + console.log(containerWidth, thoughtContainerWidth, chatContentWidth); + const pillPadding = 8; // 4px left + 4px right + const pillMargin = 6; // 1.5 * 4 for gap + const pillBorder = 1; // 0.5px * 2 + const font = '11px -apple-system, BlinkMacSystemFont, sans-serif'; // Match your app's font + + let currentLineWidth = 0; + const visible: BusterChatMessage_thoughtPill[] = []; + let hidden = 0; + + // Calculate width needed for "+X more" pill + const moreTextWidth = calculateTextWidth('+99 more', font) + pillPadding + pillBorder; + + for (let i = 0; i < pills.length; i++) { + const pill = pills[i]; + const textWidth = calculateTextWidth(pill.text, font); + const pillWidth = textWidth + pillPadding + pillBorder; + + // Check if adding this pill would exceed container width + if ( + currentLineWidth + pillWidth + (visible.length > 0 ? pillMargin : 0) + moreTextWidth <= + containerWidth + ) { + visible.push(pill); + currentLineWidth += pillWidth + (visible.length > 0 ? pillMargin : 0); + } else { + hidden++; + } + } + + setVisiblePills(visible); + setHiddenCount(hidden); + + setTimeout(() => { + visiblePills.length > 0 && setHasDoneInitialAnimation(true); + }, 300); + }, [pills, containerRef.current, debouncedWidth]); + + return ( + + 0 ? 'visible' : 'hidden'} + className={cx( + 'flex w-full flex-wrap flex-nowrap gap-1.5 overflow-hidden border border-red-500' + )}> + {visiblePills.map((pill) => ( + + ))} + {hiddenCount > 0 && ( + + )} + + + ); +}); + PillContainer.displayName = 'PillContainer'; const Pill: React.FC<{ diff --git a/web/src/app/app/_layouts/ChatLayout/ChatLayout.tsx b/web/src/app/app/_layouts/ChatLayout/ChatLayout.tsx index 088614f38..6a77a9d04 100644 --- a/web/src/app/app/_layouts/ChatLayout/ChatLayout.tsx +++ b/web/src/app/app/_layouts/ChatLayout/ChatLayout.tsx @@ -21,11 +21,13 @@ export interface ChatSplitterProps { export const ChatLayout: React.FC = React.memo( ({ defaultSelectedFile, defaultSelectedLayout = 'chat', children, chatId }) => { const appSplitterRef = useRef(null); + const chatContentRef = useRef(null); const defaultSplitterLayout = useDefaultSplitterLayout({ defaultSelectedLayout }); const useChatSplitterProps = useChatLayout({ appSplitterRef, + chatContentRef, defaultSelectedFile, defaultSelectedLayout, chatId @@ -41,7 +43,7 @@ export const ChatLayout: React.FC = React.memo( } + leftChildren={isPureFile ? null : } rightChildren={} autoSaveId="chat-splitter" defaultLayout={defaultSplitterLayout} diff --git a/web/src/app/app/_layouts/ChatLayout/ChatLayoutContext/ChatLayoutContext.tsx b/web/src/app/app/_layouts/ChatLayout/ChatLayoutContext/ChatLayoutContext.tsx index 34c6ef090..1e81f9767 100644 --- a/web/src/app/app/_layouts/ChatLayout/ChatLayoutContext/ChatLayoutContext.tsx +++ b/web/src/app/app/_layouts/ChatLayout/ChatLayoutContext/ChatLayoutContext.tsx @@ -6,7 +6,7 @@ import { import React, { PropsWithChildren, useMemo, useTransition } from 'react'; import type { SelectedFile } from '../interfaces'; import type { ChatSplitterProps } from '../ChatLayout'; -import { useMemoizedFn } from 'ahooks'; +import { useMemoizedFn, useSize } from 'ahooks'; import type { AppSplitterRef } from '@/components/layout'; import { createChatAssetRoute, createFileRoute } from './helpers'; import { useAppLayoutContextSelector } from '@/context/BusterAppLayout'; @@ -17,6 +17,7 @@ interface UseChatSplitterProps { defaultSelectedFile: SelectedFile | undefined; defaultSelectedLayout: ChatSplitterProps['defaultSelectedLayout']; appSplitterRef: React.RefObject; + chatContentRef: React.RefObject; chatId: string | undefined; } @@ -24,12 +25,16 @@ export const useChatLayout = ({ defaultSelectedFile, defaultSelectedLayout, appSplitterRef, + chatContentRef, chatId }: UseChatSplitterProps) => { const [isPending, startTransition] = useTransition(); const onChangePage = useAppLayoutContextSelector((state) => state.onChangePage); + const chatContentSize = useSize(chatContentRef); const selectedLayout = defaultSelectedLayout; + const chatContentWidth = chatContentSize?.width || 325; + const animateOpenSplitter = useMemoizedFn((side: 'left' | 'right' | 'both') => { if (appSplitterRef.current) { const { animateWidth, isSideClosed } = appSplitterRef.current; @@ -79,6 +84,7 @@ export const useChatLayout = ({ selectedLayout, isPureFile, isPureChat, + chatContentWidth, onSetSelectedFile, onCollapseFileClick, animateOpenSplitter diff --git a/web/src/context/Chats/MOCK_CHAT.ts b/web/src/context/Chats/MOCK_CHAT.ts index 70cf41cd0..9b0e123e4 100644 --- a/web/src/context/Chats/MOCK_CHAT.ts +++ b/web/src/context/Chats/MOCK_CHAT.ts @@ -5,7 +5,8 @@ import { type BusterChatMessage_thought, type BusterChatMessageRequest, type BusterChatMessageResponse, - FileType + FileType, + BusterChatMessage_thoughtPill } from '@/api/buster_socket/chats'; import { faker } from '@faker-js/faker'; @@ -25,23 +26,38 @@ const createMockResponseMessageText = (): BusterChatMessage_text => ({ message_chunk: faker.lorem.sentence() }); -const createMockResponseMessageThought = (): BusterChatMessage_thought => ({ - id: faker.string.uuid(), - type: 'thought', - thought_title: `Found ${faker.number.int(100)} terms`, - thought_secondary_title: faker.lorem.word(), - thought_pills: [], - hidden: false, - in_progress: false -}); +const createMockResponseMessageThought = (): BusterChatMessage_thought => { + const randomPillCount = faker.number.int(7); + const fourRandomPills: BusterChatMessage_thoughtPill[] = Array.from( + { length: randomPillCount }, + () => { + return { + text: faker.lorem.word(), + type: 'term', + id: faker.string.uuid() + }; + } + ); + return { + id: faker.string.uuid(), + type: 'thought', + thought_title: `Found ${faker.number.int(100)} terms`, + thought_secondary_title: faker.lorem.word(), + thought_pills: fourRandomPills, + hidden: false, + in_progress: false + }; +}; -const createMockResponseMessageFile = (): BusterChatMessage_file => ({ - id: faker.string.uuid(), - type: 'file', - file_type: 'metric', - version_number: 1, - version_id: faker.string.uuid() -}); +const createMockResponseMessageFile = (): BusterChatMessage_file => { + return { + id: faker.string.uuid(), + type: 'file', + file_type: 'metric', + version_number: 1, + version_id: faker.string.uuid() + }; +}; export const MOCK_CHAT: BusterChat = { id: '0', @@ -55,8 +71,8 @@ export const MOCK_CHAT: BusterChat = { response_messages: [ createMockResponseMessageText(), createMockResponseMessageThought(), - // createMockResponseMessageThought(), - // createMockResponseMessageThought(), + createMockResponseMessageThought(), + createMockResponseMessageThought(), // createMockResponseMessageThought(), createMockResponseMessageFile(), createMockResponseMessageFile()