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 <tukon479@gmail.com>
This commit is contained in:
Innei 2025-11-06 22:47:14 +08:00
parent ec672495b3
commit c465843bd9
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
6 changed files with 422 additions and 287 deletions

View File

@ -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<string | null>(null)
const [loadingChatId, setLoadingChatId] = useState<string | null>(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: () => <AITaskModal showSettingsTip />,
})
})
}
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 = (
<ActionButton tooltip="Chat History">
<ActionButton tooltip="Chat History" className="relative">
<i className="i-mgc-history-cute-re size-5 text-text-secondary" />
{(hasUnreadRegularSessions || hasUnreadTaskSessions) && (
<span
className="absolute right-1 top-1 block size-2 rounded-full bg-accent shadow-[0_0_0_2px_var(--color-bg-default)] dark:shadow-[0_0_0_2px_var(--color-bg-default)]"
aria-label="Unread messages"
/>
)}
</ActionButton>
)
@ -94,58 +121,90 @@ export const ChatHistoryDropdown = ({
<DropdownMenuTrigger asChild={asChild}>
{triggerElement || defaultTrigger}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-96 w-72 overflow-y-auto">
{loading && sessions.length === 0 ? (
<div className="flex items-center justify-center py-8">
<i className="i-mgc-loading-3-cute-re size-5 animate-spin text-text-secondary" />
</div>
) : sessions.length > 0 ? (
<>
<div className="mb-1.5 px-2 py-1">
<p className="text-xs font-medium text-text-secondary">Recent Chats</p>
</div>
{sessions.map((session) => (
<DropdownMenuItem
key={session.chatId}
onClick={() =>
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"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{session.title || t("common.new_chat")}
</p>
<DropdownMenuContent align="start" className="w-80">
<SegmentGroup value={activeTab} onValueChanged={setActiveTab} className="mb-4 w-full">
<SegmentItem
value="chats"
label={
<span className="flex items-center gap-1">
Chats
{hasUnreadRegularSessions && <span className="size-1.5 rounded-full bg-accent" />}
</span>
}
/>
<SegmentItem
value="tasks"
label={
<span className="flex items-center gap-1">
Tasks
{hasUnreadTaskSessions && <span className="size-1.5 rounded-full bg-accent" />}
</span>
}
/>
</SegmentGroup>
<div className="max-h-80 overflow-y-auto">
{activeTab === "chats" ? (
loading && sessions.length === 0 ? (
<div className="flex items-center justify-center py-8">
<i className="i-mgc-loading-3-cute-re size-5 animate-spin text-text-secondary" />
</div>
) : regularSessions.length > 0 ? (
<>
<div className="mb-1.5 px-2 py-1">
<p className="text-xs font-medium text-text-secondary">Recent Chats</p>
</div>
<div className="relative flex min-w-0 items-center">
<span className="ml-2 shrink-0 cursor-help text-xs text-text-secondary group-data-[highlighted]:text-text-secondary-dark">
<RelativeDay date={session.updatedAt} />
</span>
<button
type="button"
onClick={(e) => handleDeleteSession(session.chatId, e)}
className="absolute inset-y-0 right-0 flex items-center bg-accent px-2 py-1 text-white opacity-0 shadow-lg backdrop-blur-sm group-data-[highlighted]:text-white group-data-[highlighted]:opacity-100"
disabled={deletingChatId === session.chatId}
>
{deletingChatId === session.chatId ? (
<i className="i-mgc-loading-3-cute-re size-4 animate-spin" />
) : (
<i className="i-mgc-delete-2-cute-re size-4" />
)}
</button>
</div>
</DropdownMenuItem>
))}
</>
) : (
<div className="flex flex-col items-center py-8 text-center">
<i className="i-mgc-time-cute-re mb-2 block size-8 text-text-secondary" />
<p className="text-sm text-text-secondary">No chat history yet</p>
</div>
)}
{regularSessions.map((session) => (
<SessionItem
key={session.chatId}
session={session}
onClick={() => handleSessionSelect(session)}
onDelete={(e) => {
setLoadingChatId(session.chatId)
handleDeleteSession(session.chatId, e).finally(() => {
setLoadingChatId(null)
})
}}
isLoading={loadingChatId === session.chatId}
/>
))}
</>
) : (
<EmptyState message="No chat history yet" />
)
) : taskSessionsFiltered.length > 0 ? (
<>
<div className="mb-1.5 px-2 py-1">
<p className="text-xs font-medium text-text-secondary">Task Sessions</p>
</div>
{taskSessionsFiltered.map((session) => (
<SessionItem
key={session.chatId}
session={session}
onClick={() => handleSessionSelect(session)}
onDelete={(e) => {
setLoadingChatId(session.chatId)
handleDeleteSession(session.chatId, e).finally(() => {
setLoadingChatId(null)
})
}}
isLoading={loadingChatId === session.chatId}
/>
))}
{canCreateNewTask && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleScheduleActionClick}>
<i className="i-mgc-add-cute-re mr-2 size-4" />
New Task
</DropdownMenuItem>
</>
)}
</>
) : (
<EmptyState message="No task sessions yet" />
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@ -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 (
<DropdownMenuItem
onClick={onClick}
className={`group relative ${onClick ? "cursor-pointer" : "cursor-default"}`}
>
<div className="ml-1 flex min-w-0 flex-1 justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
{hasUnreadMessages && (
<span
className="absolute left-2 block size-2 shrink-0 rounded-full bg-accent group-hover:bg-white"
aria-label="Unread"
role="status"
/>
)}
<p className={`mb-0.5 truncate font-medium ${hasUnread ? "ml-2" : ""}`}>
{session.title || "Untitled Chat"}
</p>
</div>
<div className="relative flex min-w-0 items-center">
<p className="ml-2 shrink-0 truncate text-xs text-text-secondary">
<RelativeDay date={new Date(session.updatedAt)} />
</p>
{onDelete && (
<button
type="button"
onClick={onDelete}
className="absolute inset-y-0 right-0 flex items-center bg-accent px-2 py-1 text-white opacity-0 shadow-lg backdrop-blur-sm group-data-[highlighted]:text-white group-data-[highlighted]:opacity-100"
disabled={isLoading}
>
{isLoading ? (
<i className="i-mgc-loading-3-cute-re size-4 animate-spin" />
) : (
<i className="i-mgc-delete-2-cute-re size-4" />
)}
</button>
)}
</div>
</div>
</DropdownMenuItem>
)
}
const EmptyState = () => {
return (
<div className="flex flex-col items-center py-8 text-center">
<i className="i-mgc-calendar-time-add-cute-re mb-2 block size-8 text-text-secondary" />
<p className="text-sm text-text-secondary">No unread task reports</p>
</div>
)
}
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<string | null>(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 = (
<ActionButton tooltip="Task Reports" className="relative">
<i className="i-mgc-calendar-time-add-cute-re size-5 text-text-secondary" />
@ -236,10 +85,6 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor
</ActionButton>
)
const hasOneOfUnread = useMemo(() => {
return taskSessions.some((s) => isUnreadSession(s))
}, [taskSessions])
return (
<DropdownMenu>
{asChild ? (
@ -262,16 +107,25 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor
{taskSessions.length > 0 ? (
taskSessions.map((session) => (
<SessionItem
hasUnread={hasOneOfUnread}
key={session.chatId}
session={session}
onClick={() => handleSessionSelect(session)}
onDelete={(e) => handleDeleteSession(session.chatId, e)}
onDelete={(e) => {
setLoadingChatId(session.chatId)
handleDeleteSession(session.chatId, e).finally(() => {
setLoadingChatId(null)
})
}}
isLoading={loadingChatId === session.chatId}
/>
))
) : (
<EmptyState />
<EmptyState
message="No unread task reports"
icon={
<i className="i-mgc-calendar-time-add-cute-re mb-2 block size-8 text-text-secondary" />
}
/>
)}
{canCreateNewTask && (

View File

@ -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 (
<DropdownMenuItem
onClick={onClick}
className={`group relative ${onClick ? "cursor-pointer" : "cursor-default"}`}
>
<div className="ml-1 flex min-w-0 flex-1 justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
{hasUnreadMessages && (
<span
className="absolute left-2 block size-2 shrink-0 rounded-full bg-accent group-hover:bg-white"
aria-label="Unread"
role="status"
/>
)}
<p className={`mb-0.5 truncate font-medium ${hasUnread ? "ml-2" : ""}`}>
{session.title || "Untitled Chat"}
</p>
</div>
<div className="relative flex min-w-0 items-center">
<p className="ml-2 shrink-0 truncate text-xs text-text-secondary">
<RelativeDay date={new Date(session.updatedAt)} />
</p>
{onDelete && (
<button
type="button"
onClick={onDelete}
className="absolute inset-y-0 right-0 flex items-center bg-accent px-2 py-1 text-white opacity-0 shadow-lg backdrop-blur-sm group-data-[highlighted]:text-white group-data-[highlighted]:opacity-100"
disabled={isLoading}
>
{isLoading ? (
<i className="i-mgc-loading-3-cute-re size-4 animate-spin" />
) : (
<i className="i-mgc-delete-2-cute-re size-4" />
)}
</button>
)}
</div>
</div>
</DropdownMenuItem>
)
}
export const EmptyState = ({ message, icon }: EmptyStateProps) => {
return (
<div className="flex flex-col items-center py-8 text-center">
{icon || <i className="i-mgc-time-cute-re mb-2 block size-8 text-text-secondary" />}
<p className="text-sm text-text-secondary">{message}</p>
</div>
)
}

View File

@ -0,0 +1,8 @@
export {
EmptyState,
type EmptyStateProps,
SessionItem,
type SessionItemProps,
} from "./ChatSessionComponents"
export { useChatSessionHandlers, type UseChatSessionHandlersProps } from "./useChatSessionHandlers"
export { isTaskSession, isUnreadSession } from "./utils"

View File

@ -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,
}
}

View File

@ -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)
}