diff --git a/src/renderer/src/components/Popups/agent/SessionModal.tsx b/src/renderer/src/components/Popups/agent/SessionModal.tsx index cf5750c124..64f8b1e628 100644 --- a/src/renderer/src/components/Popups/agent/SessionModal.tsx +++ b/src/renderer/src/components/Popups/agent/SessionModal.tsx @@ -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 = ({ 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 = ({ } 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 } diff --git a/src/renderer/src/hooks/agents/useSessions.ts b/src/renderer/src/hooks/agents/useSessions.ts index 9e83ac5afc..3307d0353d 100644 --- a/src/renderer/src/hooks/agents/useSessions.ts +++ b/src/renderer/src/hooks/agents/useSessions.ts @@ -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 } } diff --git a/src/renderer/src/hooks/agents/useUpdateSession.ts b/src/renderer/src/hooks/agents/useUpdateSession.ts new file mode 100644 index 0000000000..4e91c2fda9 --- /dev/null +++ b/src/renderer/src/hooks/agents/useUpdateSession.ts @@ -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( + 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 +} diff --git a/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx b/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx index 9d49627f1f..e51d485dd4 100644 --- a/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx @@ -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 = ({ session, agentId, onDelete, onPress }) => { +const SessionItem: FC = ({ session, agentId, isDisabled, isLoading, onDelete, onPress }) => { const { t } = useTranslation() const { isOpen, onOpen, onClose } = useDisclosure() const { chat } = useRuntime() @@ -38,7 +40,11 @@ const SessionItem: FC = ({ session, agentId, onDelete, onPress <> - + diff --git a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx index 455e32aeca..dc2263661a 100644 --- a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx @@ -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 = ({ 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 = ({ 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 = ({ agentId }) => { deleteSession(session.id)} + isDisabled={sessionWaiting[session.id]} + isLoading={sessionWaiting[session.id]} + onDelete={() => handleDeleteSession(session.id)} onPress={() => setActiveSessionId(agentId, session.id)} /> diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts index 99dcc1914f..4c0183e9e4 100644 --- a/src/renderer/src/store/runtime.ts +++ b/src/renderer/src/store/runtime.ts @@ -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 } 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) => { 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