diff --git a/web-app/index.html b/web-app/index.html index f59835ecb..dd2e76ee6 100644 --- a/web-app/index.html +++ b/web-app/index.html @@ -20,6 +20,7 @@ content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover, interactive-widget=resizes-visual" /> Jan +
diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 6abc8362f..a6d0fbff7 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -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 @@ -179,7 +176,7 @@ const ChatInput = ({ const mcpExtension = extensionManager.get(ExtensionTypeEnum.MCP) const MCPToolComponent = mcpExtension?.getToolComponent?.() - const handleSendMesage = (prompt: string) => { + const handleSendMesage = async (prompt: string) => { if (!selectedModel) { setMessage('Please select a model to start chatting.') return @@ -191,31 +188,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(() => { diff --git a/web-app/src/containers/LeftPanel.tsx b/web-app/src/containers/LeftPanel.tsx index 45532d76c..a7be18576 100644 --- a/web-app/src/containers/LeftPanel.tsx +++ b/web-app/src/containers/LeftPanel.tsx @@ -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 = () => { /> { const { attributes, @@ -108,6 +110,18 @@ const SortableItem = memo( return (thread.title || '').replace(/]*>|<\/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( Add to project - {folders.length === 0 ? ( + {availableProjects.length === 0 ? ( No projects available ) : ( - folders - .sort((a, b) => b.updated_at - a.updated_at) - .map((folder) => ( - { - e.stopPropagation() - assignThreadToProject(thread.id, folder.id) - }} - > - - - {folder.name} - - - )) + availableProjects.map((folder) => ( + { + e.stopPropagation() + assignThreadToProject(thread.id, folder.id) + }} + > + + + {folder.name} + + + )) )} {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) => ( - + ))} diff --git a/web-app/src/containers/__tests__/EditModel.test.tsx b/web-app/src/containers/__tests__/EditModel.test.tsx index 9f4eafc84..6c0dfd059 100644 --- a/web-app/src/containers/__tests__/EditModel.test.tsx +++ b/web-app/src/containers/__tests__/EditModel.test.tsx @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' +import { render } from '@testing-library/react' import { DialogEditModel } from '../dialogs/EditModel' import { useModelProvider } from '@/hooks/useModelProvider' import '@testing-library/jest-dom' @@ -38,8 +37,8 @@ vi.mock('sonner', () => ({ vi.mock('@/components/ui/dialog', () => ({ Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) => open ?
{children}
: null, - DialogContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
+ DialogContent: ({ children, onKeyDown }: { children: React.ReactNode; onKeyDown?: (e: React.KeyboardEvent) => void }) => ( +
{children}
), DialogHeader: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -181,4 +180,67 @@ describe('DialogEditModel - Basic Component Tests', () => { expect(mockUpdateProvider).toBeDefined() expect(mockSetProviders).toBeDefined() }) + + it('should consolidate capabilities initialization without duplication', () => { + const providerWithCaps = { + provider: 'llamacpp', + active: true, + models: [ + { + id: 'test-model.gguf', + displayName: 'Test Model', + capabilities: ['vision', 'tools'], + }, + ], + settings: [], + } as any + + const { container } = render( + + ) + + // Should render without issues - capabilities helper function should work + expect(container).toBeInTheDocument() + }) + + it('should handle Enter key press with keyDown handler', () => { + const { container } = render( + + ) + + // Component should render with keyDown handler + expect(container).toBeInTheDocument() + }) + + it('should handle vision and tools capabilities', () => { + const providerWithAllCaps = { + provider: 'llamacpp', + active: true, + models: [ + { + id: 'test-model.gguf', + displayName: 'Test Model', + capabilities: ['vision', 'tools', 'completion', 'embeddings', 'web_search', 'reasoning'], + }, + ], + settings: [], + } as any + + const { container } = render( + + ) + + // Component should render without errors even with extra capabilities + // The capabilities helper should only extract vision and tools + expect(container).toBeInTheDocument() + }) }) diff --git a/web-app/src/containers/dialogs/DeleteProjectDialog.tsx b/web-app/src/containers/dialogs/DeleteProjectDialog.tsx index f8c86a3b4..50379570d 100644 --- a/web-app/src/containers/dialogs/DeleteProjectDialog.tsx +++ b/web-app/src/containers/dialogs/DeleteProjectDialog.tsx @@ -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(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 ( {t('projects.deleteProjectDialog.title')} - - {t('projects.deleteProjectDialog.description')} + + {hasStarredThreads ? ( + <> +

+ {t('projects.deleteProjectDialog.starredWarning')} +

+

+ {t('projects.deleteProjectDialog.permanentDeleteWarning')} +

+ + ) : hasThreads ? ( +

+ {t('projects.deleteProjectDialog.permanentDelete')} +

+ ) : ( +

+ {t('projects.deleteProjectDialog.deleteEmptyProject', { projectName })} +

+ )} + {hasThreads && ( +

+ {t('projects.deleteProjectDialog.saveThreadsAdvice')} +

+ )}
diff --git a/web-app/src/containers/dialogs/EditModel.tsx b/web-app/src/containers/dialogs/EditModel.tsx index 67576fbd6..f7dec06eb 100644 --- a/web-app/src/containers/dialogs/EditModel.tsx +++ b/web-app/src/containers/dialogs/EditModel.tsx @@ -17,9 +17,6 @@ import { IconTool, IconAlertTriangle, IconLoader2, - // IconWorld, - // IconAtom, - // IconCodeCircle2, } from '@tabler/icons-react' import { useState, useEffect } from 'react' import { useTranslation } from '@/i18n/react-i18next-compat' @@ -46,69 +43,45 @@ export const DialogEditModel = ({ const [isOpen, setIsOpen] = useState(false) const [isLoading, setIsLoading] = useState(false) const [capabilities, setCapabilities] = useState>({ - completion: false, vision: false, tools: false, - reasoning: false, - embeddings: false, - web_search: false, }) // Initialize with the provided model ID or the first model if available useEffect(() => { - // Only set the selected model ID if the dialog is not open to prevent switching during downloads - if (!isOpen) { + if (isOpen && !selectedModelId || !isOpen) { if (modelId) { setSelectedModelId(modelId) } else if (provider.models && provider.models.length > 0) { setSelectedModelId(provider.models[0].id) } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [modelId, isOpen]) // Add isOpen dependency to prevent switching when dialog is open - - // Handle dialog opening - set the initial model selection - useEffect(() => { - if (isOpen && !selectedModelId) { - if (modelId) { - setSelectedModelId(modelId) - } else if (provider.models && provider.models.length > 0) { - setSelectedModelId(provider.models[0].id) - } - } - }, [isOpen, selectedModelId, modelId, provider.models]) + }, [modelId, isOpen, selectedModelId, provider.models]) // Get the currently selected model const selectedModel = provider.models.find( (m: Model) => m.id === selectedModelId ) + // Helper function to convert capabilities array to object + const capabilitiesToObject = (capabilitiesList: string[]) => ({ + vision: capabilitiesList.includes('vision'), + tools: capabilitiesList.includes('tools'), + }) + // Initialize capabilities and display name from selected model useEffect(() => { if (selectedModel) { const modelCapabilities = selectedModel.capabilities || [] - setCapabilities({ - completion: modelCapabilities.includes('completion'), - vision: modelCapabilities.includes('vision'), - tools: modelCapabilities.includes('tools'), - embeddings: modelCapabilities.includes('embeddings'), - web_search: modelCapabilities.includes('web_search'), - reasoning: modelCapabilities.includes('reasoning'), - }) + const capsObject = capabilitiesToObject(modelCapabilities) + + setCapabilities(capsObject) + setOriginalCapabilities(capsObject) + // Use existing displayName if available, otherwise fall back to model ID const displayNameValue = (selectedModel as Model & { displayName?: string }).displayName || selectedModel.id setDisplayName(displayNameValue) setOriginalDisplayName(displayNameValue) - - const originalCaps = { - completion: modelCapabilities.includes('completion'), - vision: modelCapabilities.includes('vision'), - tools: modelCapabilities.includes('tools'), - embeddings: modelCapabilities.includes('embeddings'), - web_search: modelCapabilities.includes('web_search'), - reasoning: modelCapabilities.includes('reasoning'), - } - setOriginalCapabilities(originalCaps) } }, [selectedModel]) @@ -139,53 +112,38 @@ export const DialogEditModel = ({ setIsLoading(true) try { - let updatedModels = provider.models + const nameChanged = displayName !== originalDisplayName + const capabilitiesChanged = JSON.stringify(capabilities) !== JSON.stringify(originalCapabilities) - // Update display name if changed - if (displayName !== originalDisplayName) { - // Update the model in the provider models array with displayName - updatedModels = updatedModels.map((m: Model) => { - if (m.id === selectedModelId) { - return { - ...m, - displayName: displayName, - } - } - return m - }) - setOriginalDisplayName(displayName) + // Build the update object for the selected model + const modelUpdate: Partial & { _userConfiguredCapabilities?: boolean } = {} + + if (nameChanged) { + modelUpdate.displayName = displayName } - // Update capabilities if changed - if ( - JSON.stringify(capabilities) !== JSON.stringify(originalCapabilities) - ) { - const updatedCapabilities = Object.entries(capabilities) + if (capabilitiesChanged) { + modelUpdate.capabilities = Object.entries(capabilities) .filter(([, isEnabled]) => isEnabled) .map(([capName]) => capName) - - // Find and update the model in the provider - updatedModels = updatedModels.map((m: Model) => { - if (m.id === selectedModelId) { - return { - ...m, - capabilities: updatedCapabilities, - // Mark that user has manually configured capabilities - _userConfiguredCapabilities: true, - } - } - return m - }) - - setOriginalCapabilities(capabilities) + modelUpdate._userConfiguredCapabilities = true } + // Update the model in the provider models array + const updatedModels = provider.models.map((m: Model) => + m.id === selectedModelId ? { ...m, ...modelUpdate } : m + ) + // Update the provider with the updated models updateProvider(provider.provider, { ...provider, models: updatedModels, }) + // Update original values + if (nameChanged) setOriginalDisplayName(displayName) + if (capabilitiesChanged) setOriginalCapabilities(capabilities) + // Show success toast and close dialog toast.success('Model updated successfully') setIsOpen(false) @@ -201,14 +159,32 @@ export const DialogEditModel = ({ return null } + // Handle dialog close - reset to original values if not saved + const handleDialogChange = (open: boolean) => { + if (!open && hasUnsavedChanges()) { + // Reset to original values when closing without saving + setDisplayName(originalDisplayName) + setCapabilities(originalCapabilities) + } + setIsOpen(open) + } + + // Handle keyboard events for Enter key + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && hasUnsavedChanges() && !isLoading) { + e.preventDefault() + handleSaveChanges() + } + } + return ( - +
- + {t('providers:editModel.title', { modelId: selectedModel.id })} @@ -292,58 +268,6 @@ export const DialogEditModel = ({ disabled={isLoading} /> - - {/*
-
- - - {t('providers:editModel.embeddings')} - -
- - - - handleCapabilityChange('embeddings', checked) - } - /> - - - {t('providers:editModel.notAvailable')} - - -
*/} - - {/*
-
- - Web Search -
- - handleCapabilityChange('web_search', checked) - } - /> -
*/} - - {/*
-
- - {t('reasoning')} -
- - handleCapabilityChange('reasoning', checked) - } - /> -
*/} diff --git a/web-app/src/hooks/useChat.ts b/web-app/src/hooks/useChat.ts index ac73b1eeb..f7209de98 100644 --- a/web-app/src/hooks/useChat.ts +++ b/web-app/src/hooks/useChat.ts @@ -284,7 +284,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 @@ -303,6 +303,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), @@ -310,7 +323,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 ) @@ -330,7 +343,7 @@ export const useChat = () => { }) } return currentThread - }, [createThread, retrieveThread, router, setMessages]) + }, [createThread, retrieveThread, router, setMessages, serviceHub]) const restartModel = useCallback( async (provider: ProviderObject, modelId: string) => { @@ -445,9 +458,10 @@ export const useChat = () => { base64: string dataUrl: string }>, + projectId?: string, continueFromMessageId?: string ) => { - const activeThread = await getCurrentThread() + const activeThread = await getCurrentThread(projectId) const selectedProvider = useModelProvider.getState().selectedProvider let activeProvider = getProviderByName(selectedProvider) diff --git a/web-app/src/hooks/useThreadManagement.ts b/web-app/src/hooks/useThreadManagement.ts index becb41def..86ce03991 100644 --- a/web-app/src/hooks/useThreadManagement.ts +++ b/web-app/src/hooks/useThreadManagement.ts @@ -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 + updateFolder: (id: string, name: string) => Promise + deleteFolder: (id: string) => Promise + deleteFolderWithThreads: (id: string) => Promise getFolderById: (id: string) => ThreadFolder | undefined + getProjectById: (id: string) => Promise } -export const useThreadManagement = create()( - persist( - (set, get) => ({ - folders: [], +const useThreadManagementStore = create()((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 +} diff --git a/web-app/src/lib/analytics.ts b/web-app/src/lib/analytics.ts index a0ff9090f..77afeaa0f 100644 --- a/web-app/src/lib/analytics.ts +++ b/web-app/src/lib/analytics.ts @@ -2,6 +2,18 @@ * Google Analytics utility functions */ +/** + * Track page views for SPA navigation + */ +export const pageview = (path: string) => { + if (!window.gtag) return + + window.gtag('event', 'page_view', { + page_location: window.location.href, + page_path: path, + }) +} + /** * Track custom events with Google Analytics */ @@ -9,9 +21,7 @@ export function trackEvent( eventName: string, parameters?: Record ) { - if (!window.gtag) { - return - } + if (!window.gtag) return window.gtag('event', eventName, parameters) } diff --git a/web-app/src/locales/en/common.json b/web-app/src/locales/en/common.json index d0044dee3..8e2b99380 100644 --- a/web-app/src/locales/en/common.json +++ b/web-app/src/locales/en/common.json @@ -254,7 +254,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", @@ -365,4 +369,5 @@ "description": "Thread removed from \"{{projectName}}\" successfully" } } -} \ No newline at end of file +} + diff --git a/web-app/src/providers/AuthProvider.tsx b/web-app/src/providers/AuthProvider.tsx index a62ea4fdd..733296dd4 100644 --- a/web-app/src/providers/AuthProvider.tsx +++ b/web-app/src/providers/AuthProvider.tsx @@ -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) } diff --git a/web-app/src/providers/DataProvider.tsx b/web-app/src/providers/DataProvider.tsx index f16ba715c..d9d2ed685 100644 --- a/web-app/src/providers/DataProvider.tsx +++ b/web-app/src/providers/DataProvider.tsx @@ -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() @@ -85,14 +83,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(() => { diff --git a/web-app/src/providers/GoogleAnalyticsProvider.tsx b/web-app/src/providers/GoogleAnalyticsProvider.tsx index a459edcb2..b37d7bcb0 100644 --- a/web-app/src/providers/GoogleAnalyticsProvider.tsx +++ b/web-app/src/providers/GoogleAnalyticsProvider.tsx @@ -1,60 +1,21 @@ import { useEffect } from 'react' import { useLocation } from '@tanstack/react-router' +import { pageview } from '@/lib/analytics' export function GoogleAnalyticsProvider() { const location = useLocation() - useEffect(() => { - // Check if GA ID is properly configured - if (!GA_MEASUREMENT_ID || GA_MEASUREMENT_ID === 'G-XXXXXXXXXX') { - console.warn( - 'Google Analytics not initialized: Invalid GA_MEASUREMENT_ID' - ) - return - } - - // Load Google Analytics script - const script = document.createElement('script') - script.async = true - script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}` - - // Handle loading errors - script.onerror = () => { - console.warn('Failed to load Google Analytics script') - } - - document.head.appendChild(script) - - // Initialize gtag - window.dataLayer = window.dataLayer || [] - window.gtag = function (...args: unknown[]) { - window.dataLayer?.push(args) - } - window.gtag('js', new Date()) - window.gtag('config', GA_MEASUREMENT_ID, { - send_page_view: false, // We'll manually track page views - }) - - return () => { - // Cleanup: Remove script on unmount - if (script.parentNode) { - script.parentNode.removeChild(script) - } - } - }, []) - // Track page views on route change useEffect(() => { - if (!window.gtag) { + // Skip if GA is not configured + if (!GA_MEASUREMENT_ID) { return } - window.gtag('event', 'page_view', { - page_path: location.pathname + location.search, - page_location: window.location.href, - page_title: document.title, - }) + // Track page view with current path + const path = location.pathname + (window.location.search || '') + pageview(path) }, [location]) return null diff --git a/web-app/src/routes/index.tsx b/web-app/src/routes/index.tsx index d5477f56d..2b6f89a1e 100644 --- a/web-app/src/routes/index.tsx +++ b/web-app/src/routes/index.tsx @@ -71,7 +71,9 @@ function Index() { return (
- {PlatformFeatures[PlatformFeature.ASSISTANTS] && } +
+ {PlatformFeatures[PlatformFeature.ASSISTANTS] && } +
{projectThreads.length > 0 ? ( - + ) : (
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() { />
diff --git a/web-app/src/services/index.ts b/web-app/src/services/index.ts index 121742177..0bfba90e6 100644 --- a/web-app/src/services/index.ts +++ b/web-app/src/services/index.ts @@ -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 { diff --git a/web-app/src/services/projects/default.ts b/web-app/src/services/projects/default.ts new file mode 100644 index 000000000..e570453c9 --- /dev/null +++ b/web-app/src/services/projects/default.ts @@ -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 { + return this.loadFromStorage() + } + + async addProject(name: string): Promise { + 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 { + 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 { + const projects = this.loadFromStorage() + const updatedProjects = projects.filter((project) => project.id !== id) + this.saveToStorage(updatedProjects) + } + + async getProjectById(id: string): Promise { + const projects = this.loadFromStorage() + return projects.find((project) => project.id === id) + } + + async setProjects(projects: ThreadFolder[]): Promise { + this.saveToStorage(projects) + } +} diff --git a/web-app/src/services/projects/types.ts b/web-app/src/services/projects/types.ts new file mode 100644 index 000000000..d4ddc83bd --- /dev/null +++ b/web-app/src/services/projects/types.ts @@ -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 + + /** + * Add a new project/folder + */ + addProject(name: string): Promise + + /** + * Update a project/folder name + */ + updateProject(id: string, name: string): Promise + + /** + * Delete a project/folder + */ + deleteProject(id: string): Promise + + /** + * Get a project/folder by ID + */ + getProjectById(id: string): Promise + + /** + * Set all projects/folders (for bulk updates) + */ + setProjects(projects: ThreadFolder[]): Promise +} diff --git a/web-app/src/services/projects/web.ts b/web-app/src/services/projects/web.ts new file mode 100644 index 000000000..8dbaecd99 --- /dev/null +++ b/web-app/src/services/projects/web.ts @@ -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 +} diff --git a/web-app/src/services/threads/default.ts b/web-app/src/services/threads/default.ts index 4c10af26f..d59c161df 100644 --- a/web-app/src/services/threads/default.ts +++ b/web-app/src/services/threads/default.ts @@ -71,6 +71,7 @@ export class DefaultThreadsService implements ThreadsService { }, ], metadata: { + ...thread.metadata, order: thread.order, }, }) diff --git a/web-app/vite.config.ts b/web-app/vite.config.ts index 70c75a008..befdaae57 100644 --- a/web-app/vite.config.ts +++ b/web-app/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig, loadEnv } from 'vite' +import { defineConfig, loadEnv, Plugin } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import path from 'path' @@ -7,6 +7,41 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills' import packageJson from './package.json' const host = process.env.TAURI_DEV_HOST +// Plugin to inject GA scripts in HTML +function injectGoogleAnalytics(gaMeasurementId?: string): Plugin { + return { + name: 'inject-google-analytics', + transformIndexHtml(html) { + // Only inject GA scripts if GA_MEASUREMENT_ID is set + if (!gaMeasurementId) { + // Remove placeholder if no GA ID + return html.replace(/\s*\n?/g, '') + } + + const gaScripts = ` + + ` + + return html.replace('', gaScripts) + }, + } +} + // https://vite.dev/config/ export default defineConfig(({ mode }) => { // Load env file based on `mode` in the current working directory. @@ -24,6 +59,7 @@ export default defineConfig(({ mode }) => { nodePolyfills({ include: ['path'], }), + injectGoogleAnalytics(env.GA_MEASUREMENT_ID), ], resolve: { alias: { diff --git a/web-app/vite.config.web.ts b/web-app/vite.config.web.ts index 0f96b2213..4db29761d 100644 --- a/web-app/vite.config.web.ts +++ b/web-app/vite.config.web.ts @@ -1,9 +1,46 @@ -import { defineConfig } from 'vite' +import { defineConfig, Plugin } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import path from 'path' import { TanStackRouterVite } from '@tanstack/router-plugin/vite' +// Plugin to inject GA scripts in HTML +function injectGoogleAnalytics(): Plugin { + return { + name: 'inject-google-analytics', + transformIndexHtml(html) { + const gaMeasurementId = process.env.GA_MEASUREMENT_ID + + // Only inject GA scripts if GA_MEASUREMENT_ID is set + if (!gaMeasurementId) { + // Remove placeholder if no GA ID + return html.replace(/\s*\n?/g, '') + } + + const gaScripts = ` + + ` + + return html.replace('', gaScripts) + }, + } +} + export default defineConfig({ plugins: [ TanStackRouterVite({ @@ -13,6 +50,7 @@ export default defineConfig({ }), react(), tailwindcss(), + injectGoogleAnalytics(), ], build: { outDir: './dist-web',