File size: 3,765 Bytes
0b9dc2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
import type { ContentBlock, Msg, ToolCallBlock } from '@agentscope-ai/agentscope/message';
import React from 'react';
import { useRef, useEffect } from 'react';

import { EmptyMessage } from './Empty';
import { MessageBubble } from '@/components/chat/MessageBubble';
import { TextInput } from '@/components/chat/TextInput.tsx';
import { cn } from '@/lib/utils';

interface ChatContentProps {
	msgs: Msg[];
	sending: boolean;
	disabled: boolean;
	onSend: (content: ContentBlock[]) => void;
	onUserConfirm: (

		toolCall: ToolCallBlock,

		confirm: boolean,

		replyId: string,

		rules?: ToolCallBlock['suggested_rules'],

	) => void;
	autoComplete?: (input: string) => string | null;
	className?: string;
	/**

	 * Optional content pinned at the bottom of the chat — between the

	 * message scroll area and the text input (e.g. pending subagent HITL

	 * cards on a team leader's view). Rendered below the conversation so

	 * a pending confirmation sits next to the input, where the user is

	 * looking, rather than scrolled off the top.

	 */
	footerSlot?: React.ReactNode;
	/** @see TextInputProps.allowedInputTypes */
	allowedInputTypes: string[];
	/** @see TextInputProps.fileProcessor */
	fileProcessor: (file: File) => Promise<ContentBlock | null>;
}

const ChatContentComponent: React.FC<ChatContentProps> = ({

	msgs,

	sending,

	disabled,

	onSend,

	onUserConfirm,

	autoComplete,

	className,

	footerSlot,

	allowedInputTypes,

	fileProcessor,

}) => {
	const scrollAreaRef = useRef<HTMLDivElement>(null);
	const prevMsgCountRef = useRef<number>(0);
	const wasNearBottomRef = useRef<boolean>(true);

	// Auto-scroll to bottom only if user is already near the bottom
	useEffect(() => {
		const currentCount = msgs.length;
		const prevCount = prevMsgCountRef.current;

		const shouldCheck =
			(currentCount > prevCount && prevCount > 0) || (sending && prevCount > 0);

		if (shouldCheck && scrollAreaRef.current) {
			const { scrollHeight } = scrollAreaRef.current;

			// Check if user was near bottom before content changed
			const isNearBottom = wasNearBottomRef.current;

			if (isNearBottom) {
				scrollAreaRef.current.scrollTo({
					top: scrollHeight,
					behavior: 'smooth',
				});
			}
		}

		prevMsgCountRef.current = currentCount;
	}, [msgs, sending]);

	// Track if user is near bottom whenever they scroll
	useEffect(() => {
		const scrollArea = scrollAreaRef.current;
		if (!scrollArea) return;

		const handleScroll = () => {
			const { scrollTop, scrollHeight, clientHeight } = scrollArea;
			wasNearBottomRef.current = scrollTop + clientHeight >= scrollHeight - 50;
		};

		scrollArea.addEventListener('scroll', handleScroll);
		return () => scrollArea.removeEventListener('scroll', handleScroll);
	}, []);

	return (
		<div className={cn('flex flex-col h-full w-full items-center p-2 gap-4', className)}>

			<div

				ref={scrollAreaRef}

				className="flex-1 w-full max-w-full overflow-auto no-scrollbar overflow-x-hidden"

			>

				<div className="flex flex-col gap-4 size-full max-w-full">

					{msgs.length > 0 ? (

						msgs.map((message) => (

							<MessageBubble

								key={message.id}

								message={message}

								onUserConfirm={onUserConfirm}

							/>

						))

					) : (

						<EmptyMessage />

					)}

				</div>

			</div>

			{footerSlot ? <div className="w-full max-w-full shrink-0">{footerSlot}</div> : null}

			<TextInput

				className="min-w-full max-w-full w-full"

				onSend={onSend}

				disabled={disabled}

				autoComplete={autoComplete}

				allowedInputTypes={allowedInputTypes}

				fileProcessor={fileProcessor}

			/>

		</div>
	);
};

export const ChatContent = React.memo(ChatContentComponent);