fix: optimize action component state management to prevent duplicate loading spinners (#12318)

* refactor: separate message extraction from rendering

Extract `lastAssistantMessage` memoization separately from rendering
`MessageContent` component, improving code clarity and separation of
concerns.

* feat: Replace manual loading state with AssistantMessageStatus tracking

* refactor: Replace loading state with status enum in translation action

- Add LoadingOutlined icon for preparing state
- Remove AssistantMessageStatus dependency
- Simplify streaming detection using local status state

* feat: Add logging and status sync for translation action

* feat: Refactor action component state management to be consistent with
translate action

Replace separate `isContented` and `isLoading` states with a single
`status` state that tracks 'preparing', 'streaming', and 'finished'
phases. Sync status with assistant message status and update footer
loading prop accordingly.

* fix: Add missing pauseTrace import to ActionTranslate component

* fix: Add missing break statements in assistant message status handling

* fix: Move pauseTrace call inside abort completion condition
This commit is contained in:
Phantom 2026-01-07 16:51:25 +08:00 committed by GitHub
parent 2777af77d8
commit d0a1512f23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 101 additions and 31 deletions

View File

@ -823,6 +823,7 @@ const fetchAndProcessAssistantResponseImpl = async (
const streamProcessorCallbacks = createStreamProcessor(callbacks)
const abortController = new AbortController()
logger.silly('Add Abort Controller', { id: userMessageId })
addAbortController(userMessageId!, () => abortController.abort())
await transformMessagesAndFetch(

View File

@ -12,6 +12,7 @@ import {
} from '@renderer/services/AssistantService'
import { pauseTrace } from '@renderer/services/SpanManagerService'
import type { Assistant, Topic } from '@renderer/types'
import { AssistantMessageStatus } from '@renderer/types/newMessage'
import type { ActionItem } from '@renderer/types/selectionTypes'
import { abortCompletion } from '@renderer/utils/abortController'
import { ChevronDown } from 'lucide-react'
@ -34,8 +35,7 @@ const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => {
const { language } = useSettings()
const [error, setError] = useState<string | null>(null)
const [showOriginal, setShowOriginal] = useState(false)
const [isContented, setIsContented] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [status, setStatus] = useState<'preparing' | 'streaming' | 'finished'>('preparing')
const [contentToCopy, setContentToCopy] = useState('')
const initialized = useRef(false)
@ -96,19 +96,24 @@ const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => {
}, [action, language])
const fetchResult = useCallback(() => {
if (!initialized.current) {
return
}
setStatus('preparing')
const setAskId = (id: string) => {
askId.current = id
}
const onStream = () => {
setIsContented(true)
setStatus('streaming')
scrollToBottom?.()
}
const onFinish = (content: string) => {
setStatus('finished')
setContentToCopy(content)
setIsLoading(false)
}
const onError = (error: Error) => {
setIsLoading(false)
setStatus('finished')
setError(error.message)
}
@ -131,17 +136,40 @@ const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => {
const allMessages = useTopicMessages(topicRef.current?.id || '')
// Memoize the messages to prevent unnecessary re-renders
const messageContent = useMemo(() => {
const currentAssistantMessage = useMemo(() => {
const assistantMessages = allMessages.filter((message) => message.role === 'assistant')
const lastAssistantMessage = assistantMessages[assistantMessages.length - 1]
return lastAssistantMessage ? <MessageContent key={lastAssistantMessage.id} message={lastAssistantMessage} /> : null
if (assistantMessages.length === 0) {
return null
}
return assistantMessages[assistantMessages.length - 1]
}, [allMessages])
useEffect(() => {
// Sync message status
switch (currentAssistantMessage?.status) {
case AssistantMessageStatus.PROCESSING:
case AssistantMessageStatus.PENDING:
case AssistantMessageStatus.SEARCHING:
setStatus('streaming')
break
case AssistantMessageStatus.PAUSED:
case AssistantMessageStatus.ERROR:
case AssistantMessageStatus.SUCCESS:
setStatus('finished')
break
case undefined:
break
default:
logger.warn('Unexpected assistant message status:', { status: currentAssistantMessage?.status })
}
}, [currentAssistantMessage?.status])
const isPreparing = status === 'preparing'
const isStreaming = status === 'streaming'
const handlePause = () => {
if (askId.current) {
abortCompletion(askId.current)
setIsLoading(false)
}
if (topicRef.current?.id) {
pauseTrace(topicRef.current.id)
@ -150,7 +178,6 @@ const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => {
const handleRegenerate = () => {
setContentToCopy('')
setIsLoading(true)
fetchResult()
}
@ -178,13 +205,20 @@ const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => {
</OriginalContent>
)}
<Result>
{!isContented && isLoading && <LoadingOutlined style={{ fontSize: 16 }} spin />}
{messageContent}
{isPreparing && <LoadingOutlined style={{ fontSize: 16 }} spin />}
{!isPreparing && currentAssistantMessage && (
<MessageContent key={currentAssistantMessage.id} message={currentAssistantMessage} />
)}
</Result>
{error && <ErrorMsg>{error}</ErrorMsg>}
</Container>
<FooterPadding />
<WindowFooter loading={isLoading} onPause={handlePause} onRegenerate={handleRegenerate} content={contentToCopy} />
<WindowFooter
loading={isStreaming}
onPause={handlePause}
onRegenerate={handleRegenerate}
content={contentToCopy}
/>
</>
)
})

View File

@ -9,7 +9,9 @@ import { useSettings } from '@renderer/hooks/useSettings'
import useTranslate from '@renderer/hooks/useTranslate'
import MessageContent from '@renderer/pages/home/Messages/MessageContent'
import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
import { pauseTrace } from '@renderer/services/SpanManagerService'
import type { Assistant, Topic, TranslateLanguage, TranslateLanguageCode } from '@renderer/types'
import { AssistantMessageStatus } from '@renderer/types/newMessage'
import type { ActionItem } from '@renderer/types/selectionTypes'
import { abortCompletion } from '@renderer/utils/abortController'
import { detectLanguage } from '@renderer/utils/translate'
@ -48,8 +50,7 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
const [error, setError] = useState('')
const [showOriginal, setShowOriginal] = useState(false)
const [isContented, setIsContented] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [status, setStatus] = useState<'preparing' | 'streaming' | 'finished'>('preparing')
const [contentToCopy, setContentToCopy] = useState('')
const [initialized, setInitialized] = useState(false)
@ -106,6 +107,7 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
// Initialize language pair.
// It will update targetLangRef, so we could get latest target language in the following code
await updateLanguagePair()
logger.silly('[initialize] UpdateLanguagePair completed.')
// Initialize assistant
const currentAssistant = getDefaultTranslateAssistant(targetLangRef.current, action.selectedText)
@ -132,20 +134,18 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
askId.current = id
}
const onStream = () => {
setIsContented(true)
setStatus('streaming')
scrollToBottom?.()
}
const onFinish = (content: string) => {
setStatus('finished')
setContentToCopy(content)
setIsLoading(false)
}
const onError = (error: Error) => {
setIsLoading(false)
setStatus('finished')
setError(error.message)
}
setIsLoading(true)
let sourceLanguageCode: TranslateLanguageCode
try {
@ -182,12 +182,37 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
const allMessages = useTopicMessages(topicRef.current?.id || '')
const messageContent = useMemo(() => {
const currentAssistantMessage = useMemo(() => {
const assistantMessages = allMessages.filter((message) => message.role === 'assistant')
const lastAssistantMessage = assistantMessages[assistantMessages.length - 1]
return lastAssistantMessage ? <MessageContent key={lastAssistantMessage.id} message={lastAssistantMessage} /> : null
if (assistantMessages.length === 0) {
return null
}
return assistantMessages[assistantMessages.length - 1]
}, [allMessages])
useEffect(() => {
// Sync message status
switch (currentAssistantMessage?.status) {
case AssistantMessageStatus.PROCESSING:
case AssistantMessageStatus.PENDING:
case AssistantMessageStatus.SEARCHING:
setStatus('streaming')
break
case AssistantMessageStatus.PAUSED:
case AssistantMessageStatus.ERROR:
case AssistantMessageStatus.SUCCESS:
setStatus('finished')
break
case undefined:
break
default:
logger.warn('Unexpected assistant message status:', { status: currentAssistantMessage?.status })
}
}, [currentAssistantMessage?.status])
const isPreparing = status === 'preparing'
const isStreaming = status === 'streaming'
const handleChangeLanguage = (targetLanguage: TranslateLanguage, alterLanguage: TranslateLanguage) => {
if (!initialized) {
return
@ -200,15 +225,18 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
}
const handlePause = () => {
// FIXME: It doesn't work because abort signal is not set.
logger.silly('Try to pause: ', { id: askId.current })
if (askId.current) {
abortCompletion(askId.current)
setIsLoading(false)
}
if (topicRef.current?.id) {
pauseTrace(topicRef.current.id)
}
}
const handleRegenerate = () => {
setContentToCopy('')
setIsLoading(true)
fetchResult()
}
@ -228,7 +256,7 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
title={t('translate.target_language')}
optionFilterProp="label"
onChange={(value) => handleChangeLanguage(getLanguageByLangcode(value), alterLanguage)}
disabled={isLoading}
disabled={isStreaming}
/>
</Tooltip>
<ArrowRightFromLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} />
@ -240,7 +268,7 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
title={t('translate.alter_language')}
optionFilterProp="label"
onChange={(value) => handleChangeLanguage(targetLanguage, getLanguageByLangcode(value))}
disabled={isLoading}
disabled={isStreaming}
/>
</Tooltip>
<Tooltip placement="bottom" title={t('selection.action.translate.smart_translate_tips')} arrow>
@ -267,13 +295,20 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
</OriginalContent>
)}
<Result>
{!isContented && isLoading && <LoadingOutlined style={{ fontSize: 16 }} spin />}
{messageContent}
{isPreparing && <LoadingOutlined style={{ fontSize: 16 }} spin />}
{!isPreparing && currentAssistantMessage && (
<MessageContent key={currentAssistantMessage.id} message={currentAssistantMessage} />
)}
</Result>
{error && <ErrorMsg>{error}</ErrorMsg>}
</Container>
<FooterPadding />
<WindowFooter loading={isLoading} onPause={handlePause} onRegenerate={handleRegenerate} content={contentToCopy} />
<WindowFooter
loading={isStreaming}
onPause={handlePause}
onRegenerate={handleRegenerate}
content={contentToCopy}
/>
</>
)
}