From c465843bd974a54d5ef25de48552136d0436b05f Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 6 Nov 2025 22:47:14 +0800 Subject: [PATCH] feat(ai-chat): merge ChatHistoryDropdown and TaskReportDropdown - Refactored session handling by introducing shared components for session items and empty states. - Added functionality to filter and display both chat and task sessions in ChatHistoryDropdown. - Implemented loading states and delete session capabilities for better user experience. - Improved task session management with the ability to create new tasks directly from the dropdown. Signed-off-by: Innei --- .../layouts/ChatHistoryDropdown.tsx | 275 +++++++++++------- .../components/layouts/TaskReportDropdown.tsx | 212 +++----------- .../layouts/shared/ChatSessionComponents.tsx | 82 ++++++ .../components/layouts/shared/index.ts | 8 + .../layouts/shared/useChatSessionHandlers.tsx | 118 ++++++++ .../components/layouts/shared/utils.ts | 14 + 6 files changed, 422 insertions(+), 287 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/ChatSessionComponents.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/index.ts create mode 100644 apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/useChatSessionHandlers.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/utils.ts diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatHistoryDropdown.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatHistoryDropdown.tsx index 597ff4e99..40a626a01 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatHistoryDropdown.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatHistoryDropdown.tsx @@ -1,21 +1,31 @@ import { ActionButton } from "@follow/components/ui/button/index.js" +import { SegmentGroup, SegmentItem } from "@follow/components/ui/segment/index.js" +import { nextFrame } from "@follow/utils" import type { ReactNode } from "react" -import { startTransition, useCallback, useState } from "react" -import { useTranslation } from "react-i18next" +import { startTransition, useCallback, useMemo, useState } from "react" import { toast } from "sonner" -import { RelativeDay } from "~/components/ui/datetime" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu/dropdown-menu" -import { useDialog } from "~/components/ui/modal/stacked/hooks" +import { useModalStack } from "~/components/ui/modal/stacked/hooks" import { useChatHistory } from "~/modules/ai-chat/hooks/useChatHistory" -import { useTimelineSummaryAutoContext } from "~/modules/ai-chat/hooks/useTimelineSummaryAutoContext" -import { AIPersistService } from "~/modules/ai-chat/services" -import { useChatActions, useCurrentChatId } from "~/modules/ai-chat/store/hooks" +import { useAIChatSessionListQuery } from "~/modules/ai-chat-session/query" +import { AITaskModal, useAITaskListQuery, useCanCreateNewAITask } from "~/modules/ai-task" +import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack" +import { AI_SETTING_SECTION_IDS } from "~/modules/settings/tabs/ai" + +import { + EmptyState, + isTaskSession, + isUnreadSession, + SessionItem, + useChatSessionHandlers, +} from "./shared" interface ChatHistoryDropdownProps { triggerElement?: ReactNode @@ -26,66 +36,83 @@ export const ChatHistoryDropdown = ({ triggerElement, asChild = true, }: ChatHistoryDropdownProps) => { - const { t } = useTranslation("ai") - const chatActions = useChatActions() - const currentChatId = useCurrentChatId() - const shouldDisableTimelineSummary = useTimelineSummaryAutoContext() - const { ask } = useDialog() - const [deletingChatId, setDeletingChatId] = useState(null) + const [loadingChatId, setLoadingChatId] = useState(null) + const [activeTab, setActiveTab] = useState("chats") const { sessions, loading, loadHistory } = useChatHistory() + // Task session related hooks + const tasks = useAITaskListQuery() + const taskSessions = useAIChatSessionListQuery({ + refetchInterval: tasks?.length ? 1 * 60 * 1000 : false, + }) + const { present } = useModalStack() + const canCreateNewTask = useCanCreateNewAITask() + const showSettings = useSettingModal() + + // Merge both session types + const allSessions = useMemo(() => { + const regularSessions = sessions || [] + const aiTaskSessions = taskSessions || [] + return [...regularSessions, ...aiTaskSessions] + }, [sessions, taskSessions]) + + // Filter sessions by type + const regularSessions = useMemo(() => { + return (sessions || []).filter((s) => !isTaskSession(s)) + }, [sessions]) + + const taskSessionsFiltered = useMemo(() => { + return (taskSessions || []).filter((s) => isTaskSession(s)) + }, [taskSessions]) + + // Count unread sessions + const hasUnreadRegularSessions = useMemo(() => { + return regularSessions.some((s) => isUnreadSession(s)) + }, [regularSessions]) + + const hasUnreadTaskSessions = useMemo(() => { + return taskSessionsFiltered.some((s) => isUnreadSession(s)) + }, [taskSessionsFiltered]) + + const handleScheduleActionClick = () => { + if (!canCreateNewTask) { + toast.error("Please remove an existing task before creating a new one.") + return + } + showSettings({ tab: "ai", section: AI_SETTING_SECTION_IDS.tasks }) + nextFrame(() => { + present({ + title: "New AI Task", + canClose: true, + content: () => , + }) + }) + } + + const { handleSessionSelect, handleDeleteSession } = useChatSessionHandlers({ + sessions: allSessions, + }) + const handleDropdownOpen = useCallback( - (open: boolean) => { - if (open) { - loadHistory() - // AIPersistService.cleanupEmptySessions() + (isOpen: boolean) => { + if (isOpen) { + startTransition(() => { + loadHistory() + }) } }, [loadHistory], ) - const handleDeleteSession = useCallback( - async (chatId: string, e: React.MouseEvent) => { - e.stopPropagation() - e.preventDefault() - - const session = sessions.find((s) => s.chatId === chatId) - if (!session) return - - const confirm = await ask({ - title: t("delete_chat"), - message: t("delete_chat_message", { title: session.title || t("common.new_chat") }), - variant: "danger", - }) - - if (!confirm) return - - setDeletingChatId(chatId) - try { - await AIPersistService.deleteSession(chatId) - toast.success(t("delete_chat_success")) - - if (chatId === currentChatId) { - if (shouldDisableTimelineSummary) { - chatActions.setTimelineSummaryManualOverride(true) - } - chatActions.newChat() - } - - loadHistory() - } catch (error) { - console.error("Failed to delete session:", error) - toast.error(t("delete_chat_error")) - } finally { - setDeletingChatId(null) - } - }, - [sessions, ask, t, currentChatId, loadHistory, chatActions, shouldDisableTimelineSummary], - ) - const defaultTrigger = ( - + + {(hasUnreadRegularSessions || hasUnreadTaskSessions) && ( + + )} ) @@ -94,58 +121,90 @@ export const ChatHistoryDropdown = ({ {triggerElement || defaultTrigger} - - {loading && sessions.length === 0 ? ( -
- -
- ) : sessions.length > 0 ? ( - <> -
-

Recent Chats

-
- {sessions.map((session) => ( - - startTransition(() => { - chatActions.setTimelineSummaryManualOverride(true) - chatActions.switchToChat(session.chatId) - }) - } - className="group flex h-8 cursor-pointer items-center justify-between rounded-md px-2 py-3" - > -
-

- {session.title || t("common.new_chat")} -

+ + + + Chats + {hasUnreadRegularSessions && } + + } + /> + + Tasks + {hasUnreadTaskSessions && } + + } + /> + + +
+ {activeTab === "chats" ? ( + loading && sessions.length === 0 ? ( +
+ +
+ ) : regularSessions.length > 0 ? ( + <> +
+

Recent Chats

-
- - - - -
- - ))} - - ) : ( -
- -

No chat history yet

-
- )} + {regularSessions.map((session) => ( + handleSessionSelect(session)} + onDelete={(e) => { + setLoadingChatId(session.chatId) + handleDeleteSession(session.chatId, e).finally(() => { + setLoadingChatId(null) + }) + }} + isLoading={loadingChatId === session.chatId} + /> + ))} + + ) : ( + + ) + ) : taskSessionsFiltered.length > 0 ? ( + <> +
+

Task Sessions

+
+ {taskSessionsFiltered.map((session) => ( + handleSessionSelect(session)} + onDelete={(e) => { + setLoadingChatId(session.chatId) + handleDeleteSession(session.chatId, e).finally(() => { + setLoadingChatId(null) + }) + }} + isLoading={loadingChatId === session.chatId} + /> + ))} + {canCreateNewTask && ( + <> + + + + New Task + + + )} + + ) : ( + + )} +
) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/TaskReportDropdown.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/TaskReportDropdown.tsx index fcad57c2e..e717c8dc2 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/TaskReportDropdown.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/TaskReportDropdown.tsx @@ -1,12 +1,9 @@ import { ActionButton } from "@follow/components/ui/button/index.js" import { nextFrame } from "@follow/utils" -import type { AIChatSession } from "@follow-app/client-sdk" import type { ReactElement } from "react" -import { useCallback, useMemo, useState } from "react" -import { useTranslation } from "react-i18next" +import { useMemo, useState } from "react" import { toast } from "sonner" -import { RelativeDay } from "~/components/ui/datetime" import { DropdownMenu, DropdownMenuContent, @@ -14,117 +11,33 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu/dropdown-menu" -import { useDialog, useModalStack } from "~/components/ui/modal/stacked/hooks" -import { useTimelineSummaryAutoContext } from "~/modules/ai-chat/hooks/useTimelineSummaryAutoContext" -import { useChatActions, useCurrentChatId } from "~/modules/ai-chat/store/hooks" -import { AIChatSessionService } from "~/modules/ai-chat-session" -import { - useAIChatSessionListQuery, - useDeleteAIChatSessionMutation, - useMarkChatSessionSeenMutation, -} from "~/modules/ai-chat-session/query" +import { useModalStack } from "~/components/ui/modal/stacked/hooks" +import { useAIChatSessionListQuery } from "~/modules/ai-chat-session/query" import { AITaskModal, useAITaskListQuery, useCanCreateNewAITask } from "~/modules/ai-task" import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack" import { AI_SETTING_SECTION_IDS } from "~/modules/settings/tabs/ai" -import { AIPersistService } from "../../services" +import { + EmptyState, + isTaskSession, + isUnreadSession, + SessionItem, + useChatSessionHandlers, +} from "./shared" interface TaskReportDropdownProps { triggerElement?: ReactElement asChild?: boolean } -interface SessionItemProps { - hasUnread?: boolean - session: AIChatSession - onClick?: () => void - onDelete?: (e: React.MouseEvent) => void - isLoading?: boolean -} - -const isTaskSession = (session: AIChatSession): boolean => { - if (!session.lastSeenAt || !session.updatedAt) return false - if (!session.chatId.startsWith("ai-task")) return false - return true -} - -// Helper to determine if a session has unread messages -const isUnreadSession = (session: AIChatSession): boolean => { - return new Date(session.updatedAt) > new Date(session.lastSeenAt) -} -const SessionItem = ({ - session, - onClick, - onDelete, - isLoading, - hasUnread = false, -}: SessionItemProps) => { - const hasUnreadMessages = isUnreadSession(session) - return ( - -
-
- {hasUnreadMessages && ( - - )} -

- {session.title || "Untitled Chat"} -

-
-
-

- -

- {onDelete && ( - - )} -
-
-
- ) -} - -const EmptyState = () => { - return ( -
- -

No unread task reports

-
- ) -} - export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskReportDropdownProps) => { const tasks = useAITaskListQuery() const sessions = useAIChatSessionListQuery({ refetchInterval: tasks?.length ? 1 * 60 * 1000 : false, // 1 minute }) - const currentChatId = useCurrentChatId() - const chatActions = useChatActions() - const shouldDisableTimelineSummary = useTimelineSummaryAutoContext() - const deleteSessionMutation = useDeleteAIChatSessionMutation() - const { ask } = useDialog() - const { t } = useTranslation("ai") const [loadingChatId, setLoadingChatId] = useState(null) const showSettings = useSettingModal() - const markChatSessionSeenMutation = useMarkChatSessionSeenMutation() + // Only keep task sessions for display const taskSessions = useMemo(() => (sessions || []).filter((s) => isTaskSession(s)), [sessions]) const hasTaskSessions = taskSessions.length > 0 @@ -133,8 +46,18 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor [taskSessions], ) + // Call all hooks at the top level, never conditionally const { present } = useModalStack() const canCreateNewTask = useCanCreateNewAITask() + const { handleSessionSelect, handleDeleteSession } = useChatSessionHandlers({ + sessions: taskSessions, + }) + + // If no unread sessions, don't render the button (only when no custom trigger) + if (!hasUnreadSessions && !triggerElement) { + return null + } + const handleScheduleActionClick = () => { if (!canCreateNewTask) { toast.error("Please remove an existing task before creating a new one.") @@ -150,80 +73,6 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor }) } - const handleSessionSelect = useCallback( - async (session: AIChatSession) => { - if (session.chatId === currentChatId) { - console.warn("Session already active, no action taken") - return - } - try { - await AIChatSessionService.fetchAndPersistMessages(session) - } catch (e) { - console.error("Failed to sync chat session messages:", e) - toast.error("Failed to load chat messages") - } - if (shouldDisableTimelineSummary) { - chatActions.setTimelineSummaryManualOverride(true) - } - chatActions.switchToChat(session.chatId) - if (isUnreadSession(session)) { - markChatSessionSeenMutation.mutate({ - chatId: session.chatId, - lastSeenAt: new Date().toISOString(), - }) - } - }, - [chatActions, currentChatId, markChatSessionSeenMutation, shouldDisableTimelineSummary], - ) - - const handleDeleteSession = useCallback( - async (chatId: string, e: React.MouseEvent) => { - e.stopPropagation() - e.preventDefault() - - const session = sessions?.find((s) => s.chatId === chatId) - if (!session) return - - const confirm = await ask({ - title: t("delete_chat"), - message: t("delete_chat_message", { title: session.title || "Untitled Chat" }), - variant: "danger", - }) - - if (!confirm) return - - setLoadingChatId(chatId) - try { - await Promise.all([ - deleteSessionMutation.mutateAsync({ chatId }), - AIPersistService.deleteSession(chatId), - ]) - toast.success(t("delete_chat_success")) - - if (chatId === currentChatId) { - if (shouldDisableTimelineSummary) { - chatActions.setTimelineSummaryManualOverride(true) - } - chatActions.newChat() - } - } catch (error) { - console.error("Failed to delete session:", error) - toast.error(t("delete_chat_error")) - } finally { - setLoadingChatId(null) - } - }, - [ - sessions, - ask, - t, - currentChatId, - chatActions, - deleteSessionMutation, - shouldDisableTimelineSummary, - ], - ) - const defaultTrigger = ( @@ -236,10 +85,6 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor ) - const hasOneOfUnread = useMemo(() => { - return taskSessions.some((s) => isUnreadSession(s)) - }, [taskSessions]) - return ( {asChild ? ( @@ -262,16 +107,25 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor {taskSessions.length > 0 ? ( taskSessions.map((session) => ( handleSessionSelect(session)} - onDelete={(e) => handleDeleteSession(session.chatId, e)} + onDelete={(e) => { + setLoadingChatId(session.chatId) + handleDeleteSession(session.chatId, e).finally(() => { + setLoadingChatId(null) + }) + }} isLoading={loadingChatId === session.chatId} /> )) ) : ( - + + } + /> )} {canCreateNewTask && ( diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/ChatSessionComponents.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/ChatSessionComponents.tsx new file mode 100644 index 000000000..1d5d79d66 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/ChatSessionComponents.tsx @@ -0,0 +1,82 @@ +import type { AIChatSession } from "@follow-app/client-sdk" +import type { ReactNode } from "react" + +import { RelativeDay } from "~/components/ui/datetime" +import { DropdownMenuItem } from "~/components/ui/dropdown-menu/dropdown-menu" +import type { ChatSession } from "~/modules/ai-chat/types/ChatSession" + +import { isUnreadSession } from "./utils" + +// Types +export interface SessionItemProps { + session: ChatSession | AIChatSession + onClick?: () => void + onDelete?: (e: React.MouseEvent) => void + isLoading?: boolean + hasUnread?: boolean +} + +export interface EmptyStateProps { + message: string + icon?: ReactNode +} + +// Components +export const SessionItem = ({ + session, + onClick, + onDelete, + isLoading, + hasUnread = false, +}: SessionItemProps) => { + const hasUnreadMessages = isUnreadSession(session) + return ( + +
+
+ {hasUnreadMessages && ( + + )} +

+ {session.title || "Untitled Chat"} +

+
+
+

+ +

+ {onDelete && ( + + )} +
+
+
+ ) +} + +export const EmptyState = ({ message, icon }: EmptyStateProps) => { + return ( +
+ {icon || } +

{message}

+
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/index.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/index.ts new file mode 100644 index 000000000..ab7fd8c8c --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/index.ts @@ -0,0 +1,8 @@ +export { + EmptyState, + type EmptyStateProps, + SessionItem, + type SessionItemProps, +} from "./ChatSessionComponents" +export { useChatSessionHandlers, type UseChatSessionHandlersProps } from "./useChatSessionHandlers" +export { isTaskSession, isUnreadSession } from "./utils" diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/useChatSessionHandlers.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/useChatSessionHandlers.tsx new file mode 100644 index 000000000..194a1543b --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/useChatSessionHandlers.tsx @@ -0,0 +1,118 @@ +import type { AIChatSession } from "@follow-app/client-sdk" +import { useCallback } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { useDialog } from "~/components/ui/modal/stacked/hooks" +import { useTimelineSummaryAutoContext } from "~/modules/ai-chat/hooks/useTimelineSummaryAutoContext" +import { AIPersistService } from "~/modules/ai-chat/services" +import { useChatActions, useCurrentChatId } from "~/modules/ai-chat/store/hooks" +import type { ChatSession } from "~/modules/ai-chat/types/ChatSession" +import { AIChatSessionService } from "~/modules/ai-chat-session" +import { + useDeleteAIChatSessionMutation, + useMarkChatSessionSeenMutation, +} from "~/modules/ai-chat-session/query" + +import { isUnreadSession } from "./utils" + +export interface UseChatSessionHandlersProps { + sessions?: (ChatSession | AIChatSession)[] +} + +export const useChatSessionHandlers = ({ sessions = [] }: UseChatSessionHandlersProps) => { + const { t } = useTranslation("ai") + const chatActions = useChatActions() + const currentChatId = useCurrentChatId() + const shouldDisableTimelineSummary = useTimelineSummaryAutoContext() + const { ask } = useDialog() + const deleteSessionMutation = useDeleteAIChatSessionMutation() + const markChatSessionSeenMutation = useMarkChatSessionSeenMutation() + + const handleSessionSelect = useCallback( + async (session: ChatSession | AIChatSession) => { + if (session.chatId === currentChatId) { + console.warn("Session already active, no action taken") + return + } + + // Only sync AI chat sessions (not local ChatSession) + if ("userId" in session) { + try { + await AIChatSessionService.fetchAndPersistMessages(session as AIChatSession) + } catch (e) { + console.error("Failed to sync chat session messages:", e) + toast.error("Failed to load chat messages") + } + } + + if (shouldDisableTimelineSummary) { + chatActions.setTimelineSummaryManualOverride(true) + } + chatActions.switchToChat(session.chatId) + + // Mark as seen only for AI chat sessions + if ("userId" in session && isUnreadSession(session)) { + markChatSessionSeenMutation.mutate({ + chatId: session.chatId, + lastSeenAt: new Date().toISOString(), + }) + } + }, + [chatActions, currentChatId, markChatSessionSeenMutation, shouldDisableTimelineSummary], + ) + + const handleDeleteSession = useCallback( + async (chatId: string, e: React.MouseEvent) => { + e.stopPropagation() + e.preventDefault() + + const session = sessions?.find((s) => s.chatId === chatId) + if (!session) return + + const confirm = await ask({ + title: t("delete_chat"), + message: t("delete_chat_message", { title: session.title || "Untitled Chat" }), + variant: "danger", + }) + + if (!confirm) return + + try { + // Only delete AI chat sessions through the mutation + if ("userId" in session) { + await deleteSessionMutation.mutateAsync({ chatId }) + } + + // Always delete from local persistence + await AIPersistService.deleteSession(chatId) + + toast.success(t("delete_chat_success")) + + if (chatId === currentChatId) { + if (shouldDisableTimelineSummary) { + chatActions.setTimelineSummaryManualOverride(true) + } + chatActions.newChat() + } + } catch (error) { + console.error("Failed to delete session:", error) + toast.error(t("delete_chat_error")) + } + }, + [ + sessions, + ask, + t, + currentChatId, + chatActions, + deleteSessionMutation, + shouldDisableTimelineSummary, + ], + ) + + return { + handleSessionSelect, + handleDeleteSession, + } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/utils.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/utils.ts new file mode 100644 index 000000000..4fba61875 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/utils.ts @@ -0,0 +1,14 @@ +import type { AIChatSession } from "@follow-app/client-sdk" + +import type { ChatSession } from "~/modules/ai-chat/types/ChatSession" + +export const isTaskSession = (session: ChatSession | AIChatSession): boolean => { + if (!("lastSeenAt" in session) || !("updatedAt" in session)) return false + if (!("chatId" in session) || !session.chatId.startsWith("ai-task")) return false + return true +} + +export const isUnreadSession = (session: ChatSession | AIChatSession): boolean => { + if (!("lastSeenAt" in session) || !("updatedAt" in session)) return false + return new Date(session.updatedAt) > new Date(session.lastSeenAt) +}