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/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 9aa40c3e0a..b7cba46a9d 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -52,7 +52,8 @@ "delete": { "content": "Are you sure to delete this session?", "error": { - "failed": "Failed to delete the session" + "failed": "Failed to delete the session", + "last": "At least one session must be kept" }, "title": "Delete session" }, diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index a91e78959b..d7287036a6 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -52,7 +52,8 @@ "delete": { "content": "确定要删除此会话吗?", "error": { - "failed": "删除会话失败" + "failed": "删除会话失败", + "last": "至少需要保留一个会话" }, "title": "删除会话" }, diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 75300f059f..fbe922dbe3 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -52,7 +52,8 @@ "delete": { "content": "您確定要刪除此工作階段嗎?", "error": { - "failed": "無法刪除工作階段" + "failed": "無法刪除工作階段", + "last": "至少必須保留一個工作階段" }, "title": "刪除工作階段" }, diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 203e925d0d..8af53b669c 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -52,7 +52,8 @@ "delete": { "content": "Είσαι σίγουρος ότι θέλεις να διαγράψεις αυτήν τη συνεδρία;", "error": { - "failed": "Αποτυχία διαγραφής της συνεδρίας" + "failed": "Αποτυχία διαγραφής της συνεδρίας", + "last": "Τουλάχιστον μία συνεδρία πρέπει να διατηρηθεί" }, "title": "Διαγραφή συνεδρίας" }, diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index dee1890436..0be385d740 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -52,7 +52,8 @@ "delete": { "content": "¿Estás seguro de eliminar esta sesión?", "error": { - "failed": "Error al eliminar la sesión" + "failed": "Error al eliminar la sesión", + "last": "Debe mantenerse al menos una sesión" }, "title": "Eliminar sesión" }, diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 1a4f715565..93209acb5a 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -52,7 +52,8 @@ "delete": { "content": "Êtes-vous sûr de vouloir supprimer cette session ?", "error": { - "failed": "Échec de la suppression de la session" + "failed": "Échec de la suppression de la session", + "last": "Au moins une session doit être conservée" }, "title": "Supprimer la session" }, diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index da6a34f463..d23b5d15c9 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -52,7 +52,8 @@ "delete": { "content": "このセッションを削除してもよろしいですか?", "error": { - "failed": "セッションの削除に失敗しました" + "failed": "セッションの削除に失敗しました", + "last": "少なくとも1つのセッションを維持する必要があります" }, "title": "セッションを削除" }, diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 1c99745dfb..728347565c 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -52,7 +52,8 @@ "delete": { "content": "Tem certeza de que deseja excluir esta sessão?", "error": { - "failed": "Falha ao excluir a sessão" + "failed": "Falha ao excluir a sessão", + "last": "Pelo menos uma sessão deve ser mantida" }, "title": "Excluir sessão" }, diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 2fc0cb859a..59ff59243a 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -52,7 +52,8 @@ "delete": { "content": "Вы уверены, что хотите удалить этот сеанс?", "error": { - "failed": "Не удалось удалить сеанс" + "failed": "Не удалось удалить сеанс", + "last": "Должен быть сохранён хотя бы один сеанс" }, "title": "Удалить сеанс" }, 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 f3bfed18d6..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( @@ -34,14 +40,38 @@ const Sessions: React.FC = ({ agentId }) => { [dispatch] ) - const handleCreateSession = useCallback(() => { + const handleCreateSession = useCallback(async () => { if (!agent) return const session = { ...agent, id: undefined } satisfies CreateSessionForm - createSession(session) - }, [agent, createSession]) + const created = await createSession(session) + if (created) { + dispatch(setActiveSessionIdAction({ agentId, sessionId: created.id })) + } + }, [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] @@ -93,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