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:
parent
ec672495b3
commit
c465843bd9
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export {
|
||||
EmptyState,
|
||||
type EmptyStateProps,
|
||||
SessionItem,
|
||||
type SessionItemProps,
|
||||
} from "./ChatSessionComponents"
|
||||
export { useChatSessionHandlers, type UseChatSessionHandlersProps } from "./useChatSessionHandlers"
|
||||
export { isTaskSession, isUnreadSession } from "./utils"
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue