mirror of https://github.com/buster-so/buster.git
width
This commit is contained in:
parent
df3c216b3e
commit
b850a85199
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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<{
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue