enhancement: tool call block should be wrapped in a collapsible scroll area

This commit is contained in:
Louis 2025-04-23 22:07:04 +07:00
parent 1630e2eb77
commit a81b644a8f
No known key found for this signature in database
GPG Key ID: 44FA9F4D33C37DE2
4 changed files with 56 additions and 9 deletions

View File

@ -4,7 +4,7 @@ import { ThreadMessage } from '@janhq/core'
import { ScrollArea } from '@janhq/joi' import { ScrollArea } from '@janhq/joi'
import { useVirtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual'
import { useAtomValue } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import { loadModelErrorAtom } from '@/hooks/useActiveModel' import { loadModelErrorAtom } from '@/hooks/useActiveModel'
@ -12,6 +12,8 @@ import ChatItem from '../ChatItem'
import LoadModelError from '../LoadModelError' import LoadModelError from '../LoadModelError'
import { toolCallBlockStateAtom } from '../TextMessage/ToolCallBlock'
import EmptyThread from './EmptyThread' import EmptyThread from './EmptyThread'
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
@ -65,9 +67,11 @@ const ChatBody = memo(
const parentRef = useRef<HTMLDivElement>(null) const parentRef = useRef<HTMLDivElement>(null)
const prevScrollTop = useRef(0) const prevScrollTop = useRef(0)
const isUserManuallyScrollingUp = useRef(false) const isUserManuallyScrollingUp = useRef(false)
const isNestedScrollviewExpanding = useRef(false)
const currentThread = useAtomValue(activeThreadAtom) const currentThread = useAtomValue(activeThreadAtom)
const isBlockingSend = useAtomValue(isBlockingSendAtom) const isBlockingSend = useAtomValue(isBlockingSendAtom)
const showScrollBar = useAtomValue(showScrollBarAtom) const showScrollBar = useAtomValue(showScrollBarAtom)
const setToolCallExpanded = useSetAtom(toolCallBlockStateAtom)
const count = useMemo( const count = useMemo(
() => (messages?.length ?? 0) + (loadModelError ? 1 : 0), () => (messages?.length ?? 0) + (loadModelError ? 1 : 0),
@ -97,7 +101,10 @@ const ChatBody = memo(
_, _,
instance instance
) => { ) => {
if (isUserManuallyScrollingUp.current === true && isBlockingSend) if (
isNestedScrollviewExpanding ||
(isUserManuallyScrollingUp.current === true && isBlockingSend)
)
return false return false
return ( return (
// item.start < (instance.scrollOffset ?? 0) && // item.start < (instance.scrollOffset ?? 0) &&
@ -130,6 +137,22 @@ const ChatBody = memo(
[isBlockingSend] [isBlockingSend]
) )
const preserveScrollOnExpand = (callback: () => void) => {
isNestedScrollviewExpanding.current = true
const scrollEl = parentRef.current
const prevScrollTop = scrollEl?.scrollTop ?? 0
const prevScrollHeight = scrollEl?.scrollHeight ?? 0
callback() // Expand content (e.g. setIsExpanded(true))
if (scrollEl)
requestAnimationFrame(() => {
const newScrollHeight = scrollEl?.scrollHeight ?? 0
scrollEl.scrollTop =
prevScrollTop + (newScrollHeight - prevScrollHeight)
})
}
return ( return (
<div className="flex h-full w-full flex-col overflow-x-hidden"> <div className="flex h-full w-full flex-col overflow-x-hidden">
<ScrollArea <ScrollArea
@ -179,6 +202,14 @@ const ChatBody = memo(
isCurrentMessage={ isCurrentMessage={
virtualRow.index === messages?.length - 1 virtualRow.index === messages?.length - 1
} }
onExpand={(props) =>
preserveScrollOnExpand(() => {
setToolCallExpanded((prev) => ({
...prev,
...props,
}))
})
}
/> />
)} )}
</div> </div>

View File

@ -22,6 +22,7 @@ type Props = {
loadModelError?: string loadModelError?: string
isCurrentMessage?: boolean isCurrentMessage?: boolean
index: number index: number
onExpand: (props: { [id: number]: boolean }) => void
} & ThreadMessage } & ThreadMessage
const ChatItem = forwardRef<Ref, Props>((message, ref) => { const ChatItem = forwardRef<Ref, Props>((message, ref) => {
@ -81,6 +82,7 @@ const ChatItem = forwardRef<Ref, Props>((message, ref) => {
status={status} status={status}
index={message.index} index={message.index}
isCurrentMessage={message.isCurrentMessage ?? false} isCurrentMessage={message.isCurrentMessage ?? false}
onExpand={message.onExpand}
/> />
</div> </div>
)} )}

View File

@ -1,8 +1,11 @@
import React from 'react' import React from 'react'
import { ScrollArea } from '@janhq/joi'
import { atom, useAtom } from 'jotai' import { atom, useAtom } from 'jotai'
import { ChevronDown, ChevronUp, Loader } from 'lucide-react' import { ChevronDown, ChevronUp, Loader } from 'lucide-react'
import { twMerge } from 'tailwind-merge'
import { MarkdownTextMessage } from './MarkdownTextMessage' import { MarkdownTextMessage } from './MarkdownTextMessage'
interface Props { interface Props {
@ -10,16 +13,18 @@ interface Props {
name: string name: string
id: number id: number
loading: boolean loading: boolean
onExpand: (props: { [id: number]: boolean }) => void
} }
const toolCallBlockStateAtom = atom<{ [id: number]: boolean }>({}) export const toolCallBlockStateAtom = atom<{ [id: number]: boolean }>({})
const ToolCallBlock = ({ id, name, result, loading }: Props) => { const ToolCallBlock = ({ id, name, result, loading, onExpand }: Props) => {
const [collapseState, setCollapseState] = useAtom(toolCallBlockStateAtom) const [collapseState, setCollapseState] = useAtom(toolCallBlockStateAtom)
const isExpanded = collapseState[id] ?? false const isExpanded = collapseState[id] ?? false
const handleClick = () => { const handleClick = () => {
setCollapseState((prev) => ({ ...prev, [id]: !isExpanded })) onExpand({ [id]: !isExpanded })
// setCollapseState((prev) => ({ ...prev, [id]: !isExpanded }))
} }
return ( return (
<div className="mx-auto w-full"> <div className="mx-auto w-full">
@ -38,17 +43,21 @@ const ToolCallBlock = ({ id, name, result, loading }: Props) => {
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
)} )}
<span className="font-medium"> <span className="font-medium">
{' '}
View result from <span className="font-bold">{name}</span> View result from <span className="font-bold">{name}</span>
</span> </span>
</button> </button>
</div> </div>
{isExpanded && ( <ScrollArea
className={twMerge(
'w-full overflow-hidden transition-all duration-300',
isExpanded ? 'max-h-96' : 'max-h-0'
)}
>
<div className="mt-2 overflow-x-hidden pl-6 text-[hsla(var(--text-secondary))]"> <div className="mt-2 overflow-x-hidden pl-6 text-[hsla(var(--text-secondary))]">
<span>{result ?? ''} </span> <span>{result ?? ''} </span>
</div> </div>
)} </ScrollArea>
</div> </div>
</div> </div>
) )

View File

@ -30,7 +30,11 @@ import { selectedModelAtom } from '@/helpers/atoms/Model.atom'
import { chatWidthAtom } from '@/helpers/atoms/Setting.atom' import { chatWidthAtom } from '@/helpers/atoms/Setting.atom'
const MessageContainer: React.FC< const MessageContainer: React.FC<
ThreadMessage & { isCurrentMessage: boolean; index: number } ThreadMessage & {
isCurrentMessage: boolean
index: number
onExpand: (props: { [id: number]: boolean }) => void
}
> = (props) => { > = (props) => {
const isUser = props.role === ChatCompletionRole.User const isUser = props.role === ChatCompletionRole.User
const isSystem = props.role === ChatCompletionRole.System const isSystem = props.role === ChatCompletionRole.System
@ -195,6 +199,7 @@ const MessageContainer: React.FC<
<> <>
{props.metadata.tool_calls.map((toolCall) => ( {props.metadata.tool_calls.map((toolCall) => (
<ToolCallBlock <ToolCallBlock
onExpand={props.onExpand}
id={toolCall.tool?.id} id={toolCall.tool?.id}
name={toolCall.tool?.function?.name ?? ''} name={toolCall.tool?.function?.name ?? ''}
key={toolCall.tool?.id} key={toolCall.tool?.id}