This commit is contained in:
Nate Kelley 2025-01-28 12:47:14 -07:00
parent df3c216b3e
commit b850a85199
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
6 changed files with 160 additions and 142 deletions

View File

@ -5,22 +5,24 @@ import { useScroll } from 'ahooks';
interface ChatContainerProps {}
export const ChatContainer: React.FC<ChatContainerProps> = React.memo(({}) => {
const chatContentRef = useRef<HTMLDivElement>(null);
const scroll = useScroll(chatContentRef);
export const ChatContainer = React.memo(
React.forwardRef<HTMLDivElement, ChatContainerProps>((props, ref) => {
const chatContentRef = useRef<HTMLDivElement>(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 (
<div className="flex h-full w-full flex-col">
<ChatHeader showScrollOverflow={showScrollOverflow} />
<ChatContent chatContentRef={chatContentRef} />
</div>
);
});
return (
<div ref={ref} className="flex h-full w-full flex-col">
<ChatHeader showScrollOverflow={showScrollOverflow} />
<ChatContent chatContentRef={chatContentRef} />
</div>
);
})
);
ChatContainer.displayName = 'ChatContainer';

View File

@ -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<ChatResponseMessageProps> = React.memo(
@ -21,19 +19,6 @@ export const ChatResponseMessage_Thought: React.FC<ChatResponseMessageProps> = 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 (
<AnimatePresence initial={!isCompletedStream}>
<motion.div className={cx(styles.container, 'flex space-x-1.5')} {...animationConfig}>
@ -51,7 +36,7 @@ export const ChatResponseMessage_Thought: React.FC<ChatResponseMessageProps> = R
</Text>
</div>
<PillContainer pills={myPills} />
<PillContainer pills={thought_pills} isCompletedStream={isCompletedStream} />
</div>
</motion.div>
</AnimatePresence>

View File

@ -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<BusterChatMessage_thoughtPill[]>([]);
const [hiddenCount, setHiddenCount] = useState(0);
const [hasDoneInitialAnimation, setHasDoneInitialAnimation] = useState(false);
const containerRef = useRef<HTMLDivElement>(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<BusterChatMessage_thoughtPill[]>([]);
const [hiddenCount, setHiddenCount] = useState(0);
const [hasDoneInitialAnimation, setHasDoneInitialAnimation] = useState(false);
const containerRef = useRef<HTMLDivElement>(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<BusterChatMessage_thoughtPill, 'id' | 'type'>) => {
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 (
<AnimatePresence initial={true}>
<motion.div
ref={containerRef}
variants={containerVariants}
initial="hidden"
animate={visiblePills.length > 0 ? 'visible' : 'hidden'}
className={cx('flex w-full flex-wrap flex-nowrap gap-1.5 overflow-hidden pb-2.5')}>
{visiblePills.map((pill) => (
<Pill key={pill.id} useAnimation={useAnimation} {...pill} onClick={undefined} />
))}
{hiddenCount > 0 && (
<OverflowPill
hiddenPills={hiddenPills}
useAnimation={useAnimation}
onClickPill={handlePillClick}
/>
)}
</motion.div>
</AnimatePresence>
);
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<BusterChatMessage_thoughtPill, 'id' | 'type'>) => {
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 (
<AnimatePresence initial={!isCompletedStream}>
<motion.div
ref={containerRef}
variants={containerVariants}
initial="hidden"
animate={pills.length > 0 ? 'visible' : 'hidden'}
className={cx(
'flex w-full flex-wrap flex-nowrap gap-1.5 overflow-hidden border border-red-500'
)}>
{visiblePills.map((pill) => (
<Pill key={pill.id} useAnimation={useAnimation} {...pill} onClick={undefined} />
))}
{hiddenCount > 0 && (
<OverflowPill
hiddenPills={hiddenPills}
useAnimation={useAnimation}
onClickPill={handlePillClick}
/>
)}
</motion.div>
</AnimatePresence>
);
});
PillContainer.displayName = 'PillContainer';
const Pill: React.FC<{

View File

@ -21,11 +21,13 @@ export interface ChatSplitterProps {
export const ChatLayout: React.FC<ChatSplitterProps> = React.memo(
({ defaultSelectedFile, defaultSelectedLayout = 'chat', children, chatId }) => {
const appSplitterRef = useRef<AppSplitterRef>(null);
const chatContentRef = useRef<HTMLDivElement>(null);
const defaultSplitterLayout = useDefaultSplitterLayout({ defaultSelectedLayout });
const useChatSplitterProps = useChatLayout({
appSplitterRef,
chatContentRef,
defaultSelectedFile,
defaultSelectedLayout,
chatId
@ -41,7 +43,7 @@ export const ChatLayout: React.FC<ChatSplitterProps> = React.memo(
<ChatContextProvider value={useChatContextValue}>
<AppSplitter
ref={appSplitterRef}
leftChildren={isPureFile ? null : <ChatContainer />}
leftChildren={isPureFile ? null : <ChatContainer ref={chatContentRef} />}
rightChildren={<FileContainer children={children} />}
autoSaveId="chat-splitter"
defaultLayout={defaultSplitterLayout}

View File

@ -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<AppSplitterRef>;
chatContentRef: React.RefObject<HTMLDivElement>;
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

View File

@ -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()