fix(TopicsTab): persist pending state via Redux and update after task completion (#8376)

* fix(TopicsTab): pending状态将自动关闭并不再由组件管理

* feat(消息状态): 添加话题完成状态指示器及相关逻辑

- 在消息状态中新增fulfilledByTopic字段记录话题完成状态
- 添加setTopicFulfilled action用于更新话题完成状态
- 在话题切换时重置完成状态为false
- 在加载完成后设置完成状态为true
- 添加完成状态指示器组件并显示在话题列表中

* fix(TopicsTab): 修复不切换话题时未重置当前话题fulfilled状态的问题

* refactor(messageThunk): 重命名 handleChangeLoadingOfTopic 为 finishTopicLoading

提高函数命名清晰度,更准确描述其功能
This commit is contained in:
Phantom 2025-07-23 14:50:44 +08:00 committed by GitHub
parent 75b8a5a6a7
commit 622d15d5d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 65 additions and 33 deletions

View File

@ -18,6 +18,8 @@ import { getStoreSetting } from './useSettings'
let _activeTopic: Topic
let _setActiveTopic: (topic: Topic) => void
// const logger = loggerService.withContext('useTopic')
export function useActiveTopic(assistantId: string, topic?: Topic) {
const { assistant } = useAssistant(assistantId)
const [activeTopic, setActiveTopic] = useState(topic || _activeTopic || assistant?.topics[0])

View File

@ -3,8 +3,10 @@ import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { useActiveTopic } from '@renderer/hooks/useTopic'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import NavigationService from '@renderer/services/NavigationService'
import { newMessagesActions } from '@renderer/store/newMessage'
import { Assistant, Topic } from '@renderer/types'
import { FC, startTransition, useCallback, useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
@ -25,6 +27,7 @@ const HomePage: FC = () => {
const [activeAssistant, _setActiveAssistant] = useState(state?.assistant || _activeAssistant || assistants[0])
const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id, state?.topic)
const { showAssistants, showTopics, topicPosition } = useSettings()
const dispatch = useDispatch()
_activeAssistant = activeAssistant
@ -43,9 +46,12 @@ const HomePage: FC = () => {
const setActiveTopic = useCallback(
(newTopic: Topic) => {
startTransition(() => _setActiveTopic((prev) => (newTopic?.id === prev.id ? prev : newTopic)))
startTransition(() => {
_setActiveTopic((prev) => (newTopic?.id === prev.id ? prev : newTopic))
dispatch(newMessagesActions.setTopicFulfilled({ topicId: newTopic.id, fulfilled: false }))
})
},
[_setActiveTopic]
[_setActiveTopic, dispatch]
)
useEffect(() => {

View File

@ -23,6 +23,7 @@ import { fetchMessagesSummary } from '@renderer/services/ApiService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import store from '@renderer/store'
import { RootState } from '@renderer/store'
import { newMessagesActions } from '@renderer/store/newMessage'
import { setGenerating } from '@renderer/store/runtime'
import { Assistant, Topic } from '@renderer/types'
import { classNames, removeSpecialCharactersForFileName } from '@renderer/utils'
@ -35,16 +36,17 @@ import {
exportTopicToNotion,
topicToMarkdown
} from '@renderer/utils/export'
import { hasTopicPendingRequests } from '@renderer/utils/queue'
import { Dropdown, MenuProps, Tooltip } from 'antd'
import { ItemType, MenuItemType } from 'antd/es/menu/interface'
import dayjs from 'dayjs'
import { findIndex } from 'lodash'
import { FC, useCallback, useDeferredValue, useMemo, useRef, useState } from 'react'
import { FC, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components'
// const logger = loggerService.withContext('TopicsTab')
interface Props {
assistant: Assistant
activeTopic: Topic
@ -59,6 +61,8 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
const { showTopicTime, pinTopicsToTop, setTopicPosition, topicPosition } = useSettings()
const renamingTopics = useSelector((state: RootState) => state.runtime.chat.renamingTopics)
const topicLoadingQuery = useSelector((state: RootState) => state.messages.loadingByTopic)
const topicFulfilledQuery = useSelector((state: RootState) => state.messages.fulfilledByTopic)
const newlyRenamedTopics = useSelector((state: RootState) => state.runtime.chat.newlyRenamedTopics)
const borderRadius = showTopicTime ? 12 : 'var(--list-item-border-radius)'
@ -66,27 +70,13 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
const [deletingTopicId, setDeletingTopicId] = useState<string | null>(null)
const deleteTimerRef = useRef<NodeJS.Timeout>(null)
const pendingTopics = useMemo(() => {
return new Set<string>()
}, [])
const isPending = useCallback(
(topicId: string) => {
const hasPending = hasTopicPendingRequests(topicId)
if (topicId === activeTopic.id && !hasPending) {
pendingTopics.delete(topicId)
return false
}
if (pendingTopics.has(topicId)) {
return true
}
if (hasPending) {
pendingTopics.add(topicId)
return true
}
return false
},
[activeTopic.id, pendingTopics]
)
const isPending = useCallback((topicId: string) => topicLoadingQuery[topicId], [topicLoadingQuery])
const isFulfilled = useCallback((topicId: string) => topicFulfilledQuery[topicId], [topicFulfilledQuery])
const dispatch = useDispatch()
useEffect(() => {
dispatch(newMessagesActions.setTopicFulfilled({ topicId: activeTopic.id, fulfilled: false }))
}, [activeTopic.id, dispatch, topicFulfilledQuery])
const isRenaming = useCallback(
(topicId: string) => {
@ -480,6 +470,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
onClick={() => onSwitchTopic(topic)}
style={{ borderRadius }}>
{isPending(topic.id) && !isActive && <PendingIndicator />}
{isFulfilled(topic.id) && !isActive && <FulfilledIndicator />}
<TopicNameContainer>
<TopicName className={getTopicNameClassName()} title={topicName}>
{topicName}
@ -644,7 +635,20 @@ const PendingIndicator = styled.div.attrs({
left: 3px;
top: 15px;
border-radius: 50%;
background-color: var(--color-primary);
background-color: var(--color-status-warning);
`
const FulfilledIndicator = styled.div.attrs({
className: 'animation-pulse'
})`
--pulse-size: 5px;
width: 5px;
height: 5px;
position: absolute;
left: 3px;
top: 15px;
border-radius: 50%;
background-color: var(--color-status-success);
`
const AddTopicButton = styled.div`

View File

@ -212,8 +212,8 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => {
})
)
await saveUpdatesToDB(assistantMsgId, topicId, messageUpdates, [])
EventEmitter.emit(EVENT_NAMES.MESSAGE_COMPLETE, { id: assistantMsgId, topicId, status })
logger.debug('onComplete finished')
}
}
}

View File

@ -14,6 +14,7 @@ export interface MessagesState extends EntityState<Message, string> {
messageIdsByTopic: Record<string, string[]> // Map: topicId -> ordered message IDs
currentTopicId: string | null
loadingByTopic: Record<string, boolean>
fulfilledByTopic: Record<string, boolean>
displayCount: number
}
@ -22,6 +23,7 @@ const initialState: MessagesState = messagesAdapter.getInitialState({
messageIdsByTopic: {},
currentTopicId: null,
loadingByTopic: {},
fulfilledByTopic: {},
displayCount: 20
})
@ -37,6 +39,12 @@ interface SetTopicLoadingPayload {
loading: boolean
}
// Payload for setting topic loading state
interface SetTopicFulfilledPayload {
topicId: string
fulfilled: boolean
}
// Payload for upserting a block reference
interface UpsertBlockReferencePayload {
messageId: string
@ -85,6 +93,10 @@ export const messagesSlice = createSlice({
const { topicId, loading } = action.payload
state.loadingByTopic[topicId] = loading
},
setTopicFulfilled(state, action: PayloadAction<SetTopicFulfilledPayload>) {
const { topicId, fulfilled } = action.payload
state.fulfilledByTopic[topicId] = fulfilled
},
setDisplayCount(state, action: PayloadAction<number>) {
state.displayCount = action.payload
},
@ -104,6 +116,9 @@ export const messagesSlice = createSlice({
if (!(topicId in state.loadingByTopic)) {
state.loadingByTopic[topicId] = false
}
if (!(topicId in state.fulfilledByTopic)) {
state.fulfilledByTopic[topicId] = false
}
},
insertMessageAtIndex(state, action: PayloadAction<InsertMessageAtIndexPayload>) {
const { topicId, message, index } = action.payload
@ -118,6 +133,9 @@ export const messagesSlice = createSlice({
if (!(topicId in state.loadingByTopic)) {
state.loadingByTopic[topicId] = false
}
if (!(topicId in state.fulfilledByTopic)) {
state.fulfilledByTopic[topicId] = false
}
},
updateMessage(
state,
@ -162,6 +180,7 @@ export const messagesSlice = createSlice({
}
delete state.messageIdsByTopic[topicId]
state.loadingByTopic[topicId] = false
state.fulfilledByTopic[topicId] = false
},
removeMessage(state, action: PayloadAction<RemoveMessagePayload>) {
const { topicId, messageId } = action.payload

View File

@ -29,9 +29,10 @@ import { newMessagesActions, selectMessagesForTopic } from '../newMessage'
const logger = loggerService.withContext('MessageThunk')
const handleChangeLoadingOfTopic = async (topicId: string) => {
const finishTopicLoading = async (topicId: string) => {
await waitForTopicQueue(topicId)
store.dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false }))
store.dispatch(newMessagesActions.setTopicFulfilled({ topicId, fulfilled: true }))
}
// TODO: 后续可以将db操作移到Listener Middleware中
export const saveMessageAndBlocksToDB = async (message: Message, blocks: MessageBlock[], messageIndex: number = -1) => {
@ -956,7 +957,7 @@ export const sendMessage =
} catch (error) {
logger.error('Error in sendMessage thunk:', error as Error)
} finally {
handleChangeLoadingOfTopic(topicId)
finishTopicLoading(topicId)
}
}
@ -1213,7 +1214,7 @@ export const resendMessageThunk =
} catch (error) {
logger.error(`[resendMessageThunk] Error resending user message ${userMessageToResend.id}:`, error as Error)
} finally {
handleChangeLoadingOfTopic(topicId)
finishTopicLoading(topicId)
}
}
@ -1347,7 +1348,7 @@ export const regenerateAssistantResponseThunk =
)
// dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false }))
} finally {
handleChangeLoadingOfTopic(topicId)
finishTopicLoading(topicId)
}
}
@ -1524,7 +1525,7 @@ export const appendAssistantResponseThunk =
// Optionally dispatch an error action or notification
// Resetting loading state should be handled by the underlying fetchAndProcessAssistantResponseImpl
} finally {
handleChangeLoadingOfTopic(topicId)
finishTopicLoading(topicId)
}
}