Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 77 additions & 72 deletions src/components/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,20 @@ interface SplitMessage {
reasoning_content?: string;
}

interface ChatMessageProps {
message: MessageDisplay;
onRegenerateMessage(msg: Message): void;
onEditUserMessage(msg: Message, content: string, extra: MessageExtra[]): void;
onEditAssistantMessage(msg: Message, content: string): void;
onChangeSibling(sibling: Message['id']): void;
}
export default memo(function ChatMessage({
message,
onRegenerateMessage,
onEditUserMessage,
onEditAssistantMessage,
onChangeSibling,
}: {
message: MessageDisplay;
onRegenerateMessage(msg: Message): void;
onEditUserMessage(msg: Message, content: string, extra: MessageExtra[]): void;
onEditAssistantMessage(msg: Message, content: string): void;
onChangeSibling(sibling: Message['id']): void;
}) {
}: ChatMessageProps) {
const { msg, siblingCurrIdx, siblingLeafNodeIds, isPending } = message;

const { t } = useTranslation();
Expand Down Expand Up @@ -187,7 +188,7 @@ export default memo(function ChatMessage({
{!isEditing && (!!content || !!reasoning_content) && (
<div dir="auto" tabIndex={0}>
{!!reasoning_content && (
<ThoughtProcess
<ThinkingSection
isThinking={isThinking}
content={reasoning_content}
/>
Expand Down Expand Up @@ -374,17 +375,18 @@ export default memo(function ChatMessage({
);
});

interface EditMessageProps {
msg: Message | PendingMessage;
setIsEditing(flag: boolean): void;
onEditUserMessage(msg: Message, content: string, extra: MessageExtra[]): void;
onEditAssistantMessage(msg: Message, content: string): void;
}
function EditMessage({
msg,
setIsEditing,
onEditUserMessage,
onEditAssistantMessage,
}: {
msg: Message | PendingMessage;
setIsEditing(flag: boolean): void;
onEditUserMessage(msg: Message, content: string, extra: MessageExtra[]): void;
onEditAssistantMessage(msg: Message, content: string): void;
}) {
}: EditMessageProps) {
const { t } = useTranslation();

const [editingContent, setEditingContent] = useState<string>(
Expand Down Expand Up @@ -462,17 +464,21 @@ function EditMessage({
);
}

function ThoughtProcess({
isThinking,
content,
}: {
interface ThinkingSectionProps {
isThinking: boolean;
content: string;
}) {
}
const ThinkingSection = memo(function ThinkingSection({
isThinking,
content,
}: ThinkingSectionProps) {
const { t } = useTranslation();
const {
config: { showThoughtInProgress },
} = useAppContext();

if (!content) return null;

return (
<div
role="button"
Expand Down Expand Up @@ -508,57 +514,56 @@ function ThoughtProcess({
</div>
</div>
);
}
});

const PlayButton = memo(
({
className,
disabled,
text,
}: {
className?: string;
disabled?: boolean;
text: string;
}) => {
const { t } = useTranslation();
const {
config: { ttsVoice, ttsPitch, ttsRate, ttsVolume },
} = useAppContext();
return (
<TextToSpeech
text={text}
voice={getSpeechSynthesisVoiceByName(ttsVoice)}
pitch={ttsPitch}
rate={ttsRate}
volume={ttsVolume}
>
{({ isPlaying, play, stop }) => (
<Fragment>
{!isPlaying && (
<IntlIconButton
className={className}
onClick={play}
disabled={disabled}
t={t}
titleKey="chatScreen.titles.play"
ariaLabelKey="chatScreen.ariaLabels.playMessage"
icon={LuVolume2}
/>
)}
{isPlaying && (
<IntlIconButton
className={className}
onClick={stop}
disabled={disabled}
t={t}
titleKey="chatScreen.titles.stop"
ariaLabelKey="chatScreen.ariaLabels.stopMessage"
icon={LuVolumeX}
/>
)}
</Fragment>
)}
</TextToSpeech>
);
}
);
interface PlayButtonProps {
className?: string;
disabled?: boolean;
text: string;
}
const PlayButton = memo(function PlayButton({
className,
disabled,
text,
}: PlayButtonProps) {
const { t } = useTranslation();
const {
config: { ttsVoice, ttsPitch, ttsRate, ttsVolume },
} = useAppContext();
return (
<TextToSpeech
text={text}
voice={getSpeechSynthesisVoiceByName(ttsVoice)}
pitch={ttsPitch}
rate={ttsRate}
volume={ttsVolume}
>
{({ isPlaying, play, stop }) => (
<Fragment>
{!isPlaying && (
<IntlIconButton
className={className}
onClick={play}
disabled={disabled}
t={t}
titleKey="chatScreen.titles.play"
ariaLabelKey="chatScreen.ariaLabels.playMessage"
icon={LuVolume2}
/>
)}
{isPlaying && (
<IntlIconButton
className={className}
onClick={stop}
disabled={disabled}
t={t}
titleKey="chatScreen.titles.stop"
ariaLabelKey="chatScreen.ariaLabels.stopMessage"
icon={LuVolumeX}
/>
)}
</Fragment>
)}
</TextToSpeech>
);
});
26 changes: 13 additions & 13 deletions src/components/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,17 +129,17 @@ interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
titleKey: string;
ariaLabelKey: string;
}
export const IntlIconButton = memo(
({
className,
disabled,
onClick,
icon: Icon,
t,
titleKey,
ariaLabelKey,
...props
}: IconButtonProps) => (
export const IntlIconButton = memo(function IntlIconButton({
className,
disabled,
onClick,
icon: Icon,
t,
titleKey,
ariaLabelKey,
...props
}: IconButtonProps) {
return (
<button
className={className}
onClick={onClick}
Expand All @@ -150,8 +150,8 @@ export const IntlIconButton = memo(
>
<Icon className="lucide h-4 w-4" />
</button>
)
);
);
});

export interface DropdownOption {
value: string | number;
Expand Down
88 changes: 59 additions & 29 deletions src/hooks/useChatScroll.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,74 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback } from 'react';

/**
* Distance from bottom (in pixels) to trigger automatic scroll when near the bottom.
* @default 100
*/
const TO_BOTTOM = 100;
const DELAY = 80;

export function scrollSmooth(element: HTMLElement) {
element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' });
}
/**
* Delay (in milliseconds) before scrolling when using `scrollImmediate`.
* @default 80
*/
const DELAY = 80;

export function useChatScroll(elementRef: React.RefObject<HTMLElement>) {
/**
* Custom hook for managing chat scroll behavior in a message container.
*
* This is useful for chat interfaces where you want to auto-scroll when new messages arrive,
* but avoid scrolling when the user has manually scrolled up to read older messages.
*
* @param options - Configuration options for the hook
* @param options.elementRef - Ref object pointing to the chat container element
* @param options.behavior - Scroll behavior option ('auto' or 'smooth'). Defaults to 'auto'
*/
export function useChatScroll(
/**
* React ref object pointing to the chat container element.
* This element must have scrollable content (overflow-y: auto/scroll).
*/
elementRef: React.RefObject<HTMLElement>
) {
/**
* Immediately scrolls the chat container to the bottom after a specified delay.
*
* This is typically used when new messages are added to the chat and you want to
* auto-scroll to the bottom after a brief delay (to allow UI updates to complete).
*
* @param delay - Optional delay in milliseconds before scrolling. Defaults to 80ms.
*/
const scrollImmediate = useCallback(
(delay: number = DELAY) => {
(behavior: ScrollBehavior = 'auto', delay: number = DELAY) => {
const element = elementRef?.current;
if (!element) return;
setTimeout(() => scrollSmooth(element), delay);
setTimeout(
() => element.scrollTo({ top: element.scrollHeight, behavior }),
delay
);
},
[elementRef]
);

const scrollToBottom = useCallback(() => {
const element = elementRef?.current;
if (!element) return;

const { scrollHeight, scrollTop, clientHeight } = element;
const spaceToBottom = scrollHeight - scrollTop - clientHeight;
if (spaceToBottom < TO_BOTTOM) {
scrollSmooth(element);
}
}, [elementRef]);

useEffect(() => {
const element = elementRef?.current;
if (!element) return;

const observer = new MutationObserver(() => {
scrollToBottom();
});
/**
* Scrolls to the bottom of the container only if the user is near the bottom.
*
* This method checks the current scroll position and only scrolls to bottom if the
* user is within `TO_BOTTOM` pixels of the bottom. This prevents unwanted scrolling
* when the user has manually scrolled up to read older messages.
*/
const scrollToBottom = useCallback(
(behavior: ScrollBehavior = 'auto') => {
const element = elementRef?.current;
if (!element) return;

observer.observe(element, { childList: true, subtree: true });
return () => observer.disconnect();
}, [elementRef, scrollToBottom]);
const { scrollHeight, scrollTop, clientHeight } = element;
const spaceToBottom = scrollHeight - scrollTop - clientHeight;
if (spaceToBottom < TO_BOTTOM) {
element.scrollTo({ top: element.scrollHeight, behavior });
}
},
[elementRef]
);

return { scrollImmediate, scrollToBottom };
}
Loading
Loading