feat: thread organization folder

This commit is contained in:
Faisal Amir 2025-09-25 10:12:08 +07:00
parent 3dcf522224
commit e7a1a06395
23 changed files with 1735 additions and 264 deletions

View File

@ -21,4 +21,5 @@ export const localStorageKey = {
lastUsedAssistant: 'last-used-assistant',
favoriteModels: 'favorite-models',
setupCompleted: 'setup-completed',
threadManagement: 'thread-management',
}

View File

@ -3,6 +3,8 @@ export const route = {
home: '/',
appLogs: '/logs',
assistant: '/assistant',
project: '/project',
projectDetail: '/project/$projectId',
settings: {
index: '/settings',
model_providers: '/settings/providers',

View File

@ -4,6 +4,7 @@ import TextareaAutosize from 'react-textarea-autosize'
import { cn } from '@/lib/utils'
import { usePrompt } from '@/hooks/usePrompt'
import { useThreads } from '@/hooks/useThreads'
import { useThreadManagement } from '@/hooks/useThreadManagement'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
@ -43,9 +44,15 @@ type ChatInputProps = {
showSpeedToken?: boolean
model?: ThreadModel
initialMessage?: boolean
projectId?: string
}
const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
const ChatInput = ({
model,
className,
initialMessage,
projectId,
}: ChatInputProps) => {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [isFocused, setIsFocused] = useState(false)
const [rows, setRows] = useState(1)
@ -58,6 +65,8 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
const prompt = usePrompt((state) => state.prompt)
const setPrompt = usePrompt((state) => state.setPrompt)
const currentThreadId = useThreads((state) => state.currentThreadId)
const updateThread = useThreads((state) => state.updateThread)
const { getFolderById } = useThreadManagement()
const { t } = useTranslation()
const spellCheckChatInput = useGeneralSetting(
(state) => state.spellCheckChatInput
@ -177,6 +186,28 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
uploadedFiles.length > 0 ? uploadedFiles : undefined
)
setUploadedFiles([])
// Handle project assignment for new threads
if (projectId && !currentThreadId) {
const project = getFolderById(projectId)
if (project) {
// Use setTimeout to ensure the thread is created first
setTimeout(() => {
const newCurrentThreadId = useThreads.getState().currentThreadId
if (newCurrentThreadId) {
updateThread(newCurrentThreadId, {
metadata: {
project: {
id: project.id,
name: project.name,
updated_at: project.updated_at,
},
},
})
}
}, 100)
}
}
}
useEffect(() => {

View File

@ -4,14 +4,18 @@ import { cn } from '@/lib/utils'
import {
IconLayoutSidebar,
IconDots,
IconCirclePlusFilled,
IconSettingsFilled,
IconCirclePlus,
IconSettings,
IconStar,
IconMessageFilled,
IconAppsFilled,
IconFolderPlus,
IconMessage,
IconApps,
IconX,
IconSearch,
IconClipboardSmileFilled,
IconClipboardSmile,
IconFolder,
IconPencil,
IconTrash,
} from '@tabler/icons-react'
import { route } from '@/constants/routes'
import ThreadList from './ThreadList'
@ -28,6 +32,7 @@ import { UserProfileMenu } from '@/containers/auth/UserProfileMenu'
import { useAuth } from '@/hooks/useAuth'
import { useThreads } from '@/hooks/useThreads'
import { useThreadManagement } from '@/hooks/useThreadManagement'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { useMemo, useState, useEffect, useRef } from 'react'
@ -37,38 +42,40 @@ import { useSmallScreen } from '@/hooks/useMediaQuery'
import { useClickOutside } from '@/hooks/useClickOutside'
import { DeleteAllThreadsDialog } from '@/containers/dialogs'
import AddProjectDialog from '@/containers/dialogs/AddProjectDialog'
import { DeleteProjectDialog } from '@/containers/dialogs/DeleteProjectDialog'
const mainMenus = [
{
title: 'common:newChat',
icon: IconCirclePlusFilled,
icon: IconCirclePlus,
route: route.home,
isEnabled: true,
},
{
title: 'Projects',
icon: IconFolderPlus,
route: route.project,
isEnabled: true,
},
{
title: 'common:assistants',
icon: IconClipboardSmileFilled,
icon: IconClipboardSmile,
route: route.assistant,
isEnabled: PlatformFeatures[PlatformFeature.ASSISTANTS],
},
{
title: 'common:hub',
icon: IconAppsFilled,
icon: IconApps,
route: route.hub.index,
isEnabled: PlatformFeatures[PlatformFeature.MODEL_HUB],
},
{
title: 'common:settings',
icon: IconSettingsFilled,
icon: IconSettings,
route: route.settings.general,
isEnabled: true,
},
{
title: 'common:authentication',
icon: null,
route: null,
isEnabled: PlatformFeatures[PlatformFeature.AUTHENTICATION],
},
]
const LeftPanel = () => {
@ -152,20 +159,65 @@ const LeftPanel = () => {
const getFilteredThreads = useThreads((state) => state.getFilteredThreads)
const threads = useThreads((state) => state.threads)
const { folders, addFolder, updateFolder, deleteFolder, getFolderById } =
useThreadManagement()
// Project dialog states
const [projectDialogOpen, setProjectDialogOpen] = useState(false)
const [editingProjectKey, setEditingProjectKey] = useState<string | null>(
null
)
const [deleteProjectConfirmOpen, setDeleteProjectConfirmOpen] =
useState(false)
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(
null
)
const filteredThreads = useMemo(() => {
return getFilteredThreads(searchTerm)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getFilteredThreads, searchTerm, threads])
const filteredProjects = useMemo(() => {
if (!searchTerm) return folders
return folders.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase())
)
}, [folders, searchTerm])
// Memoize categorized threads based on filteredThreads
const favoritedThreads = useMemo(() => {
return filteredThreads.filter((t) => t.isFavorite)
}, [filteredThreads])
const unFavoritedThreads = useMemo(() => {
return filteredThreads.filter((t) => !t.isFavorite)
return filteredThreads.filter((t) => !t.isFavorite && !t.metadata?.project)
}, [filteredThreads])
// Project handlers
const handleProjectDelete = (id: string) => {
setDeletingProjectId(id)
setDeleteProjectConfirmOpen(true)
}
const confirmProjectDelete = () => {
if (deletingProjectId) {
deleteFolder(deletingProjectId)
setDeleteProjectConfirmOpen(false)
setDeletingProjectId(null)
}
}
const handleProjectSave = (name: string) => {
if (editingProjectKey) {
updateFolder(editingProjectKey, name)
} else {
addFolder(name)
}
setProjectDialogOpen(false)
setEditingProjectKey(null)
}
// Disable body scroll when panel is open on small screens
useEffect(() => {
if (isSmallScreen && open) {
@ -260,15 +312,12 @@ const LeftPanel = () => {
)}
</div>
<div className="flex flex-col justify-between overflow-hidden mt-0 !h-[calc(100%-42px)] ">
<div className={cn('flex flex-col !h-[calc(100%-200px)]')}>
<div className="flex flex-col gap-y-1 overflow-hidden mt-0 !h-[calc(100%-42px)]">
<div className="space-y-1 py-1">
{IS_MACOS && (
<div
ref={searchContainerMacRef}
className={cn(
'relative mb-4 mt-1',
isResizableContext ? 'mx-2' : 'mx-1'
)}
className={cn('relative mb-2 mt-1 mx-1')}
data-ignore-outside-clicks
>
<IconSearch className="absolute size-4 top-1/2 left-2 -translate-y-1/2 text-left-panel-fg/50" />
@ -294,7 +343,151 @@ const LeftPanel = () => {
)}
</div>
)}
<div className="flex flex-col w-full overflow-y-auto overflow-x-hidden">
{mainMenus.map((menu) => {
if (!menu.isEnabled) {
return null
}
// Handle authentication menu specially
if (menu.title === 'common:authentication') {
return (
<div key={menu.title}>
<div className="mx-1 my-2 border-t border-left-panel-fg/5" />
{isAuthenticated ? (
<UserProfileMenu />
) : (
<AuthLoginButton />
)}
</div>
)
}
// Regular menu items must have route and icon
if (!menu.route || !menu.icon) return null
const isActive = (() => {
// Settings routes
if (menu.route.includes(route.settings.index)) {
return currentPath.includes(route.settings.index)
}
// Default exact match for other routes
return currentPath === menu.route
})()
return (
<Link
key={menu.title}
to={menu.route}
onClick={() => isSmallScreen && setLeftPanel(false)}
data-test-id={`menu-${menu.title}`}
activeOptions={{ exact: true }}
className={cn(
'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded',
isActive && 'bg-left-panel-fg/10'
)}
>
<menu.icon size={18} className="text-left-panel-fg/70" />
<span className="font-medium text-left-panel-fg/90">
{t(menu.title)}
</span>
</Link>
)
})}
</div>
{filteredProjects.length > 0 && (
<div className="space-y-1 py-1">
<div className="flex items-center justify-between mb-2">
<span className="block text-xs text-left-panel-fg/50 px-1 font-semibold">
Projects
</span>
</div>
<div className="flex flex-col">
{filteredProjects
.slice()
.sort((a, b) => b.updated_at - a.updated_at)
.map((folder) => {
const ProjectItem = () => {
const [openDropdown, setOpenDropdown] = useState(false)
const isProjectActive =
currentPath === `/project/${folder.id}`
return (
<div key={folder.id} className="mb-1">
<div
className={cn(
'rounded hover:bg-left-panel-fg/10 flex items-center justify-between gap-2 px-1.5 group/project-list transition-all cursor-pointer',
isProjectActive && 'bg-left-panel-fg/10'
)}
>
<Link
to="/project/$projectId"
params={{ projectId: folder.id }}
onClick={() =>
isSmallScreen && setLeftPanel(false)
}
className="py-1 pr-2 truncate flex items-center gap-2 flex-1"
>
<IconFolder
size={16}
className="text-left-panel-fg/70"
/>
<span className="text-sm text-left-panel-fg/90">
{folder.name}
</span>
</Link>
<div className="flex items-center">
<DropdownMenu
open={openDropdown}
onOpenChange={(open) => setOpenDropdown(open)}
>
<DropdownMenuTrigger asChild>
<IconDots
size={14}
className="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/project-list:data-[state=closed]:size-5 size-5 data-[state=closed]:size-0"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
setEditingProjectKey(folder.id)
setProjectDialogOpen(true)
}}
>
<IconPencil size={16} />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
handleProjectDelete(folder.id)
}}
>
<IconTrash size={16} />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)
}
return <ProjectItem key={folder.id} />
})}
</div>
</div>
)}
<div className="flex flex-col h-full overflow-y-scroll w-[calc(100%+6px)]">
<div className="flex flex-col w-full h-full overflow-y-auto overflow-x-hidden">
<div className="h-full w-full overflow-y-auto">
{favoritedThreads.length > 0 && (
<>
@ -397,7 +590,7 @@ const LeftPanel = () => {
<>
<div className="px-1 mt-2">
<div className="flex items-center gap-1 text-left-panel-fg/80">
<IconMessageFilled size={18} />
<IconMessage size={18} />
<h6 className="font-medium text-base">
{t('common:noThreadsYet')}
</h6>
@ -414,59 +607,36 @@ const LeftPanel = () => {
</div>
</div>
</div>
{PlatformFeatures[PlatformFeature.AUTHENTICATION] && (
<div className="space-y-1 shrink-0 py-1">
<div>
<div className="mx-1 my-2 border-t border-left-panel-fg/5" />
{isAuthenticated ? <UserProfileMenu /> : <AuthLoginButton />}
</div>
</div>
)}
<DownloadManagement />
</div>
<div className="space-y-1 shrink-0 py-1 mt-2">
{mainMenus.map((menu) => {
if (!menu.isEnabled) {
return null
}
// Handle authentication menu specially
if (menu.title === 'common:authentication') {
return (
<div key={menu.title}>
<div className="mx-1 my-2 border-t border-left-panel-fg/5" />
{isAuthenticated ? (
<UserProfileMenu />
) : (
<AuthLoginButton />
)}
</div>
)
}
// Regular menu items must have route and icon
if (!menu.route || !menu.icon) return null
const isActive =
currentPath.includes(route.settings.index) &&
menu.route.includes(route.settings.index)
return (
<Link
key={menu.title}
to={menu.route}
onClick={() => isSmallScreen && setLeftPanel(false)}
data-test-id={`menu-${menu.title}`}
className={cn(
'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded',
isActive
? 'bg-left-panel-fg/10'
: '[&.active]:bg-left-panel-fg/10'
)}
>
<menu.icon size={18} className="text-left-panel-fg/70" />
<span className="font-medium text-left-panel-fg/90">
{t(menu.title)}
</span>
</Link>
)
})}
</div>
<DownloadManagement />
</div>
</aside>
{/* Project Dialogs */}
<AddProjectDialog
open={projectDialogOpen}
onOpenChange={setProjectDialogOpen}
editingKey={editingProjectKey}
initialData={
editingProjectKey ? getFolderById(editingProjectKey) : undefined
}
onSave={handleProjectSave}
/>
<DeleteProjectDialog
open={deleteProjectConfirmOpen}
onOpenChange={setDeleteProjectConfirmOpen}
onConfirm={confirmProjectDelete}
projectName={deletingProjectId ? getFolderById(deletingProjectId)?.name : undefined}
/>
</>
)
}

View File

@ -16,9 +16,13 @@ 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 } from '@/lib/utils'
import { useSmallScreen } from '@/hooks/useMediaQuery'
@ -28,147 +32,268 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { memo, 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 }: { thread: Thread }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: thread.id, disabled: true })
const SortableItem = memo(
({
thread,
variant,
}: {
thread: Thread
variant?: 'default' | 'project'
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: thread.id, disabled: true })
const isSmallScreen = useSmallScreen()
const setLeftPanel = useLeftPanel(state => state.setLeftPanel)
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 { 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 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 = () => {
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 handleClick = () => {
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 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 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,
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={handleClick}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
setOpenDropdown(true)
}}
className={cn(
'mb-1 rounded hover:bg-left-panel-fg/10 flex items-center justify-between gap-2 px-1.5 group/thread-list transition-all',
isDragging ? 'cursor-move' : 'cursor-pointer',
isActive && 'bg-left-panel-fg/10'
)}
>
<div className="py-1 pr-2 truncate">
<span>{thread.title || t('common:newThread')}</span>
</div>
<div className="flex items-center">
<DropdownMenu
open={openDropdown}
onOpenChange={(open) => setOpenDropdown(open)}
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}
onClick={handleClick}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
setOpenDropdown(true)
}}
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'
)}
>
<div
className={cn(
'pr-2 truncate',
variant === 'project' ? 'py-2' : 'py-1'
)}
>
<DropdownMenuTrigger asChild>
<IconDots
size={14}
className="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"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
{thread.isFavorite ? (
<DropdownMenuItem
<span>{thread.title || t('common:newThread')}</span>
{variant === 'project' && (
<>
{variant === 'project' && getLastMessageInfo?.content && (
<div className="text-sm text-main-view-fg/60 mt-0.5 line-clamp-2">
{getLastMessageInfo.content}
</div>
)}
</>
)}
</div>
<div className="flex items-center">
<DropdownMenu
open={openDropdown}
onOpenChange={(open) => setOpenDropdown(open)}
>
<DropdownMenuTrigger asChild>
<IconDots
size={14}
className="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"
onClick={(e) => {
e.preventDefault()
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)}
/>
/>
</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)}
/>
<DropdownMenuSeparator />
<DeleteThreadDialog
thread={thread}
onDelete={deleteThread}
onDropdownClose={() => setOpenDropdown(false)}
/>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-2">
<IconFolder size={16} />
<span>Add to project</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{folders.length === 0 ? (
<DropdownMenuItem disabled>
<span className="text-left-panel-fg/50">
No projects available
</span>
</DropdownMenuItem>
) : (
folders
.sort((a, b) => b.updated_at - a.updated_at)
.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)}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)
})
)
}
)
type ThreadListProps = {
threads: Thread[]
isFavoriteSection?: boolean
variant?: 'default' | 'project'
showDate?: boolean
}
function ThreadList({ threads }: ThreadListProps) {
function ThreadList({ threads, variant = 'default' }: ThreadListProps) {
const sortedThreads = useMemo(() => {
return threads.sort((a, b) => {
return (b.updated || 0) - (a.updated || 0)
@ -192,7 +317,7 @@ function ThreadList({ threads }: ThreadListProps) {
strategy={verticalListSortingStrategy}
>
{sortedThreads.map((thread, index) => (
<SortableItem key={index} thread={thread} />
<SortableItem key={index} thread={thread} variant={variant} />
))}
</SortableContext>
</DndContext>

View File

@ -0,0 +1,125 @@
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useThreadManagement } from '@/hooks/useThreadManagement'
import { toast } from 'sonner'
import { useTranslation } from '@/i18n/react-i18next-compat'
interface AddProjectDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
editingKey: string | null
initialData?: {
id: string
name: string
updated_at: number
}
onSave: (name: string) => void
}
export default function AddProjectDialog({
open,
onOpenChange,
editingKey,
initialData,
onSave,
}: AddProjectDialogProps) {
const { t } = useTranslation()
const [name, setName] = useState(initialData?.name || '')
const { folders } = useThreadManagement()
useEffect(() => {
if (open) {
setName(initialData?.name || '')
}
}, [open, initialData])
const handleSave = () => {
if (!name.trim()) return
const trimmedName = name.trim()
// Check for duplicate names (excluding current project when editing)
const isDuplicate = folders.some(
(folder) =>
folder.name.toLowerCase() === trimmedName.toLowerCase() &&
folder.id !== editingKey
)
if (isDuplicate) {
toast.warning(t('projects.addProjectDialog.alreadyExists', { projectName: trimmedName }))
return
}
onSave(trimmedName)
// Show detailed success message
if (editingKey && initialData) {
toast.success(
t('projects.addProjectDialog.renameSuccess', {
oldName: initialData.name,
newName: trimmedName
})
)
} else {
toast.success(t('projects.addProjectDialog.createSuccess', { projectName: trimmedName }))
}
setName('')
}
const handleCancel = () => {
onOpenChange(false)
setName('')
}
// Check if the button should be disabled
const isButtonDisabled =
!name.trim() || (editingKey && name.trim() === initialData?.name)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{editingKey ? t('projects.addProjectDialog.editTitle') : t('projects.addProjectDialog.createTitle')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-main-view-fg/80">
{t('projects.addProjectDialog.nameLabel')}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('projects.addProjectDialog.namePlaceholder')}
className="mt-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !isButtonDisabled) {
handleSave()
}
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="link" onClick={handleCancel}>
{t('cancel')}
</Button>
<Button onClick={handleSave} disabled={Boolean(isButtonDisabled)}>
{editingKey ? t('projects.addProjectDialog.updateButton') : t('projects.addProjectDialog.createButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,85 @@
import { useRef } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
import { useTranslation } from '@/i18n/react-i18next-compat'
interface DeleteProjectDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
projectName?: string
}
export function DeleteProjectDialog({
open,
onOpenChange,
onConfirm,
projectName,
}: DeleteProjectDialogProps) {
const { t } = useTranslation()
const deleteButtonRef = useRef<HTMLButtonElement>(null)
const handleConfirm = () => {
try {
onConfirm()
toast.success(
projectName
? t('projects.deleteProjectDialog.successWithName', { projectName })
: t('projects.deleteProjectDialog.successWithoutName')
)
onOpenChange(false)
} catch (error) {
toast.error(t('projects.deleteProjectDialog.error'))
console.error('Delete project error:', error)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleConfirm()
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-md"
onOpenAutoFocus={(e) => {
e.preventDefault()
deleteButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle>{t('projects.deleteProjectDialog.title')}</DialogTitle>
<DialogDescription>
{t('projects.deleteProjectDialog.description')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="link" onClick={() => onOpenChange(false)}>
{t('cancel')}
</Button>
<Button
ref={deleteButtonRef}
variant="destructive"
onClick={handleConfirm}
onKeyDown={handleKeyDown}
aria-label={t('projects.deleteProjectDialog.ariaLabel', {
projectName: projectName || t('projects.title').toLowerCase(),
})}
>
{t('projects.deleteProjectDialog.deleteButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -6,4 +6,5 @@ export { MessageMetadataDialog } from './MessageMetadataDialog'
export { DeleteMessageDialog } from './DeleteMessageDialog'
export { FactoryResetDialog } from './FactoryResetDialog'
export { DeleteAssistantDialog } from './DeleteAssistantDialog'
export { DeleteProjectDialog } from './DeleteProjectDialog'
export { AddProviderDialog } from './AddProviderDialog'

View File

@ -0,0 +1,82 @@
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { ulid } from 'ulidx'
import { localStorageKey } from '@/constants/localStorage'
import { useThreads } from '@/hooks/useThreads'
type ThreadFolder = {
id: string
name: string
updated_at: number
}
type ThreadManagementState = {
folders: ThreadFolder[]
setFolders: (folders: ThreadFolder[]) => void
addFolder: (name: string) => void
updateFolder: (id: string, name: string) => void
deleteFolder: (id: string) => void
getFolderById: (id: string) => ThreadFolder | undefined
}
export const useThreadManagement = create<ThreadManagementState>()(
persist(
(set, get) => ({
folders: [],
setFolders: (folders) => {
set({ folders })
},
addFolder: (name) => {
const newFolder: ThreadFolder = {
id: ulid(),
name,
updated_at: Date.now(),
}
set((state) => ({
folders: [...state.folders, newFolder],
}))
},
updateFolder: (id, name) => {
set((state) => ({
folders: state.folders.map((folder) =>
folder.id === id
? { ...folder, name, updated_at: Date.now() }
: folder
),
}))
},
deleteFolder: (id) => {
// Remove project metadata from all threads that belong to this project
const threadsState = useThreads.getState()
const threadsToUpdate = Object.values(threadsState.threads).filter(
(thread) => thread.metadata?.project?.id === id
)
threadsToUpdate.forEach((thread) => {
threadsState.updateThread(thread.id, {
metadata: {
...thread.metadata,
project: undefined,
},
})
})
set((state) => ({
folders: state.folders.filter((folder) => folder.id !== id),
}))
},
getFolderById: (id) => {
return get().folders.find((folder) => folder.id === id)
},
}),
{
name: localStorageKey.threadManagement,
storage: createJSONStorage(() => localStorage),
}
)
)

View File

@ -20,12 +20,14 @@ type ThreadState = {
createThread: (
model: ThreadModel,
title?: string,
assistant?: Assistant
assistant?: Assistant,
projectMetadata?: { id: string; name: string; updated_at: number }
) => Promise<Thread>
updateCurrentThreadModel: (model: ThreadModel) => void
getFilteredThreads: (searchTerm: string) => Thread[]
updateCurrentThreadAssistant: (assistant: Assistant) => void
updateThreadTimestamp: (threadId: string) => void
updateThread: (threadId: string, updates: Partial<Thread>) => void
searchIndex: Fzf<Thread[]> | null
}
@ -132,20 +134,28 @@ export const useThreads = create<ThreadState>()((set, get) => ({
deleteAllThreads: () => {
set((state) => {
const allThreadIds = Object.keys(state.threads)
const favoriteThreadIds = allThreadIds.filter(
(threadId) => state.threads[threadId].isFavorite
)
const nonFavoriteThreadIds = allThreadIds.filter(
(threadId) => !state.threads[threadId].isFavorite
// Identify threads to keep (favorites OR have project metadata)
const threadsToKeepIds = allThreadIds.filter(
(threadId) =>
state.threads[threadId].isFavorite ||
state.threads[threadId].metadata?.project
)
// Only delete non-favorite threads
nonFavoriteThreadIds.forEach((threadId) => {
// Identify threads to delete (non-favorites AND no project metadata)
const threadsToDeleteIds = allThreadIds.filter(
(threadId) =>
!state.threads[threadId].isFavorite &&
!state.threads[threadId].metadata?.project
)
// Delete threads that are not favorites and not in projects
threadsToDeleteIds.forEach((threadId) => {
getServiceHub().threads().deleteThread(threadId)
})
// Keep only favorite threads
const remainingThreads = favoriteThreadIds.reduce(
// Keep favorite threads and threads with project metadata
const remainingThreads = threadsToKeepIds.reduce(
(acc, threadId) => {
acc[threadId] = state.threads[threadId]
return acc
@ -208,13 +218,18 @@ export const useThreads = create<ThreadState>()((set, get) => ({
setCurrentThreadId: (threadId) => {
if (threadId !== get().currentThreadId) set({ currentThreadId: threadId })
},
createThread: async (model, title, assistant) => {
createThread: async (model, title, assistant, projectMetadata) => {
const newThread: Thread = {
id: ulid(),
title: title ?? 'New Thread',
model,
updated: Date.now() / 1000,
assistants: assistant ? [assistant] : [],
...(projectMetadata && {
metadata: {
project: projectMetadata,
},
}),
}
return await getServiceHub()
.threads()
@ -328,4 +343,26 @@ export const useThreads = create<ThreadState>()((set, get) => ({
}
})
},
updateThread: (threadId, updates) => {
set((state) => {
const thread = state.threads[threadId]
if (!thread) return state
const updatedThread = {
...thread,
...updates,
updated: Date.now() / 1000,
}
getServiceHub().threads().updateThread(updatedThread)
const newThreads = { ...state.threads, [threadId]: updatedThread }
return {
threads: newThreads,
searchIndex: new Fzf<Thread[]>(Object.values(newThreads), {
selector: (item: Thread) => item.title,
}),
}
})
},
}))

View File

@ -117,6 +117,7 @@
"chatInput": "Frage mich etwas..."
},
"confirm": "Bestätige",
"continue": "Weiter",
"loading": "Lade...",
"error": "Fehler",
"success": "Erfolg",
@ -127,6 +128,7 @@
"createAssistant": "Assistenten anlegen",
"enterApiKey": "API Key eingeben",
"scrollToBottom": "Zum Ende scrollen",
"generateAiResponse": "KI-Antwort generieren",
"addModel": {
"title": "Modell hinzufügen",
"modelId": "Modell ID",
@ -154,12 +156,12 @@
"delete": "Löschen"
},
"editJson": {
"errorParse": "Failed to parse JSON",
"errorPaste": "Failed to paste JSON",
"errorFormat": "Invalid JSON format",
"titleAll": "Edit All Servers Configuration",
"placeholder": "Enter JSON configuration...",
"save": "Save"
"errorParse": "JSON-Parsing fehlgeschlagen",
"errorPaste": "JSON-Einfügen fehlgeschlagen",
"errorFormat": "Ungültiges JSON-Format",
"titleAll": "Alle Serverkonfigurationen bearbeiten",
"placeholder": "JSON-Konfiguration eingeben...",
"save": "Speichern"
},
"editModel": {
"title": "Modell bearbeiten: {{modelId}}",
@ -228,11 +230,85 @@
"title": "Nachricht Metadaten"
}
},
"projects": {
"title": "Projekte",
"addProject": "Projekt hinzufügen",
"addToProject": "Zum Projekt hinzufügen",
"removeFromProject": "Vom Projekt entfernen",
"createNewProject": "Neues Projekt erstellen",
"editProject": "Projekt bearbeiten",
"deleteProject": "Projekt löschen",
"projectName": "Projektname",
"enterProjectName": "Projektname eingeben...",
"noProjectsAvailable": "Keine Projekte verfügbar",
"noProjectsYet": "Noch keine Projekte",
"noProjectsYetDesc": "Starten Sie ein neues Projekt, indem Sie auf die Schaltfläche Projekt hinzufügen klicken.",
"projectNotFound": "Projekt nicht gefunden",
"projectNotFoundDesc": "Das gesuchte Projekt existiert nicht oder wurde gelöscht.",
"deleteProjectDialog": {
"title": "Projekt löschen",
"description": "Sind Sie sicher, dass Sie dieses Projekt löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteButton": "Löschen",
"successWithName": "Projekt \"{{projectName}}\" erfolgreich gelöscht",
"successWithoutName": "Projekt erfolgreich gelöscht",
"error": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"ariaLabel": "{{projectName}} löschen"
},
"addProjectDialog": {
"createTitle": "Neues Projekt erstellen",
"editTitle": "Projekt bearbeiten",
"nameLabel": "Projektname",
"namePlaceholder": "Projektname eingeben...",
"createButton": "Erstellen",
"updateButton": "Aktualisieren",
"alreadyExists": "Projekt \"{{projectName}}\" existiert bereits",
"createSuccess": "Projekt \"{{projectName}}\" erfolgreich erstellt",
"renameSuccess": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt"
},
"noConversationsIn": "Keine Gespräche in {{projectName}}",
"startNewConversation": "Starten Sie ein neues Gespräch mit {{projectName}} unten",
"conversationsIn": "Gespräche in {{projectName}}",
"conversationsDescription": "Klicken Sie auf ein Gespräch, um weiterzuchatten, oder starten Sie unten ein neues.",
"thread": "Thread",
"threads": "Threads",
"updated": "Aktualisiert:",
"collapseThreads": "Threads einklappen",
"expandThreads": "Threads ausklappen",
"update": "Aktualisieren"
},
"toast": {
"allThreadsUnfavorited": {
"title": "Alle Threads De-Favorisieren ",
"description": "Alle deine Threads wurden defavorisiert."
},
"projectCreated": {
"title": "Projekt erstellt",
"description": "Projekt \"{{projectName}}\" erfolgreich erstellt"
},
"projectRenamed": {
"title": "Projekt umbenannt",
"description": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt"
},
"projectDeleted": {
"title": "Projekt gelöscht",
"description": "Projekt \"{{projectName}}\" erfolgreich gelöscht"
},
"projectAlreadyExists": {
"title": "Projekt existiert bereits",
"description": "Projekt \"{{projectName}}\" existiert bereits"
},
"projectDeleteFailed": {
"title": "Löschen fehlgeschlagen",
"description": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut."
},
"threadAssignedToProject": {
"title": "Thread zugewiesen",
"description": "Thread erfolgreich zu \"{{projectName}}\" hinzugefügt"
},
"threadRemovedFromProject": {
"title": "Thread entfernt",
"description": "Thread erfolgreich von \"{{projectName}}\" entfernt"
},
"deleteAllThreads": {
"title": "Alle Threads löschen",
"description": "Alle deine Threads wurden permanent gelöscht."
@ -280,6 +356,80 @@
"downloadAndVerificationComplete": {
"title": "Download abgeschlossen",
"description": "Modell \"{{item}}\" erfolgreich heruntergeladen und verifiziert"
},
"projectCreated": {
"title": "Projekt erstellt",
"description": "Projekt \"{{projectName}}\" erfolgreich erstellt"
},
"projectRenamed": {
"title": "Projekt umbenannt",
"description": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt"
},
"projectDeleted": {
"title": "Projekt gelöscht",
"description": "Projekt \"{{projectName}}\" erfolgreich gelöscht"
},
"projectAlreadyExists": {
"title": "Projekt existiert bereits",
"description": "Projekt \"{{projectName}}\" existiert bereits"
},
"projectDeleteFailed": {
"title": "Löschen fehlgeschlagen",
"description": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut."
},
"threadAssignedToProject": {
"title": "Thread zugewiesen",
"description": "Thread erfolgreich zu \"{{projectName}}\" hinzugefügt"
},
"threadRemovedFromProject": {
"title": "Thread entfernt",
"description": "Thread erfolgreich von \"{{projectName}}\" entfernt"
}
},
"projects": {
"title": "Projekte",
"addProject": "Projekt hinzufügen",
"addToProject": "Zu Projekt hinzufügen",
"removeFromProject": "Von Projekt entfernen",
"createNewProject": "Neues Projekt erstellen",
"editProject": "Projekt bearbeiten",
"deleteProject": "Projekt löschen",
"projectName": "Projektname",
"enterProjectName": "Projektname eingeben...",
"noProjectsAvailable": "Keine Projekte verfügbar",
"noProjectsYet": "Noch keine Projekte",
"noProjectsYetDesc": "Starten Sie ein neues Projekt, indem Sie auf die Schaltfläche Projekt hinzufügen klicken.",
"projectNotFound": "Projekt nicht gefunden",
"projectNotFoundDesc": "Das gesuchte Projekt existiert nicht oder wurde gelöscht.",
"deleteProjectDialog": {
"title": "Projekt löschen",
"description": "Sind Sie sicher, dass Sie dieses Projekt löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteButton": "Löschen",
"successWithName": "Projekt \"{{projectName}}\" erfolgreich gelöscht",
"successWithoutName": "Projekt erfolgreich gelöscht",
"error": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"ariaLabel": "{{projectName}} löschen"
},
"addProjectDialog": {
"createTitle": "Neues Projekt erstellen",
"editTitle": "Projekt bearbeiten",
"nameLabel": "Projektname",
"namePlaceholder": "Projektname eingeben...",
"createButton": "Erstellen",
"updateButton": "Aktualisieren",
"alreadyExists": "Projekt \"{{projectName}}\" existiert bereits",
"createSuccess": "Projekt \"{{projectName}}\" erfolgreich erstellt",
"renameSuccess": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt"
},
"noConversationsIn": "Keine Gespräche in {{projectName}}",
"startNewConversation": "Starten Sie ein neues Gespräch mit {{projectName}} unten",
"conversationsIn": "Gespräche in {{projectName}}",
"conversationsDescription": "Klicken Sie auf ein Gespräch, um weiterzuchatten, oder starten Sie unten ein neues.",
"thread": "Thread",
"threads": "Threads",
"updated": "Aktualisiert:",
"collapseThreads": "Threads einklappen",
"expandThreads": "Threads ausklappen",
"update": "Aktualisieren"
}
}

View File

@ -119,6 +119,7 @@
"chatInput": "Ask me anything..."
},
"confirm": "Confirm",
"continue": "Continue",
"loading": "Loading...",
"error": "Error",
"success": "Success",
@ -231,6 +232,52 @@
"title": "Message Metadata"
}
},
"projects": {
"title": "Projects",
"addProject": "Add Project",
"addToProject": "Add to project",
"removeFromProject": "Remove from project",
"createNewProject": "Create New Project",
"editProject": "Edit Project",
"deleteProject": "Delete Project",
"projectName": "Project Name",
"enterProjectName": "Enter project name...",
"noProjectsAvailable": "No projects available",
"noProjectsYet": "No Projects Yet",
"noProjectsYetDesc": "Start a new project by clicking the Add Project button.",
"projectNotFound": "Project Not Found",
"projectNotFoundDesc": "The project you're looking for doesn't exist or has been deleted.",
"deleteProjectDialog": {
"title": "Delete Project",
"description": "Are you sure you want to delete this project? This action cannot be undone.",
"deleteButton": "Delete",
"successWithName": "Project \"{{projectName}}\" deleted successfully",
"successWithoutName": "Project deleted successfully",
"error": "Failed to delete project. Please try again.",
"ariaLabel": "Delete {{projectName}}"
},
"addProjectDialog": {
"createTitle": "Create New Project",
"editTitle": "Edit Project",
"nameLabel": "Project Name",
"namePlaceholder": "Enter project name...",
"createButton": "Create",
"updateButton": "Update",
"alreadyExists": "Project \"{{projectName}}\" already exists",
"createSuccess": "Project \"{{projectName}}\" created successfully",
"renameSuccess": "Project renamed from \"{{oldName}}\" to \"{{newName}}\""
},
"noConversationsIn": "No Conversations in {{projectName}}",
"startNewConversation": "Start a new conversation with {{projectName}} below",
"conversationsIn": "Conversations in {{projectName}}",
"conversationsDescription": "Click on any conversation to continue chatting, or start a new one below.",
"thread": "thread",
"threads": "threads",
"updated": "Updated:",
"collapseThreads": "Collapse threads",
"expandThreads": "Expand threads",
"update": "Update"
},
"toast": {
"allThreadsUnfavorited": {
"title": "All Threads Unfavorited",
@ -283,6 +330,34 @@
"downloadAndVerificationComplete": {
"title": "Download Complete",
"description": "Model \"{{item}}\" downloaded and verified successfully"
},
"projectCreated": {
"title": "Project Created",
"description": "Project \"{{projectName}}\" created successfully"
},
"projectRenamed": {
"title": "Project Renamed",
"description": "Project renamed from \"{{oldName}}\" to \"{{newName}}\""
},
"projectDeleted": {
"title": "Project Deleted",
"description": "Project \"{{projectName}}\" deleted successfully"
},
"projectAlreadyExists": {
"title": "Project Already Exists",
"description": "Project \"{{projectName}}\" already exists"
},
"projectDeleteFailed": {
"title": "Delete Failed",
"description": "Failed to delete project. Please try again."
},
"threadAssignedToProject": {
"title": "Thread Assigned",
"description": "Thread assigned to \"{{projectName}}\" successfully"
},
"threadRemovedFromProject": {
"title": "Thread Removed",
"description": "Thread removed from \"{{projectName}}\" successfully"
}
}
}

View File

@ -117,6 +117,7 @@
"chatInput": "Tanyakan apa saja padaku..."
},
"confirm": "Konfirmasi",
"continue": "Lanjutkan",
"loading": "Memuat...",
"error": "Kesalahan",
"success": "Sukses",
@ -127,6 +128,7 @@
"createAssistant": "Buat Asisten",
"enterApiKey": "Masukkan Kunci API",
"scrollToBottom": "Gulir ke bawah",
"generateAiResponse": "Hasilkan Respons AI",
"addModel": {
"title": "Tambah Model",
"modelId": "ID Model",
@ -170,6 +172,13 @@
"embeddings": "Embedding",
"notAvailable": "Belum tersedia"
},
"outOfContextError": {
"truncateInput": "Potong Input",
"title": "Kesalahan konteks habis",
"description": "Obrolan ini mencapai batas memori AI, seperti papan tulis yang penuh. Kami dapat memperluas jendela memori (disebut ukuran konteks) agar lebih mengingat, tetapi mungkin akan menggunakan lebih banyak memori komputer Anda. Kami juga dapat memotong input, yang berarti akan melupakan sebagian riwayat obrolan untuk memberi ruang bagi pesan baru.",
"increaseContextSizeDescription": "Apakah Anda ingin meningkatkan ukuran konteks?",
"increaseContextSize": "Tingkatkan Ukuran Konteks"
},
"toolApproval": {
"title": "Permintaan Izin Alat",
"description": "Asisten ingin menggunakan <strong>{{toolName}}</strong>",
@ -273,6 +282,80 @@
"downloadAndVerificationComplete": {
"title": "Unduhan Selesai",
"description": "Model \"{{item}}\" berhasil diunduh dan diverifikasi"
},
"projectCreated": {
"title": "Proyek Dibuat",
"description": "Proyek \"{{projectName}}\" berhasil dibuat"
},
"projectRenamed": {
"title": "Proyek Diganti Nama",
"description": "Proyek diganti nama dari \"{{oldName}}\" ke \"{{newName}}\""
},
"projectDeleted": {
"title": "Proyek Dihapus",
"description": "Proyek \"{{projectName}}\" berhasil dihapus"
},
"projectAlreadyExists": {
"title": "Proyek Sudah Ada",
"description": "Proyek \"{{projectName}}\" sudah ada"
},
"projectDeleteFailed": {
"title": "Penghapusan Gagal",
"description": "Gagal menghapus proyek. Silakan coba lagi."
},
"threadAssignedToProject": {
"title": "Thread Ditugaskan",
"description": "Thread berhasil ditugaskan ke \"{{projectName}}\""
},
"threadRemovedFromProject": {
"title": "Thread Dihapus",
"description": "Thread berhasil dihapus dari \"{{projectName}}\""
}
},
"projects": {
"title": "Proyek",
"addProject": "Tambah Proyek",
"addToProject": "Tambahkan ke proyek",
"removeFromProject": "Hapus dari proyek",
"createNewProject": "Buat Proyek Baru",
"editProject": "Edit Proyek",
"deleteProject": "Hapus Proyek",
"projectName": "Nama Proyek",
"enterProjectName": "Masukkan nama proyek...",
"noProjectsAvailable": "Tidak ada proyek tersedia",
"noProjectsYet": "Belum Ada Proyek",
"noProjectsYetDesc": "Mulai proyek baru dengan mengklik tombol Tambah Proyek.",
"projectNotFound": "Proyek Tidak Ditemukan",
"projectNotFoundDesc": "Proyek yang Anda cari tidak ada atau telah dihapus.",
"deleteProjectDialog": {
"title": "Hapus Proyek",
"description": "Apakah Anda yakin ingin menghapus proyek ini? Tindakan ini tidak dapat dibatalkan.",
"deleteButton": "Hapus",
"successWithName": "Proyek \"{{projectName}}\" berhasil dihapus",
"successWithoutName": "Proyek berhasil dihapus",
"error": "Gagal menghapus proyek. Silakan coba lagi.",
"ariaLabel": "Hapus {{projectName}}"
},
"addProjectDialog": {
"createTitle": "Buat Proyek Baru",
"editTitle": "Edit Proyek",
"nameLabel": "Nama Proyek",
"namePlaceholder": "Masukkan nama proyek...",
"createButton": "Buat",
"updateButton": "Perbarui",
"alreadyExists": "Proyek \"{{projectName}}\" sudah ada",
"createSuccess": "Proyek \"{{projectName}}\" berhasil dibuat",
"renameSuccess": "Proyek diubah dari \"{{oldName}}\" menjadi \"{{newName}}\""
},
"noConversationsIn": "Tidak Ada Percakapan di {{projectName}}",
"startNewConversation": "Mulai percakapan baru dengan {{projectName}} di bawah",
"conversationsIn": "Percakapan di {{projectName}}",
"conversationsDescription": "Klik percakapan mana pun untuk melanjutkan chatting, atau mulai yang baru di bawah.",
"thread": "utas",
"threads": "utas",
"updated": "Diperbarui:",
"collapseThreads": "Tutup utas",
"expandThreads": "Buka utas",
"update": "Perbarui"
}
}

View File

@ -117,6 +117,7 @@
"chatInput": "Zapytaj mnie o cokolwiek…"
},
"confirm": "Potwierdź",
"continue": "Kontynuuj",
"loading": "Wczytywanie…",
"error": "Błąd",
"success": "Sukces",
@ -229,6 +230,52 @@
"title": "Metadane Wiadomości"
}
},
"projects": {
"title": "Projekty",
"addProject": "Dodaj Projekt",
"addToProject": "Dodaj do projektu",
"removeFromProject": "Usuń z projektu",
"createNewProject": "Utwórz Nowy Projekt",
"editProject": "Edytuj Projekt",
"deleteProject": "Usuń Projekt",
"projectName": "Nazwa Projektu",
"enterProjectName": "Wprowadź nazwę projektu...",
"noProjectsAvailable": "Brak dostępnych projektów",
"noProjectsYet": "Brak Projektów",
"noProjectsYetDesc": "Rozpocznij nowy projekt klikając przycisk Dodaj Projekt.",
"projectNotFound": "Projekt Nie Znaleziony",
"projectNotFoundDesc": "Projekt, którego szukasz nie istnieje lub został usunięty.",
"deleteProjectDialog": {
"title": "Usuń Projekt",
"description": "Na pewno chcesz usunąć ten projekt? Tej operacji nie można cofnąć.",
"deleteButton": "Usuń",
"successWithName": "Projekt \"{{projectName}}\" został pomyślnie usunięty",
"successWithoutName": "Projekt został pomyślnie usunięty",
"error": "Nie udało się usunąć projektu. Spróbuj ponownie.",
"ariaLabel": "Usuń {{projectName}}"
},
"addProjectDialog": {
"createTitle": "Utwórz Nowy Projekt",
"editTitle": "Edytuj Projekt",
"nameLabel": "Nazwa Projektu",
"namePlaceholder": "Wprowadź nazwę projektu...",
"createButton": "Utwórz",
"updateButton": "Aktualizuj",
"alreadyExists": "Projekt \"{{projectName}}\" już istnieje",
"createSuccess": "Projekt \"{{projectName}}\" został pomyślnie utworzony",
"renameSuccess": "Projekt zmieniono z \"{{oldName}}\" na \"{{newName}}\""
},
"noConversationsIn": "Brak Rozmów w {{projectName}}",
"startNewConversation": "Rozpocznij nową rozmowę z {{projectName}} poniżej",
"conversationsIn": "Rozmowy w {{projectName}}",
"conversationsDescription": "Kliknij na dowolną rozmowę aby kontynuować czat, lub rozpocznij nową poniżej.",
"thread": "wątek",
"threads": "wątki",
"updated": "Zaktualizowano:",
"collapseThreads": "Zwiń wątki",
"expandThreads": "Rozwiń wątki",
"update": "Aktualizuj"
},
"toast": {
"allThreadsUnfavorited": {
"title": "Wszystkie Wątki Usunięte z Ulubionych",

View File

@ -117,6 +117,7 @@
"chatInput": "Hỏi tôi bất cứ điều gì..."
},
"confirm": "Xác nhận",
"continue": "Tiếp tục",
"loading": "Đang tải...",
"error": "Lỗi",
"success": "Thành công",

View File

@ -117,6 +117,7 @@
"chatInput": "随便问我什么..."
},
"confirm": "确认",
"continue": "继续",
"loading": "加载中...",
"error": "错误",
"success": "成功",

View File

@ -117,6 +117,7 @@
"chatInput": "問我任何事..."
},
"confirm": "確認",
"continue": "繼續",
"loading": "載入中...",
"error": "錯誤",
"success": "成功",

View File

@ -15,6 +15,7 @@ import { Route as SystemMonitorImport } from './routes/system-monitor'
import { Route as LogsImport } from './routes/logs'
import { Route as AssistantImport } from './routes/assistant'
import { Route as IndexImport } from './routes/index'
import { Route as ProjectIndexImport } from './routes/project/index'
import { Route as HubIndexImport } from './routes/hub/index'
import { Route as ThreadsThreadIdImport } from './routes/threads/$threadId'
import { Route as SettingsShortcutsImport } from './routes/settings/shortcuts'
@ -26,6 +27,7 @@ import { Route as SettingsHardwareImport } from './routes/settings/hardware'
import { Route as SettingsGeneralImport } from './routes/settings/general'
import { Route as SettingsExtensionsImport } from './routes/settings/extensions'
import { Route as SettingsAppearanceImport } from './routes/settings/appearance'
import { Route as ProjectProjectIdImport } from './routes/project/$projectId'
import { Route as LocalApiServerLogsImport } from './routes/local-api-server/logs'
import { Route as HubModelIdImport } from './routes/hub/$modelId'
import { Route as SettingsProvidersIndexImport } from './routes/settings/providers/index'
@ -58,6 +60,12 @@ const IndexRoute = IndexImport.update({
getParentRoute: () => rootRoute,
} as any)
const ProjectIndexRoute = ProjectIndexImport.update({
id: '/project/',
path: '/project/',
getParentRoute: () => rootRoute,
} as any)
const HubIndexRoute = HubIndexImport.update({
id: '/hub/',
path: '/hub/',
@ -124,6 +132,12 @@ const SettingsAppearanceRoute = SettingsAppearanceImport.update({
getParentRoute: () => rootRoute,
} as any)
const ProjectProjectIdRoute = ProjectProjectIdImport.update({
id: '/project/$projectId',
path: '/project/$projectId',
getParentRoute: () => rootRoute,
} as any)
const LocalApiServerLogsRoute = LocalApiServerLogsImport.update({
id: '/local-api-server/logs',
path: '/local-api-server/logs',
@ -201,6 +215,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LocalApiServerLogsImport
parentRoute: typeof rootRoute
}
'/project/$projectId': {
id: '/project/$projectId'
path: '/project/$projectId'
fullPath: '/project/$projectId'
preLoaderRoute: typeof ProjectProjectIdImport
parentRoute: typeof rootRoute
}
'/settings/appearance': {
id: '/settings/appearance'
path: '/settings/appearance'
@ -278,6 +299,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof HubIndexImport
parentRoute: typeof rootRoute
}
'/project/': {
id: '/project/'
path: '/project'
fullPath: '/project'
preLoaderRoute: typeof ProjectIndexImport
parentRoute: typeof rootRoute
}
'/auth/google/callback': {
id: '/auth/google/callback'
path: '/auth/google/callback'
@ -311,6 +339,7 @@ export interface FileRoutesByFullPath {
'/system-monitor': typeof SystemMonitorRoute
'/hub/$modelId': typeof HubModelIdRoute
'/local-api-server/logs': typeof LocalApiServerLogsRoute
'/project/$projectId': typeof ProjectProjectIdRoute
'/settings/appearance': typeof SettingsAppearanceRoute
'/settings/extensions': typeof SettingsExtensionsRoute
'/settings/general': typeof SettingsGeneralRoute
@ -322,6 +351,7 @@ export interface FileRoutesByFullPath {
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/hub': typeof HubIndexRoute
'/project': typeof ProjectIndexRoute
'/auth/google/callback': typeof AuthGoogleCallbackRoute
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
'/settings/providers': typeof SettingsProvidersIndexRoute
@ -334,6 +364,7 @@ export interface FileRoutesByTo {
'/system-monitor': typeof SystemMonitorRoute
'/hub/$modelId': typeof HubModelIdRoute
'/local-api-server/logs': typeof LocalApiServerLogsRoute
'/project/$projectId': typeof ProjectProjectIdRoute
'/settings/appearance': typeof SettingsAppearanceRoute
'/settings/extensions': typeof SettingsExtensionsRoute
'/settings/general': typeof SettingsGeneralRoute
@ -345,19 +376,21 @@ export interface FileRoutesByTo {
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/hub': typeof HubIndexRoute
'/project': typeof ProjectIndexRoute
'/auth/google/callback': typeof AuthGoogleCallbackRoute
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
'/settings/providers': typeof SettingsProvidersIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
'__root__': typeof rootRoute
'/': typeof IndexRoute
'/assistant': typeof AssistantRoute
'/logs': typeof LogsRoute
'/system-monitor': typeof SystemMonitorRoute
'/hub/$modelId': typeof HubModelIdRoute
'/local-api-server/logs': typeof LocalApiServerLogsRoute
'/project/$projectId': typeof ProjectProjectIdRoute
'/settings/appearance': typeof SettingsAppearanceRoute
'/settings/extensions': typeof SettingsExtensionsRoute
'/settings/general': typeof SettingsGeneralRoute
@ -369,6 +402,7 @@ export interface FileRoutesById {
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/hub/': typeof HubIndexRoute
'/project/': typeof ProjectIndexRoute
'/auth/google/callback': typeof AuthGoogleCallbackRoute
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
'/settings/providers/': typeof SettingsProvidersIndexRoute
@ -383,6 +417,7 @@ export interface FileRouteTypes {
| '/system-monitor'
| '/hub/$modelId'
| '/local-api-server/logs'
| '/project/$projectId'
| '/settings/appearance'
| '/settings/extensions'
| '/settings/general'
@ -394,6 +429,7 @@ export interface FileRouteTypes {
| '/settings/shortcuts'
| '/threads/$threadId'
| '/hub'
| '/project'
| '/auth/google/callback'
| '/settings/providers/$providerName'
| '/settings/providers'
@ -405,6 +441,7 @@ export interface FileRouteTypes {
| '/system-monitor'
| '/hub/$modelId'
| '/local-api-server/logs'
| '/project/$projectId'
| '/settings/appearance'
| '/settings/extensions'
| '/settings/general'
@ -416,6 +453,7 @@ export interface FileRouteTypes {
| '/settings/shortcuts'
| '/threads/$threadId'
| '/hub'
| '/project'
| '/auth/google/callback'
| '/settings/providers/$providerName'
| '/settings/providers'
@ -427,6 +465,7 @@ export interface FileRouteTypes {
| '/system-monitor'
| '/hub/$modelId'
| '/local-api-server/logs'
| '/project/$projectId'
| '/settings/appearance'
| '/settings/extensions'
| '/settings/general'
@ -438,6 +477,7 @@ export interface FileRouteTypes {
| '/settings/shortcuts'
| '/threads/$threadId'
| '/hub/'
| '/project/'
| '/auth/google/callback'
| '/settings/providers/$providerName'
| '/settings/providers/'
@ -451,6 +491,7 @@ export interface RootRouteChildren {
SystemMonitorRoute: typeof SystemMonitorRoute
HubModelIdRoute: typeof HubModelIdRoute
LocalApiServerLogsRoute: typeof LocalApiServerLogsRoute
ProjectProjectIdRoute: typeof ProjectProjectIdRoute
SettingsAppearanceRoute: typeof SettingsAppearanceRoute
SettingsExtensionsRoute: typeof SettingsExtensionsRoute
SettingsGeneralRoute: typeof SettingsGeneralRoute
@ -462,6 +503,7 @@ export interface RootRouteChildren {
SettingsShortcutsRoute: typeof SettingsShortcutsRoute
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
HubIndexRoute: typeof HubIndexRoute
ProjectIndexRoute: typeof ProjectIndexRoute
AuthGoogleCallbackRoute: typeof AuthGoogleCallbackRoute
SettingsProvidersProviderNameRoute: typeof SettingsProvidersProviderNameRoute
SettingsProvidersIndexRoute: typeof SettingsProvidersIndexRoute
@ -474,6 +516,7 @@ const rootRouteChildren: RootRouteChildren = {
SystemMonitorRoute: SystemMonitorRoute,
HubModelIdRoute: HubModelIdRoute,
LocalApiServerLogsRoute: LocalApiServerLogsRoute,
ProjectProjectIdRoute: ProjectProjectIdRoute,
SettingsAppearanceRoute: SettingsAppearanceRoute,
SettingsExtensionsRoute: SettingsExtensionsRoute,
SettingsGeneralRoute: SettingsGeneralRoute,
@ -485,6 +528,7 @@ const rootRouteChildren: RootRouteChildren = {
SettingsShortcutsRoute: SettingsShortcutsRoute,
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
HubIndexRoute: HubIndexRoute,
ProjectIndexRoute: ProjectIndexRoute,
AuthGoogleCallbackRoute: AuthGoogleCallbackRoute,
SettingsProvidersProviderNameRoute: SettingsProvidersProviderNameRoute,
SettingsProvidersIndexRoute: SettingsProvidersIndexRoute,
@ -506,6 +550,7 @@ export const routeTree = rootRoute
"/system-monitor",
"/hub/$modelId",
"/local-api-server/logs",
"/project/$projectId",
"/settings/appearance",
"/settings/extensions",
"/settings/general",
@ -517,6 +562,7 @@ export const routeTree = rootRoute
"/settings/shortcuts",
"/threads/$threadId",
"/hub/",
"/project/",
"/auth/google/callback",
"/settings/providers/$providerName",
"/settings/providers/"
@ -540,6 +586,9 @@ export const routeTree = rootRoute
"/local-api-server/logs": {
"filePath": "local-api-server/logs.tsx"
},
"/project/$projectId": {
"filePath": "project/$projectId.tsx"
},
"/settings/appearance": {
"filePath": "settings/appearance.tsx"
},
@ -573,6 +622,9 @@ export const routeTree = rootRoute
"/hub/": {
"filePath": "hub/index.tsx"
},
"/project/": {
"filePath": "project/index.tsx"
},
"/auth/google/callback": {
"filePath": "auth.google.callback.tsx"
},

View File

@ -12,6 +12,7 @@ import { AvatarEmoji } from '@/containers/AvatarEmoji'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { PlatformGuard } from '@/lib/platform/PlatformGuard'
import { PlatformFeature } from '@/lib/platform/types'
import { Button } from '@/components/ui/button'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Route = createFileRoute(route.assistant as any)({
@ -61,72 +62,71 @@ function AssistantContent() {
return (
<div className="flex h-full flex-col flex-justify-center">
<HeaderPage>
<span>{t('assistants:title')}</span>
<div className="flex items-center justify-between w-full mr-2">
<span>{t('assistants:title')}</span>
<Button
onClick={() => {
setEditingKey(null)
setOpen(true)
}}
size="sm"
className="relative z-50"
>
<IconCirclePlus size={16} />
Add Assistant
</Button>
</div>
</HeaderPage>
<div className="h-full p-4 overflow-y-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<div className="h-full p-4 w-3/4 mx-auto overflow-y-auto mt-2">
<div className="space-y-3">
{assistants
.slice()
.sort((a, b) => a.created_at - b.created_at)
.map((assistant) => (
<div
className="bg-main-view-fg/3 p-3 rounded-md"
className="bg-main-view-fg/3 py-2 px-4 rounded-lg flex items-center gap-4"
key={assistant.id}
>
<div className="flex items-center justify-between gap-2">
<h3 className="text-base font-medium text-main-view-fg/80">
<div className="flex items-center gap-1">
{assistant?.avatar && (
<span className="shrink-0 w-4 h-4 relative flex items-center justify-center">
<AvatarEmoji
avatar={assistant?.avatar}
imageClassName="object-cover"
textClassName="text-sm"
/>
</span>
)}
<span className="line-clamp-1">{assistant.name}</span>
</div>
</h3>
<div className="flex items-center gap-0.5">
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
title={t('assistants:editAssistant')}
onClick={() => {
setEditingKey(assistant.id)
setOpen(true)
}}
>
<IconPencil size={18} className="text-main-view-fg/50" />
</div>
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
title={t('assistants:deleteAssistant')}
onClick={() => handleDelete(assistant.id)}
>
<IconTrash size={18} className="text-main-view-fg/50" />
<div className="flex items-start gap-3 flex-1">
{assistant?.avatar && (
<div className="shrink-0 w-8 h-8 relative flex items-center justify-center bg-main-view-fg/4 rounded-md">
<AvatarEmoji
avatar={assistant?.avatar}
imageClassName="w-5 h-5 object-contain"
textClassName="text-lg"
/>
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="text-base font-medium text-main-view-fg/80 line-clamp-1">
{assistant.name}
</h3>
<p className="text-main-view-fg/50 text-sm line-clamp-2 mt-0.5">
{assistant.description}
</p>
</div>
</div>
<p
className="text-main-view-fg/50 mt-1 line-clamp-2"
title={assistant.description}
>
{assistant.description}
</p>
<div className="flex items-center">
<button
className="size-8 cursor-pointer flex items-center justify-center rounded-md hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
title={t('assistants:editAssistant')}
onClick={() => {
setEditingKey(assistant.id)
setOpen(true)
}}
>
<IconPencil size={16} className="text-main-view-fg/50" />
</button>
<button
className="size-8 cursor-pointer flex items-center justify-center rounded-md hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
title={t('assistants:deleteAssistant')}
onClick={() => handleDelete(assistant.id)}
>
<IconTrash size={16} className="text-main-view-fg/50" />
</button>
</div>
</div>
))}
<div
className="bg-main-view p-3 min-h-[88px] rounded-md border border-dashed border-main-view-fg/10 flex items-center justify-center cursor-pointer hover:bg-main-view-fg/1 transition-all duration-200 ease-in-out"
key="new-assistant"
onClick={() => {
setEditingKey(null)
setOpen(true)
}}
>
<IconCirclePlus className="text-main-view-fg/50" />
</div>
</div>
<AddEditAssistant
open={open}

View File

@ -0,0 +1,143 @@
import { createFileRoute, useParams } from '@tanstack/react-router'
import { useMemo } from 'react'
import { useThreadManagement } from '@/hooks/useThreadManagement'
import { useThreads } from '@/hooks/useThreads'
import { useModelProvider } from '@/hooks/useModelProvider'
import { useTranslation } from '@/i18n/react-i18next-compat'
import ChatInput from '@/containers/ChatInput'
import HeaderPage from '@/containers/HeaderPage'
import SetupScreen from '@/containers/SetupScreen'
import ThreadList from '@/containers/ThreadList'
import DropdownAssistant from '@/containers/DropdownAssistant'
import { PlatformFeatures } from '@/lib/platform/const'
import { PlatformFeature } from '@/lib/platform/types'
import { IconMessage } from '@tabler/icons-react'
import { cn } from '@/lib/utils'
import { useAppearance } from '@/hooks/useAppearance'
import { useSmallScreen } from '@/hooks/useMediaQuery'
export const Route = createFileRoute('/project/$projectId')({
component: ProjectPage,
})
function ProjectPage() {
const { t } = useTranslation()
const { projectId } = useParams({ from: '/project/$projectId' })
const { providers } = useModelProvider()
const { getFolderById } = useThreadManagement()
const threads = useThreads((state) => state.threads)
const chatWidth = useAppearance((state) => state.chatWidth)
const isSmallScreen = useSmallScreen()
// Find the project
const project = getFolderById(projectId)
// Get threads for this project
const projectThreads = useMemo(() => {
return Object.values(threads)
.filter((thread) => thread.metadata?.project?.id === projectId)
.sort((a, b) => (b.updated || 0) - (a.updated || 0))
}, [threads, projectId])
// Conditional to check if there are any valid providers
const hasValidProviders = providers.some(
(provider) =>
provider.api_key?.length ||
(provider.provider === 'llamacpp' && provider.models.length) ||
(provider.provider === 'jan' && provider.models.length)
)
if (!hasValidProviders) {
return <SetupScreen />
}
if (!project) {
return (
<div className="flex h-full flex-col items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-semibold text-main-view-fg mb-2">
{t('projects.projectNotFound')}
</h1>
<p className="text-main-view-fg/70">
{t('projects.projectNotFoundDesc')}
</p>
</div>
</div>
)
}
return (
<div className="flex h-full flex-col">
<HeaderPage>
<div className="flex items-center justify-between w-full">
{PlatformFeatures[PlatformFeature.ASSISTANTS] && (
<DropdownAssistant />
)}
</div>
</HeaderPage>
<div className="h-full relative flex flex-col justify-between px-4 md:px-8 py-4 overflow-y-auto">
<div
className={cn(
'mx-auto flex h-full flex-col justify-between',
chatWidth === 'compact' ? 'w-full md:w-4/6' : 'w-full',
isSmallScreen && 'w-full'
)}
>
<div className="flex h-full flex-col">
<div className="mb-6 mt-2">
{projectThreads.length > 0 && (
<>
<h2 className="text-xl font-semibold text-main-view-fg mb-2">
{t('projects.conversationsIn', { projectName: project.name })}
</h2>
<p className="text-main-view-fg/70">
{t('projects.conversationsDescription')}
</p>
</>
)}
</div>
{/* Thread List or Empty State */}
<div className="mb-0">
{projectThreads.length > 0 ? (
<ThreadList threads={projectThreads} variant="project" />
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<IconMessage
size={48}
className="text-main-view-fg/30 mb-4"
/>
<h3 className="text-lg font-medium text-main-view-fg/60 mb-2">
{t('projects.noConversationsIn', { projectName: project.name })}
</h3>
<p className="text-main-view-fg/50 text-sm">
{t('projects.startNewConversation', { projectName: project.name })}
</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* New Chat Input */}
<div
className={cn(
'mx-auto pt-2 pb-3 shrink-0 relative px-2',
chatWidth === 'compact' ? 'w-full md:w-4/6' : 'w-full',
isSmallScreen && 'w-full'
)}
>
<ChatInput
showSpeedToken={false}
initialMessage={true}
projectId={projectId}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,244 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState, useMemo } from 'react'
import { useThreadManagement } from '@/hooks/useThreadManagement'
import { useThreads } from '@/hooks/useThreads'
import { useTranslation } from '@/i18n/react-i18next-compat'
import HeaderPage from '@/containers/HeaderPage'
import ThreadList from '@/containers/ThreadList'
import {
IconCirclePlus,
IconPencil,
IconTrash,
IconFolder,
IconChevronDown,
IconChevronRight,
} from '@tabler/icons-react'
import AddProjectDialog from '@/containers/dialogs/AddProjectDialog'
import { DeleteProjectDialog } from '@/containers/dialogs/DeleteProjectDialog'
import { Button } from '@/components/ui/button'
import { formatDate } from '@/utils/formatDate'
export const Route = createFileRoute('/project/')({
component: Project,
})
function Project() {
return <ProjectContent />
}
function ProjectContent() {
const { t } = useTranslation()
const { folders, addFolder, updateFolder, deleteFolder, getFolderById } =
useThreadManagement()
const threads = useThreads((state) => state.threads)
const [open, setOpen] = useState(false)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
new Set()
)
const handleDelete = (id: string) => {
setDeletingId(id)
setDeleteConfirmOpen(true)
}
const confirmDelete = () => {
if (deletingId) {
deleteFolder(deletingId)
setDeleteConfirmOpen(false)
setDeletingId(null)
}
}
const handleSave = (name: string) => {
if (editingKey) {
updateFolder(editingKey, name)
} else {
addFolder(name)
}
setOpen(false)
setEditingKey(null)
}
const formatProjectDate = (timestamp: number) => {
return formatDate(new Date(timestamp), { includeTime: false })
}
// Get threads for a specific project
const getThreadsForProject = useMemo(() => {
return (projectId: string) => {
return Object.values(threads)
.filter((thread) => thread.metadata?.project?.id === projectId)
.sort((a, b) => (b.updated || 0) - (a.updated || 0))
}
}, [threads])
const toggleProjectExpansion = (projectId: string) => {
setExpandedProjects((prev) => {
const newSet = new Set(prev)
if (newSet.has(projectId)) {
newSet.delete(projectId)
} else {
newSet.add(projectId)
}
return newSet
})
}
return (
<div className="flex h-full flex-col flex-justify-center">
<HeaderPage>
<div className="flex items-center justify-between w-full mr-2">
<span>{t('projects.title')}</span>
<Button
onClick={() => {
setEditingKey(null)
setOpen(true)
}}
size="sm"
className="relative z-50"
>
<IconCirclePlus size={16} />
{t('projects.addProject')}
</Button>
</div>
</HeaderPage>
<div className="h-full overflow-y-auto flex flex-col">
<div className="p-4 w-full md:w-3/4 mx-auto mt-2">
{folders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<IconFolder size={48} className="text-main-view-fg/30 mb-4" />
<h3 className="text-lg font-medium text-main-view-fg/60 mb-2">
{t('projects.noProjectsYet')}
</h3>
<p className="text-main-view-fg/50 text-sm">
{t('projects.noProjectsYetDesc')}
</p>
</div>
) : (
<div className="space-y-3">
{folders
.slice()
.sort((a, b) => a.updated_at - b.updated_at)
.map((folder) => {
const projectThreads = getThreadsForProject(folder.id)
const isExpanded = expandedProjects.has(folder.id)
return (
<div
className="bg-main-view-fg/3 py-2 px-4 rounded-lg"
key={folder.id}
>
<div className="flex items-center gap-4">
<div className="flex items-start gap-3 flex-1">
<div className="shrink-0 w-8 h-8 relative flex items-center justify-center bg-main-view-fg/4 rounded-md">
<IconFolder
size={16}
className="text-main-view-fg/50"
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-base font-medium text-main-view-fg/80 line-clamp-1">
{folder.name}
</h3>
<span className="text-xs bg-main-view-fg/10 text-main-view-fg/60 px-2 py-0.5 rounded-full">
{projectThreads.length}{' '}
{projectThreads.length === 1
? t('projects.thread')
: t('projects.threads')}
</span>
</div>
<p className="text-main-view-fg/50 text-xs line-clamp-2 mt-0.5">
{t('projects.updated')}{' '}
{formatProjectDate(folder.updated_at)}
</p>
</div>
</div>
<div className="flex items-center">
{projectThreads.length > 0 && (
<button
className="size-8 cursor-pointer flex items-center justify-center rounded-md hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out mr-1"
title={
isExpanded
? t('projects.collapseThreads')
: t('projects.expandThreads')
}
onClick={() => toggleProjectExpansion(folder.id)}
>
{isExpanded ? (
<IconChevronDown
size={16}
className="text-main-view-fg/50"
/>
) : (
<IconChevronRight
size={16}
className="text-main-view-fg/50"
/>
)}
</button>
)}
<button
className="size-8 cursor-pointer flex items-center justify-center rounded-md hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
title={t('projects.editProject')}
onClick={() => {
setEditingKey(folder.id)
setOpen(true)
}}
>
<IconPencil
size={16}
className="text-main-view-fg/50"
/>
</button>
<button
className="size-8 cursor-pointer flex items-center justify-center rounded-md hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
title={t('projects.deleteProject')}
onClick={() => handleDelete(folder.id)}
>
<IconTrash
size={16}
className="text-main-view-fg/50"
/>
</button>
</div>
</div>
{/* Thread List */}
{isExpanded && projectThreads.length > 0 && (
<div className="mt-3 pl-2">
<ThreadList
threads={projectThreads}
variant="project"
/>
</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>
<AddProjectDialog
open={open}
onOpenChange={setOpen}
editingKey={editingKey}
initialData={editingKey ? getFolderById(editingKey) : undefined}
onSave={handleSave}
/>
<DeleteProjectDialog
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
onConfirm={confirmDelete}
projectName={deletingId ? getFolderById(deletingId)?.name : undefined}
/>
</div>
)
}

View File

@ -30,6 +30,12 @@ export class DefaultThreadsService implements ThreadsService {
provider: e.assistants?.[0]?.model?.engine,
},
assistants: e.assistants ?? [defaultAssistant],
metadata: {
...e.metadata,
// Override extracted fields to avoid duplication
order: e.metadata?.order,
is_favorite: e.metadata?.is_favorite,
},
} as Thread
})
})
@ -101,6 +107,7 @@ export class DefaultThreadsService implements ThreadsService {
},
],
metadata: {
...thread.metadata,
is_favorite: thread.isFavorite,
order: thread.order,
},

View File

@ -44,6 +44,14 @@ type Thread = {
model?: ThreadModel
updated: number
order?: number
metadata?: {
project?: {
id: string
name: string
updated_at: number
}
[key: string]: unknown
}
}
type Assistant = {