Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 2 additions & 26 deletions web-app/src/containers/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
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 {
Expand Down Expand Up @@ -65,8 +64,6 @@
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
Expand Down Expand Up @@ -112,9 +109,9 @@
const servers = await serviceHub.mcp().getConnectedServers()
setConnectedServers(servers)
} catch (error) {
console.error('Failed to get connected servers:', error)
setConnectedServers([])
}

Check warning on line 114 in web-app/src/containers/ChatInput.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

112-114 lines are not covered with tests
}

checkConnectedServers()
Expand All @@ -132,7 +129,7 @@
const activeModels = await serviceHub
.models()
.getActiveModels('llamacpp')
setHasActiveModels(activeModels.length > 0)

Check warning on line 132 in web-app/src/containers/ChatInput.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

132 line is not covered with tests
} catch (error) {
console.error('Failed to get active models:', error)
setHasActiveModels(false)
Expand All @@ -156,12 +153,12 @@
if (selectedModel?.capabilities?.includes('vision')) {
setHasMmproj(true)
} else {
setHasMmproj(false)
}

Check warning on line 157 in web-app/src/containers/ChatInput.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

156-157 lines are not covered with tests
} catch (error) {
console.error('Error checking mmproj:', error)
setHasMmproj(false)
}

Check warning on line 161 in web-app/src/containers/ChatInput.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

159-161 lines are not covered with tests
}
}

Expand All @@ -173,48 +170,27 @@

const handleSendMesage = (prompt: string) => {
if (!selectedModel) {
setMessage('Please select a model to start chatting.')
return
}

Check warning on line 175 in web-app/src/containers/ChatInput.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

173-175 lines are not covered with tests
if (!prompt.trim() && uploadedFiles.length === 0) {
return
}

Check warning on line 178 in web-app/src/containers/ChatInput.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

177-178 lines are not covered with tests
setMessage('')
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(() => {
const handleFocusIn = () => {
if (document.activeElement === textareaRef.current) {
setIsFocused(true)
}

Check warning on line 193 in web-app/src/containers/ChatInput.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

192-193 lines are not covered with tests
}

const handleFocusOut = () => {
Expand All @@ -241,8 +217,8 @@

useEffect(() => {
if (tooltipToolsAvailable && dropdownToolsAvailable) {
setTooltipToolsAvailable(false)
}

Check warning on line 221 in web-app/src/containers/ChatInput.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

220-221 lines are not covered with tests
}, [dropdownToolsAvailable, tooltipToolsAvailable])

// Focus when thread changes
Expand All @@ -264,17 +240,17 @@

const stopStreaming = useCallback(
(threadId: string) => {
abortControllers[threadId]?.abort()
cancelToolCall?.()
},

Check warning on line 245 in web-app/src/containers/ChatInput.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

243-245 lines are not covered with tests
[abortControllers, cancelToolCall]
)

const fileInputRef = useRef<HTMLInputElement>(null)

const handleAttachmentClick = () => {
fileInputRef.current?.click()
}

Check warning on line 253 in web-app/src/containers/ChatInput.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

252-253 lines are not covered with tests

const handleRemoveFile = (indexToRemove: number) => {
setUploadedFiles((prev) =>
Expand Down
21 changes: 9 additions & 12 deletions web-app/src/containers/LeftPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -204,19 +204,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',
Expand Down Expand Up @@ -680,8 +677,8 @@ const LeftPanel = () => {
/>
<DeleteProjectDialog
open={deleteProjectConfirmOpen}
onOpenChange={setDeleteProjectConfirmOpen}
onConfirm={confirmProjectDelete}
onOpenChange={handleProjectDeleteClose}
projectId={deletingProjectId ?? undefined}
projectName={
deletingProjectId ? getFolderById(deletingProjectId)?.name : undefined
}
Expand Down
51 changes: 32 additions & 19 deletions web-app/src/containers/ThreadList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,11 @@ const SortableItem = memo(
({
thread,
variant,
currentProjectId,
}: {
thread: Thread
variant?: 'default' | 'project'
currentProjectId?: string
}) => {
const {
attributes,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 && (
<>
Expand Down Expand Up @@ -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)
Expand All @@ -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>
Expand Down
66 changes: 57 additions & 9 deletions web-app/src/containers/dialogs/DeleteProjectDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef } from 'react'
import { useRef, useMemo } from 'react'
import {
Dialog,
DialogContent,
Expand All @@ -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 })
Expand All @@ -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
Expand All @@ -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>
Expand Down
24 changes: 20 additions & 4 deletions web-app/src/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
const setModelLoadError = useModelLoad((state) => state.setModelLoadError)
const router = useRouter()

const getCurrentThread = useCallback(async () => {
const getCurrentThread = useCallback(async (projectId?: string) => {
let currentThread = retrieveThread()

if (!currentThread) {
Expand All @@ -93,13 +93,28 @@
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,
Expand All @@ -107,7 +122,7 @@
})
}
return currentThread
}, [createThread, retrieveThread, router])

Check warning on line 125 in web-app/src/hooks/useChat.ts

View workflow job for this annotation

GitHub Actions / test-on-macos

React Hook useCallback has a missing dependency: 'serviceHub'. Either include it or remove the dependency array

Check warning on line 125 in web-app/src/hooks/useChat.ts

View workflow job for this annotation

GitHub Actions / coverage-check

React Hook useCallback has a missing dependency: 'serviceHub'. Either include it or remove the dependency array

Check warning on line 125 in web-app/src/hooks/useChat.ts

View workflow job for this annotation

GitHub Actions / test-on-ubuntu

React Hook useCallback has a missing dependency: 'serviceHub'. Either include it or remove the dependency array

Check warning on line 125 in web-app/src/hooks/useChat.ts

View workflow job for this annotation

GitHub Actions / test-on-windows-pr

React Hook useCallback has a missing dependency: 'serviceHub'. Either include it or remove the dependency array

const restartModel = useCallback(
async (provider: ProviderObject, modelId: string) => {
Expand Down Expand Up @@ -221,9 +236,10 @@
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)

Expand Down
Loading
Loading