feat(sessions): add session waiting state and improve deletion handling

- Add sessionWaiting state to track updating/deleting sessions
- Extract updateSession logic into separate hook
- Improve session deletion with waiting state and fallback session selection
- Disable session items during deletion to prevent duplicate actions
This commit is contained in:
icarus 2025-09-22 18:47:24 +08:00
parent 42dbc6555c
commit 52f00f08f2
6 changed files with 94 additions and 25 deletions

View File

@ -20,6 +20,7 @@ import { getModelLogo } from '@renderer/config/models'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useApiModels } from '@renderer/hooks/agents/useModels'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { AgentEntity, AgentSessionEntity, BaseSessionForm, CreateSessionForm, UpdateSessionForm } from '@renderer/types'
import { ChangeEvent, FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -86,7 +87,8 @@ export const SessionModal: React.FC<Props> = ({
const { t } = useTranslation()
const loadingRef = useRef(false)
// const { setTimeoutTimer } = useTimer()
const { createSession, updateSession } = useSessions(agentId)
const { createSession } = useSessions(agentId)
const updateSession = useUpdateSession(agentId)
// Only support claude code for now
const { models } = useApiModels({ providerType: 'anthropic' })
const { agent } = useAgent(agentId)
@ -164,7 +166,7 @@ export const SessionModal: React.FC<Props> = ({
}
if (form.accessible_paths.length === 0) {
window.toast.error(t('agent.session.accessible_paths.required'))
window.toast.error(t('agent.session.accessible_paths.error.at_least_one'))
loadingRef.current = false
return
}

View File

@ -1,4 +1,4 @@
import { CreateSessionForm, UpdateSessionForm } from '@renderer/types'
import { CreateSessionForm } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@ -46,25 +46,14 @@ export const useSessions = (agentId: string) => {
const deleteSession = useCallback(
async (id: string) => {
if (!agentId) return
if (!agentId) return false
try {
await client.deleteSession(agentId, id)
mutate((prev) => prev?.filter((session) => session.id !== id))
return true
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.delete.error.failed')))
}
},
[agentId, client, mutate, t]
)
const updateSession = useCallback(
async (form: UpdateSessionForm) => {
if (!agentId) return
try {
const result = await client.updateSession(agentId, form)
mutate((prev) => prev?.map((session) => (session.id === form.id ? result : session)))
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.update.error.failed')))
return false
}
},
[agentId, client, mutate, t]
@ -76,7 +65,6 @@ export const useSessions = (agentId: string) => {
isLoading,
createSession,
getSession,
deleteSession,
updateSession
deleteSession
}
}

View File

@ -0,0 +1,36 @@
import { ListAgentSessionsResponse, UpdateSessionForm } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { mutate } from 'swr'
import { useAgentClient } from './useAgentClient'
export const useUpdateSession = (agentId: string) => {
const { t } = useTranslation()
const client = useAgentClient()
const paths = client.getSessionPaths(agentId)
const listKey = paths.base
const updateSession = useCallback(
async (form: UpdateSessionForm) => {
const sessionId = form.id
try {
const itemKey = paths.withId(sessionId)
// may change to optimistic update
const result = await client.updateSession(agentId, form)
mutate<ListAgentSessionsResponse['data']>(
listKey,
(prev) => prev?.map((session) => (session.id === result.id ? result : session)) ?? []
)
mutate(itemKey, result)
window.toast.success(t('common.update_success'))
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed')))
}
},
[agentId, client, listKey, paths, t]
)
return updateSession
}

View File

@ -13,11 +13,13 @@ interface SessionItemProps {
session: AgentSessionEntity
// use external agentId as SSOT, instead of session.agent_id
agentId: string
isDisabled?: boolean
isLoading?: boolean
onDelete: () => void
onPress: () => void
}
const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress }) => {
const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoading, onDelete, onPress }) => {
const { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure()
const { chat } = useRuntime()
@ -38,7 +40,11 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
<>
<ContextMenu modal={false}>
<ContextMenuTrigger>
<ButtonContainer onPress={onPress} className={isActive ? 'active' : ''}>
<ButtonContainer
isDisabled={isDisabled}
isLoading={isLoading}
onPress={onPress}
className={isActive ? 'active' : ''}>
<SessionLabelContainer className="name" title={session.name ?? session.id}>
<SessionLabel />
</SessionLabelContainer>

View File

@ -1,9 +1,14 @@
import { Alert, Button, Spinner } from '@heroui/react'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useAppDispatch } from '@renderer/store'
import { setActiveSessionIdAction, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import {
setActiveSessionIdAction,
setActiveTopicOrSessionAction,
setSessionWaitingAction
} from '@renderer/store/runtime'
import { CreateSessionForm } from '@renderer/types'
import { AnimatePresence, motion } from 'framer-motion'
import { Plus } from 'lucide-react'
@ -22,8 +27,9 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
const { t } = useTranslation()
const { agent } = useAgent(agentId)
const { sessions, isLoading, error, deleteSession, createSession } = useSessions(agentId)
const updateSession = useUpdateSession(agentId)
const { chat } = useRuntime()
const { activeSessionId } = chat
const { activeSessionId, sessionWaiting } = chat
const dispatch = useAppDispatch()
const setActiveSessionId = useCallback(
@ -46,6 +52,27 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
}
}, [agent, agentId, createSession, dispatch])
const handleDeleteSession = useCallback(
async (id: string) => {
if (sessions.length === 1) {
window.toast.error(t('agent.session.delete.error.last'))
return
}
dispatch(setSessionWaitingAction({ id, value: true }))
const success = await deleteSession(id)
if (success) {
const newSessionId = sessions.find((s) => s.id !== id)?.id
if (newSessionId) {
dispatch(setActiveSessionIdAction({ agentId, sessionId: newSessionId }))
} else {
// may clear messages instead of forbidden deletion
}
}
dispatch(setSessionWaitingAction({ id, value: false }))
},
[agentId, deleteSession, dispatch, sessions]
)
const currentActiveSessionId = activeSessionId[agentId]
useEffect(() => {
@ -96,7 +123,9 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
<SessionItem
session={session}
agentId={agentId}
onDelete={() => deleteSession(session.id)}
isDisabled={sessionWaiting[session.id]}
isLoading={sessionWaiting[session.id]}
onDelete={() => handleDeleteSession(session.id)}
onPress={() => setActiveSessionId(agentId, session.id)}
/>
</motion.div>

View File

@ -18,6 +18,8 @@ export interface ChatState {
renamingTopics: string[]
/** topic ids that are newly renamed */
newlyRenamedTopics: string[]
/** is a session waiting for updating/deleting. undefined and false share same semantics. */
sessionWaiting: Record<string, boolean>
}
export interface WebSearchState {
@ -90,7 +92,8 @@ const initialState: RuntimeState = {
activeTopicOrSession: 'topic',
activeSessionId: {},
renamingTopics: [],
newlyRenamedTopics: []
newlyRenamedTopics: [],
sessionWaiting: {}
},
websearch: {
activeSearches: {}
@ -184,6 +187,10 @@ const runtimeSlice = createSlice({
},
addIknowAction: (state, action: PayloadAction<string>) => {
state.iknow[action.payload] = true
},
setSessionWaitingAction: (state, action: PayloadAction<{ id: string; value: boolean }>) => {
const { id, value } = action.payload
state.chat.sessionWaiting[id] = value
}
}
})
@ -212,6 +219,7 @@ export const {
setActiveTopicOrSessionAction,
setRenamingTopics,
setNewlyRenamedTopics,
setSessionWaitingAction,
// WebSearch related actions
setActiveSearches,
setWebSearchStatus