Merge pull request #6701 from menloresearch/cherry-pick/projects

cherry pick : projects + performance enhancement
This commit is contained in:
Nguyen Ngoc Minh 2025-10-01 16:42:20 +00:00 committed by GitHub
commit 8e10f27cc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 381 additions and 169 deletions

View File

@ -4,7 +4,6 @@ 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 {
@ -65,8 +64,6 @@ const ChatInput = ({
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
@ -183,31 +180,10 @@ const ChatInput = ({
sendMessage(
prompt,
true,
uploadedFiles.length > 0 ? uploadedFiles : undefined
uploadedFiles.length > 0 ? uploadedFiles : undefined,
projectId
)
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

@ -163,7 +163,7 @@ const LeftPanel = () => {
const getFilteredThreads = useThreads((state) => state.getFilteredThreads)
const threads = useThreads((state) => state.threads)
const { folders, addFolder, updateFolder, deleteFolder, getFolderById } =
const { folders, addFolder, updateFolder, getFolderById } =
useThreadManagement()
// Project dialog states
@ -204,19 +204,16 @@ const LeftPanel = () => {
setDeleteProjectConfirmOpen(true)
}
const confirmProjectDelete = () => {
if (deletingProjectId) {
deleteFolder(deletingProjectId)
const handleProjectDeleteClose = () => {
setDeleteProjectConfirmOpen(false)
setDeletingProjectId(null)
}
}
const handleProjectSave = (name: string) => {
const handleProjectSave = async (name: string) => {
if (editingProjectKey) {
updateFolder(editingProjectKey, name)
await updateFolder(editingProjectKey, name)
} else {
const newProject = addFolder(name)
const newProject = await addFolder(name)
// Navigate to the newly created project
navigate({
to: '/project/$projectId',
@ -680,8 +677,8 @@ const LeftPanel = () => {
/>
<DeleteProjectDialog
open={deleteProjectConfirmOpen}
onOpenChange={setDeleteProjectConfirmOpen}
onConfirm={confirmProjectDelete}
onOpenChange={handleProjectDeleteClose}
projectId={deletingProjectId ?? undefined}
projectName={
deletingProjectId ? getFolderById(deletingProjectId)?.name : undefined
}

View File

@ -47,9 +47,11 @@ const SortableItem = memo(
({
thread,
variant,
currentProjectId,
}: {
thread: Thread
variant?: 'default' | 'project'
currentProjectId?: string
}) => {
const {
attributes,
@ -108,6 +110,18 @@ const SortableItem = memo(
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) {
@ -226,16 +240,14 @@ const SortableItem = memo(
<span>Add to project</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{folders.length === 0 ? (
{availableProjects.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) => (
availableProjects.map((folder) => (
<DropdownMenuItem
key={folder.id}
onClick={(e) => {
@ -296,9 +308,10 @@ type ThreadListProps = {
isFavoriteSection?: boolean
variant?: 'default' | 'project'
showDate?: boolean
currentProjectId?: string
}
function ThreadList({ threads, variant = 'default' }: ThreadListProps) {
function ThreadList({ threads, variant = 'default', currentProjectId }: ThreadListProps) {
const sortedThreads = useMemo(() => {
return threads.sort((a, b) => {
return (b.updated || 0) - (a.updated || 0)
@ -322,7 +335,7 @@ function ThreadList({ threads, variant = 'default' }: ThreadListProps) {
strategy={verticalListSortingStrategy}
>
{sortedThreads.map((thread, index) => (
<SortableItem key={index} thread={thread} variant={variant} />
<SortableItem key={index} thread={thread} variant={variant} currentProjectId={currentProjectId} />
))}
</SortableContext>
</DndContext>

View File

@ -1,4 +1,4 @@
import { useRef } from 'react'
import { useRef, useMemo } from 'react'
import {
Dialog,
DialogContent,
@ -10,26 +10,49 @@ import {
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { useThreads } from '@/hooks/useThreads'
import { useThreadManagement } from '@/hooks/useThreadManagement'
interface DeleteProjectDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
projectId?: string
projectName?: string
}
export function DeleteProjectDialog({
open,
onOpenChange,
onConfirm,
projectId,
projectName,
}: DeleteProjectDialogProps) {
const { t } = useTranslation()
const deleteButtonRef = useRef<HTMLButtonElement>(null)
const threads = useThreads((state) => state.threads)
const { deleteFolderWithThreads } = useThreadManagement()
// Calculate thread stats for this project
const { threadCount, starredThreadCount } = useMemo(() => {
if (!projectId) return { threadCount: 0, starredThreadCount: 0 }
const projectThreads = Object.values(threads).filter(
(thread) => thread.metadata?.project?.id === projectId
)
const starredCount = projectThreads.filter(
(thread) => thread.isFavorite
).length
return {
threadCount: projectThreads.length,
starredThreadCount: starredCount,
}
}, [projectId, threads])
const handleConfirm = async () => {
if (!projectId) return
const handleConfirm = () => {
try {
onConfirm()
await deleteFolderWithThreads(projectId)
toast.success(
projectName
? t('projects.deleteProjectDialog.successWithName', { projectName })
@ -42,12 +65,15 @@ export function DeleteProjectDialog({
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
const handleKeyDown = async (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleConfirm()
await handleConfirm()
}
}
const hasStarredThreads = starredThreadCount > 0
const hasThreads = threadCount > 0
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
@ -59,8 +85,30 @@ export function DeleteProjectDialog({
>
<DialogHeader>
<DialogTitle>{t('projects.deleteProjectDialog.title')}</DialogTitle>
<DialogDescription>
{t('projects.deleteProjectDialog.description')}
<DialogDescription className="space-y-2">
{hasStarredThreads ? (
<>
<p className="text-red-600 dark:text-red-400 font-semibold">
{t('projects.deleteProjectDialog.starredWarning')}
</p>
<p className="font-medium">
{t('projects.deleteProjectDialog.permanentDeleteWarning')}
</p>
</>
) : hasThreads ? (
<p>
{t('projects.deleteProjectDialog.permanentDelete')}
</p>
) : (
<p>
{t('projects.deleteProjectDialog.deleteEmptyProject', { projectName })}
</p>
)}
{hasThreads && (
<p className="text-sm text-muted-foreground mt-3">
{t('projects.deleteProjectDialog.saveThreadsAdvice')}
</p>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>

View File

@ -83,7 +83,7 @@ export const useChat = () => {
const setModelLoadError = useModelLoad((state) => state.setModelLoadError)
const router = useRouter()
const getCurrentThread = useCallback(async () => {
const getCurrentThread = useCallback(async (projectId?: string) => {
let currentThread = retrieveThread()
if (!currentThread) {
@ -93,13 +93,28 @@ export const useChat = () => {
const assistants = useAssistant.getState().assistants
const selectedModel = useModelProvider.getState().selectedModel
const selectedProvider = useModelProvider.getState().selectedProvider
// Get project metadata if projectId is provided
let projectMetadata: { id: string; name: string; updated_at: number } | undefined
if (projectId) {
const project = await serviceHub.projects().getProjectById(projectId)
if (project) {
projectMetadata = {
id: project.id,
name: project.name,
updated_at: project.updated_at,
}
}
}
currentThread = await createThread(
{
id: selectedModel?.id ?? defaultModel(selectedProvider),
provider: selectedProvider,
},
currentPrompt,
assistants.find((a) => a.id === currentAssistant?.id) || assistants[0]
assistants.find((a) => a.id === currentAssistant?.id) || assistants[0],
projectMetadata,
)
router.navigate({
to: route.threadsDetail,
@ -221,9 +236,10 @@ export const useChat = () => {
size: number
base64: string
dataUrl: string
}>
}>,
projectId?: string
) => {
const activeThread = await getCurrentThread()
const activeThread = await getCurrentThread(projectId)
const selectedProvider = useModelProvider.getState().selectedProvider
let activeProvider = getProviderByName(selectedProvider)

View File

@ -1,56 +1,43 @@
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { ulid } from 'ulidx'
import { localStorageKey } from '@/constants/localStorage'
import { getServiceHub } from '@/hooks/useServiceHub'
import { useThreads } from '@/hooks/useThreads'
type ThreadFolder = {
id: string
name: string
updated_at: number
}
import type { ThreadFolder } from '@/services/projects/types'
import { useEffect } from 'react'
type ThreadManagementState = {
folders: ThreadFolder[]
setFolders: (folders: ThreadFolder[]) => void
addFolder: (name: string) => ThreadFolder
updateFolder: (id: string, name: string) => void
deleteFolder: (id: string) => void
addFolder: (name: string) => Promise<ThreadFolder>
updateFolder: (id: string, name: string) => Promise<void>
deleteFolder: (id: string) => Promise<void>
deleteFolderWithThreads: (id: string) => Promise<void>
getFolderById: (id: string) => ThreadFolder | undefined
getProjectById: (id: string) => Promise<ThreadFolder | undefined>
}
export const useThreadManagement = create<ThreadManagementState>()(
persist(
(set, get) => ({
const useThreadManagementStore = create<ThreadManagementState>()((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],
}))
addFolder: async (name) => {
const projectsService = getServiceHub().projects()
const newFolder = await projectsService.addProject(name)
const updatedProjects = await projectsService.getProjects()
set({ folders: updatedProjects })
return newFolder
},
updateFolder: (id, name) => {
set((state) => ({
folders: state.folders.map((folder) =>
folder.id === id
? { ...folder, name, updated_at: Date.now() }
: folder
),
}))
updateFolder: async (id, name) => {
const projectsService = getServiceHub().projects()
await projectsService.updateProject(id, name)
const updatedProjects = await projectsService.getProjects()
set({ folders: updatedProjects })
},
deleteFolder: (id) => {
deleteFolder: async (id) => {
// Remove project metadata from all threads that belong to this project
const threadsState = useThreads.getState()
const threadsToUpdate = Object.values(threadsState.threads).filter(
@ -66,18 +53,64 @@ export const useThreadManagement = create<ThreadManagementState>()(
})
})
set((state) => ({
folders: state.folders.filter((folder) => folder.id !== id),
}))
const projectsService = getServiceHub().projects()
await projectsService.deleteProject(id)
const updatedProjects = await projectsService.getProjects()
set({ folders: updatedProjects })
},
deleteFolderWithThreads: async (id) => {
// Get all threads that belong to this project
const threadsState = useThreads.getState()
const projectThreads = Object.values(threadsState.threads).filter(
(thread) => thread.metadata?.project?.id === id
)
// Delete threads from backend first
const serviceHub = getServiceHub()
for (const thread of projectThreads) {
await serviceHub.threads().deleteThread(thread.id)
}
// Delete threads from frontend state
for (const thread of projectThreads) {
threadsState.deleteThread(thread.id)
}
// Delete the project from storage
const projectsService = serviceHub.projects()
await projectsService.deleteProject(id)
const updatedProjects = await projectsService.getProjects()
set({ folders: updatedProjects })
},
getFolderById: (id) => {
return get().folders.find((folder) => folder.id === id)
},
}),
{
name: localStorageKey.threadManagement,
storage: createJSONStorage(() => localStorage),
getProjectById: async (id) => {
const projectsService = getServiceHub().projects()
return await projectsService.getProjectById(id)
},
}))
export const useThreadManagement = () => {
const store = useThreadManagementStore()
// Load projects from service on mount
useEffect(() => {
const syncProjects = async () => {
try {
const projectsService = getServiceHub().projects()
const projects = await projectsService.getProjects()
useThreadManagementStore.setState({ folders: projects })
} catch (error) {
console.error('Error syncing projects:', error)
}
)
)
}
syncProjects()
}, [])
return store
}

View File

@ -249,7 +249,11 @@
"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.",
"permanentDelete": "This will permanently delete all threads.",
"permanentDeleteWarning": "This action will permanently delete ALL threads within the project!",
"deleteEmptyProject": "This action will delete project \"{{projectName}}\".",
"saveThreadsAdvice": "To save threads, move them to your thread list or another project before deleting.",
"starredWarning": "You still have starred threads within the project.",
"deleteButton": "Delete",
"successWithName": "Project \"{{projectName}}\" deleted successfully",
"successWithoutName": "Project deleted successfully",
@ -361,3 +365,4 @@
}
}
}

View File

@ -31,19 +31,10 @@ export function AuthProvider({ children }: AuthProviderProps) {
const fetchUserData = useCallback(async () => {
try {
const { setThreads } = useThreads.getState()
const { setMessages } = useMessages.getState()
// Fetch threads first
const threads = await serviceHub.threads().fetchThreads()
setThreads(threads)
// Fetch messages for each thread
const messagePromises = threads.map(async (thread) => {
const messages = await serviceHub.messages().fetchMessages(thread.id)
setMessages(thread.id, messages)
})
await Promise.all(messagePromises)
} catch (error) {
console.error('Failed to fetch user data:', error)
}

View File

@ -1,4 +1,3 @@
import { useMessages } from '@/hooks/useMessages'
import { useModelProvider } from '@/hooks/useModelProvider'
import { useAppUpdater } from '@/hooks/useAppUpdater'
@ -19,7 +18,6 @@ export function DataProvider() {
const { setProviders, selectedModel, selectedProvider, getProviderByName } =
useModelProvider()
const { setMessages } = useMessages()
const { checkForUpdate } = useAppUpdater()
const { setServers } = useMCPServers()
const { setAssistants, initializeWithLastUsed } = useAssistant()
@ -87,14 +85,8 @@ export function DataProvider() {
.fetchThreads()
.then((threads) => {
setThreads(threads)
threads.forEach((thread) =>
serviceHub
.messages()
.fetchMessages(thread.id)
.then((messages) => setMessages(thread.id, messages))
)
})
}, [serviceHub, setThreads, setMessages])
}, [serviceHub, setThreads])
// Check for app updates
useEffect(() => {

View File

@ -105,7 +105,7 @@ function ProjectPage() {
{/* Thread List or Empty State */}
<div className="mb-0">
{projectThreads.length > 0 ? (
<ThreadList threads={projectThreads} variant="project" />
<ThreadList threads={projectThreads} variant="project" currentProjectId={projectId} />
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<IconMessage

View File

@ -32,7 +32,7 @@ function Project() {
function ProjectContent() {
const { t } = useTranslation()
const navigate = useNavigate()
const { folders, addFolder, updateFolder, deleteFolder, getFolderById } =
const { folders, addFolder, updateFolder, getFolderById } =
useThreadManagement()
const threads = useThreads((state) => state.threads)
const [open, setOpen] = useState(false)
@ -48,19 +48,16 @@ function ProjectContent() {
setDeleteConfirmOpen(true)
}
const confirmDelete = () => {
if (deletingId) {
deleteFolder(deletingId)
const handleDeleteClose = () => {
setDeleteConfirmOpen(false)
setDeletingId(null)
}
}
const handleSave = (name: string) => {
const handleSave = async (name: string) => {
if (editingKey) {
updateFolder(editingKey, name)
await updateFolder(editingKey, name)
} else {
const newProject = addFolder(name)
const newProject = await addFolder(name)
// Navigate to the newly created project
navigate({
to: '/project/$projectId',
@ -244,8 +241,8 @@ function ProjectContent() {
/>
<DeleteProjectDialog
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
onConfirm={confirmDelete}
onOpenChange={handleDeleteClose}
projectId={deletingId ?? undefined}
projectName={deletingId ? getFolderById(deletingId)?.name : undefined}
/>
</div>

View File

@ -26,6 +26,7 @@ import { DefaultUpdaterService } from './updater/default'
import { DefaultPathService } from './path/default'
import { DefaultCoreService } from './core/default'
import { DefaultDeepLinkService } from './deeplink/default'
import { DefaultProjectsService } from './projects/default'
// Import service types
import type { ThemeService } from './theme/types'
@ -46,6 +47,7 @@ import type { UpdaterService } from './updater/types'
import type { PathService } from './path/types'
import type { CoreService } from './core/types'
import type { DeepLinkService } from './deeplink/types'
import type { ProjectsService } from './projects/types'
export interface ServiceHub {
// Service getters - all synchronous after initialization
@ -67,6 +69,7 @@ export interface ServiceHub {
path(): PathService
core(): CoreService
deeplink(): DeepLinkService
projects(): ProjectsService
}
class PlatformServiceHub implements ServiceHub {
@ -88,6 +91,7 @@ class PlatformServiceHub implements ServiceHub {
private pathService: PathService = new DefaultPathService()
private coreService: CoreService = new DefaultCoreService()
private deepLinkService: DeepLinkService = new DefaultDeepLinkService()
private projectsService: ProjectsService = new DefaultProjectsService()
private initialized = false
/**
@ -158,6 +162,7 @@ class PlatformServiceHub implements ServiceHub {
deepLinkModule,
providersModule,
mcpModule,
projectsModule,
] = await Promise.all([
import('./theme/web'),
import('./app/web'),
@ -169,6 +174,7 @@ class PlatformServiceHub implements ServiceHub {
import('./deeplink/web'),
import('./providers/web'),
import('./mcp/web'),
import('./projects/web'),
])
this.themeService = new themeModule.WebThemeService()
@ -181,6 +187,7 @@ class PlatformServiceHub implements ServiceHub {
this.deepLinkService = new deepLinkModule.WebDeepLinkService()
this.providersService = new providersModule.WebProvidersService()
this.mcpService = new mcpModule.WebMCPService()
this.projectsService = new projectsModule.WebProjectsService()
}
this.initialized = true
@ -290,6 +297,11 @@ class PlatformServiceHub implements ServiceHub {
this.ensureInitialized()
return this.deepLinkService
}
projects(): ProjectsService {
this.ensureInitialized()
return this.projectsService
}
}
export async function initializeServiceHub(): Promise<ServiceHub> {

View File

@ -0,0 +1,78 @@
/**
* Default Projects Service - localStorage implementation
*/
import { ulid } from 'ulidx'
import type { ProjectsService, ThreadFolder } from './types'
import { localStorageKey } from '@/constants/localStorage'
export class DefaultProjectsService implements ProjectsService {
private storageKey = localStorageKey.threadManagement
private loadFromStorage(): ThreadFolder[] {
try {
const stored = localStorage.getItem(this.storageKey)
if (!stored) return []
const data = JSON.parse(stored)
return data.state?.folders || []
} catch (error) {
console.error('Error loading projects from localStorage:', error)
return []
}
}
private saveToStorage(projects: ThreadFolder[]): void {
try {
const data = {
state: { folders: projects },
version: 0,
}
localStorage.setItem(this.storageKey, JSON.stringify(data))
} catch (error) {
console.error('Error saving projects to localStorage:', error)
}
}
async getProjects(): Promise<ThreadFolder[]> {
return this.loadFromStorage()
}
async addProject(name: string): Promise<ThreadFolder> {
const newProject: ThreadFolder = {
id: ulid(),
name,
updated_at: Date.now(),
}
const projects = this.loadFromStorage()
const updatedProjects = [...projects, newProject]
this.saveToStorage(updatedProjects)
return newProject
}
async updateProject(id: string, name: string): Promise<void> {
const projects = this.loadFromStorage()
const updatedProjects = projects.map((project) =>
project.id === id
? { ...project, name, updated_at: Date.now() }
: project
)
this.saveToStorage(updatedProjects)
}
async deleteProject(id: string): Promise<void> {
const projects = this.loadFromStorage()
const updatedProjects = projects.filter((project) => project.id !== id)
this.saveToStorage(updatedProjects)
}
async getProjectById(id: string): Promise<ThreadFolder | undefined> {
const projects = this.loadFromStorage()
return projects.find((project) => project.id === id)
}
async setProjects(projects: ThreadFolder[]): Promise<void> {
this.saveToStorage(projects)
}
}

View File

@ -0,0 +1,42 @@
/**
* Projects Service Types
* Types for project/folder management operations
*/
export interface ThreadFolder {
id: string
name: string
updated_at: number
}
export interface ProjectsService {
/**
* Get all projects/folders
*/
getProjects(): Promise<ThreadFolder[]>
/**
* Add a new project/folder
*/
addProject(name: string): Promise<ThreadFolder>
/**
* Update a project/folder name
*/
updateProject(id: string, name: string): Promise<void>
/**
* Delete a project/folder
*/
deleteProject(id: string): Promise<void>
/**
* Get a project/folder by ID
*/
getProjectById(id: string): Promise<ThreadFolder | undefined>
/**
* Set all projects/folders (for bulk updates)
*/
setProjects(projects: ThreadFolder[]): Promise<void>
}

View File

@ -0,0 +1,11 @@
/**
* Web Projects Service - Web implementation
* Currently extends default, will be customized by extension-web team later
*/
import { DefaultProjectsService } from './default'
export class WebProjectsService extends DefaultProjectsService {
// Currently uses the same localStorage implementation as default
// Extension-web team can override methods here later
}

View File

@ -62,6 +62,7 @@ export class DefaultThreadsService implements ThreadsService {
},
],
metadata: {
...thread.metadata,
order: thread.order,
},
})