* decouple successfully * only show movable projects for project items * handle delete covnersations when projects is removed * fix leftpanel assignemtn * fix lint
346 lines
11 KiB
TypeScript
346 lines
11 KiB
TypeScript
import {
|
|
DndContext,
|
|
closestCenter,
|
|
useSensor,
|
|
useSensors,
|
|
PointerSensor,
|
|
KeyboardSensor,
|
|
} from '@dnd-kit/core'
|
|
import {
|
|
SortableContext,
|
|
verticalListSortingStrategy,
|
|
useSortable,
|
|
} from '@dnd-kit/sortable'
|
|
import { CSS } from '@dnd-kit/utilities'
|
|
import {
|
|
IconDots,
|
|
IconStarFilled,
|
|
IconStar,
|
|
IconFolder,
|
|
IconX,
|
|
} from '@tabler/icons-react'
|
|
import { useThreads } from '@/hooks/useThreads'
|
|
import { useThreadManagement } from '@/hooks/useThreadManagement'
|
|
import { useLeftPanel } from '@/hooks/useLeftPanel'
|
|
import { useMessages } from '@/hooks/useMessages'
|
|
import { cn, extractThinkingContent } from '@/lib/utils'
|
|
import { useSmallScreen } from '@/hooks/useMediaQuery'
|
|
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
|
import { memo, MouseEvent, useMemo, useState } from 'react'
|
|
import { useNavigate, useMatches } from '@tanstack/react-router'
|
|
import { RenameThreadDialog, DeleteThreadDialog } from '@/containers/dialogs'
|
|
import { route } from '@/constants/routes'
|
|
import { toast } from 'sonner'
|
|
|
|
const SortableItem = memo(
|
|
({
|
|
thread,
|
|
variant,
|
|
currentProjectId,
|
|
}: {
|
|
thread: Thread
|
|
variant?: 'default' | 'project'
|
|
currentProjectId?: string
|
|
}) => {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: thread.id, disabled: true })
|
|
|
|
const isSmallScreen = useSmallScreen()
|
|
const setLeftPanel = useLeftPanel((state) => state.setLeftPanel)
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.5 : 1,
|
|
}
|
|
const toggleFavorite = useThreads((state) => state.toggleFavorite)
|
|
const deleteThread = useThreads((state) => state.deleteThread)
|
|
const renameThread = useThreads((state) => state.renameThread)
|
|
const updateThread = useThreads((state) => state.updateThread)
|
|
const getFolderById = useThreadManagement().getFolderById
|
|
const { folders } = useThreadManagement()
|
|
const getMessages = useMessages((state) => state.getMessages)
|
|
const { t } = useTranslation()
|
|
const [openDropdown, setOpenDropdown] = useState(false)
|
|
const navigate = useNavigate()
|
|
// Check if current route matches this thread's detail page
|
|
const matches = useMatches()
|
|
const isActive = matches.some(
|
|
(match) =>
|
|
match.routeId === '/threads/$threadId' &&
|
|
'threadId' in match.params &&
|
|
match.params.threadId === thread.id
|
|
)
|
|
|
|
const handleClick = (e: MouseEvent<HTMLDivElement>) => {
|
|
if (openDropdown) {
|
|
e.stopPropagation()
|
|
e.preventDefault()
|
|
return
|
|
}
|
|
if (!isDragging) {
|
|
// Only close panel and navigate if the thread is not already active
|
|
if (!isActive) {
|
|
if (isSmallScreen) setLeftPanel(false)
|
|
navigate({ to: route.threadsDetail, params: { threadId: thread.id } })
|
|
}
|
|
}
|
|
}
|
|
|
|
const plainTitleForRename = useMemo(() => {
|
|
// Basic HTML stripping for simple span tags.
|
|
// If thread.title is undefined or null, treat as empty string before replace.
|
|
return (thread.title || '').replace(/<span[^>]*>|<\/span>/g, '')
|
|
}, [thread.title])
|
|
|
|
const availableProjects = useMemo(() => {
|
|
return folders
|
|
.filter((f) => {
|
|
// Exclude the current project page we're on
|
|
if (f.id === currentProjectId) return false
|
|
// Exclude the project this thread is already assigned to
|
|
if (f.id === thread.metadata?.project?.id) return false
|
|
return true
|
|
})
|
|
.sort((a, b) => b.updated_at - a.updated_at)
|
|
}, [folders, currentProjectId, thread.metadata?.project?.id])
|
|
|
|
const assignThreadToProject = (threadId: string, projectId: string) => {
|
|
const project = getFolderById(projectId)
|
|
if (project && updateThread) {
|
|
const projectMetadata = {
|
|
id: project.id,
|
|
name: project.name,
|
|
updated_at: project.updated_at,
|
|
}
|
|
|
|
updateThread(threadId, {
|
|
metadata: {
|
|
...thread.metadata,
|
|
project: projectMetadata,
|
|
},
|
|
})
|
|
|
|
toast.success(`Thread assigned to "${project.name}" successfully`)
|
|
}
|
|
}
|
|
|
|
const getLastMessageInfo = useMemo(() => {
|
|
const messages = getMessages(thread.id)
|
|
if (messages.length === 0) return null
|
|
|
|
const lastMessage = messages[messages.length - 1]
|
|
return {
|
|
date: new Date(lastMessage.created_at || 0),
|
|
content: lastMessage.content?.[0]?.text?.value || '',
|
|
}
|
|
}, [getMessages, thread.id])
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
{...attributes}
|
|
{...listeners}
|
|
className={cn(
|
|
'rounded hover:bg-left-panel-fg/10 flex items-center justify-between gap-2 px-1.5 group/thread-list transition-all',
|
|
variant === 'project'
|
|
? 'mb-2 rounded-lg px-4 border border-main-view-fg/10 bg-main-view-fg/5'
|
|
: 'mb-1',
|
|
isDragging ? 'cursor-move' : 'cursor-pointer',
|
|
isActive && 'bg-left-panel-fg/10'
|
|
)}
|
|
onClick={(e) => handleClick(e)}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setOpenDropdown(true)
|
|
}}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'pr-2 truncate flex-1',
|
|
variant === 'project' ? 'py-2 cursor-pointer' : 'py-1'
|
|
)}
|
|
>
|
|
<span>{thread.title || t('common:newThread')}</span>
|
|
{variant === 'project' && getLastMessageInfo?.content && (
|
|
<span className="block text-sm text-main-view-fg/60 mt-0.5 truncate">
|
|
{extractThinkingContent(getLastMessageInfo.content)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center">
|
|
<DropdownMenu
|
|
open={openDropdown}
|
|
onOpenChange={(open) => setOpenDropdown(open)}
|
|
>
|
|
<DropdownMenuTrigger asChild>
|
|
<IconDots
|
|
size={14}
|
|
className={cn(
|
|
'text-left-panel-fg/60 shrink-0 cursor-pointer px-0.5 -mr-1 data-[state=open]:bg-left-panel-fg/10 rounded group-hover/thread-list:data-[state=closed]:size-5 size-5 data-[state=closed]:size-0',
|
|
variant === 'project' && 'text-main-view-fg/60'
|
|
)}
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
}}
|
|
/>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent side="bottom" align="end" className="w-44">
|
|
{thread.isFavorite ? (
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
toggleFavorite(thread.id)
|
|
}}
|
|
>
|
|
<IconStarFilled />
|
|
<span>{t('common:unstar')}</span>
|
|
</DropdownMenuItem>
|
|
) : (
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
toggleFavorite(thread.id)
|
|
}}
|
|
>
|
|
<IconStar />
|
|
<span>{t('common:star')}</span>
|
|
</DropdownMenuItem>
|
|
)}
|
|
<RenameThreadDialog
|
|
thread={thread}
|
|
plainTitleForRename={plainTitleForRename}
|
|
onRename={renameThread}
|
|
onDropdownClose={() => setOpenDropdown(false)}
|
|
/>
|
|
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger className="gap-2">
|
|
<IconFolder size={16} />
|
|
<span>Add to project</span>
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent>
|
|
{availableProjects.length === 0 ? (
|
|
<DropdownMenuItem disabled>
|
|
<span className="text-left-panel-fg/50">
|
|
No projects available
|
|
</span>
|
|
</DropdownMenuItem>
|
|
) : (
|
|
availableProjects.map((folder) => (
|
|
<DropdownMenuItem
|
|
key={folder.id}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
assignThreadToProject(thread.id, folder.id)
|
|
}}
|
|
>
|
|
<IconFolder size={16} />
|
|
<span className="truncate max-w-[200px]">
|
|
{folder.name}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
))
|
|
)}
|
|
{thread.metadata?.project && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
// Remove project from metadata
|
|
const projectName = thread.metadata?.project?.name
|
|
updateThread(thread.id, {
|
|
metadata: {
|
|
...thread.metadata,
|
|
project: undefined,
|
|
},
|
|
})
|
|
toast.success(
|
|
`Thread removed from "${projectName}" successfully`
|
|
)
|
|
}}
|
|
>
|
|
<IconX size={16} />
|
|
<span>Remove from project</span>
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
<DropdownMenuSeparator />
|
|
<DeleteThreadDialog
|
|
thread={thread}
|
|
onDelete={deleteThread}
|
|
onDropdownClose={() => setOpenDropdown(false)}
|
|
variant={variant}
|
|
/>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
)
|
|
|
|
type ThreadListProps = {
|
|
threads: Thread[]
|
|
isFavoriteSection?: boolean
|
|
variant?: 'default' | 'project'
|
|
showDate?: boolean
|
|
currentProjectId?: string
|
|
}
|
|
|
|
function ThreadList({ threads, variant = 'default', currentProjectId }: ThreadListProps) {
|
|
const sortedThreads = useMemo(() => {
|
|
return threads.sort((a, b) => {
|
|
return (b.updated || 0) - (a.updated || 0)
|
|
})
|
|
}, [threads])
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: {
|
|
delay: 200,
|
|
tolerance: 5,
|
|
},
|
|
}),
|
|
useSensor(KeyboardSensor)
|
|
)
|
|
|
|
return (
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter}>
|
|
<SortableContext
|
|
items={sortedThreads.map((t) => t.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
{sortedThreads.map((thread, index) => (
|
|
<SortableItem key={index} thread={thread} variant={variant} currentProjectId={currentProjectId} />
|
|
))}
|
|
</SortableContext>
|
|
</DndContext>
|
|
)
|
|
}
|
|
|
|
export default memo(ThreadList)
|