feat: improve projects (#6698)
* decouple successfully * only show movable projects for project items * handle delete covnersations when projects is removed * fix leftpanel assignemtn * fix lint
This commit is contained in:
parent
870c12af9b
commit
d5110de67b
@ -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 {
|
||||
@ -68,8 +67,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
|
||||
@ -180,7 +177,7 @@ const ChatInput = ({
|
||||
const MCPToolComponent = mcpExtension?.getToolComponent?.()
|
||||
|
||||
|
||||
const handleSendMesage = (prompt: string) => {
|
||||
const handleSendMesage = async (prompt: string) => {
|
||||
if (!selectedModel) {
|
||||
setMessage('Please select a model to start chatting.')
|
||||
return
|
||||
@ -192,31 +189,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(() => {
|
||||
|
||||
@ -164,7 +164,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
|
||||
@ -205,19 +205,16 @@ const LeftPanel = () => {
|
||||
setDeleteProjectConfirmOpen(true)
|
||||
}
|
||||
|
||||
const confirmProjectDelete = () => {
|
||||
if (deletingProjectId) {
|
||||
deleteFolder(deletingProjectId)
|
||||
setDeleteProjectConfirmOpen(false)
|
||||
setDeletingProjectId(null)
|
||||
}
|
||||
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',
|
||||
@ -681,8 +678,8 @@ const LeftPanel = () => {
|
||||
/>
|
||||
<DeleteProjectDialog
|
||||
open={deleteProjectConfirmOpen}
|
||||
onOpenChange={setDeleteProjectConfirmOpen}
|
||||
onConfirm={confirmProjectDelete}
|
||||
onOpenChange={handleProjectDeleteClose}
|
||||
projectId={deletingProjectId ?? undefined}
|
||||
projectName={
|
||||
deletingProjectId ? getFolderById(deletingProjectId)?.name : undefined
|
||||
}
|
||||
|
||||
@ -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,29 +240,27 @@ 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) => (
|
||||
<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>
|
||||
))
|
||||
availableProjects.map((folder) => (
|
||||
<DropdownMenuItem
|
||||
key={folder.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
assignThreadToProject(thread.id, folder.id)
|
||||
}}
|
||||
>
|
||||
<IconFolder size={16} />
|
||||
<span className="truncate max-w-[200px]">
|
||||
{folder.name}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
{thread.metadata?.project && (
|
||||
<>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -90,7 +90,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()
|
||||
|
||||
// Check if we're in temporary chat mode
|
||||
@ -109,6 +109,19 @@ export const useChat = () => {
|
||||
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),
|
||||
@ -116,7 +129,7 @@ export const useChat = () => {
|
||||
},
|
||||
isTemporaryMode ? 'Temporary Chat' : currentPrompt,
|
||||
assistants.find((a) => a.id === currentAssistant?.id) || assistants[0],
|
||||
undefined, // no project metadata
|
||||
projectMetadata,
|
||||
isTemporaryMode // pass temporary flag
|
||||
)
|
||||
|
||||
@ -250,9 +263,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)
|
||||
|
||||
|
||||
@ -1,83 +1,116 @@
|
||||
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) => ({
|
||||
folders: [],
|
||||
const useThreadManagementStore = create<ThreadManagementState>()((set, get) => ({
|
||||
folders: [],
|
||||
|
||||
setFolders: (folders) => {
|
||||
set({ folders })
|
||||
},
|
||||
setFolders: (folders) => {
|
||||
set({ folders })
|
||||
},
|
||||
|
||||
addFolder: (name) => {
|
||||
const newFolder: ThreadFolder = {
|
||||
id: ulid(),
|
||||
name,
|
||||
updated_at: Date.now(),
|
||||
}
|
||||
set((state) => ({
|
||||
folders: [...state.folders, newFolder],
|
||||
}))
|
||||
return 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) => {
|
||||
// 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
|
||||
)
|
||||
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(
|
||||
(thread) => thread.metadata?.project?.id === id
|
||||
)
|
||||
|
||||
threadsToUpdate.forEach((thread) => {
|
||||
threadsState.updateThread(thread.id, {
|
||||
metadata: {
|
||||
...thread.metadata,
|
||||
project: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
threadsToUpdate.forEach((thread) => {
|
||||
threadsState.updateThread(thread.id, {
|
||||
metadata: {
|
||||
...thread.metadata,
|
||||
project: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
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 })
|
||||
},
|
||||
|
||||
getFolderById: (id) => {
|
||||
return get().folders.find((folder) => folder.id === id)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: localStorageKey.threadManagement,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
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)
|
||||
},
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -253,7 +253,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",
|
||||
@ -364,4 +368,5 @@
|
||||
"description": "Thread removed from \"{{projectName}}\" successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
setDeleteConfirmOpen(false)
|
||||
setDeletingId(null)
|
||||
}
|
||||
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>
|
||||
|
||||
@ -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> {
|
||||
|
||||
78
web-app/src/services/projects/default.ts
Normal file
78
web-app/src/services/projects/default.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
42
web-app/src/services/projects/types.ts
Normal file
42
web-app/src/services/projects/types.ts
Normal 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>
|
||||
}
|
||||
11
web-app/src/services/projects/web.ts
Normal file
11
web-app/src/services/projects/web.ts
Normal 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
|
||||
}
|
||||
@ -71,6 +71,7 @@ export class DefaultThreadsService implements ThreadsService {
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
...thread.metadata,
|
||||
order: thread.order,
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user