enhancement: responsive dialog modals

This commit is contained in:
Faisal Amir 2025-09-06 21:48:09 +07:00
parent 88fb1acc0a
commit a49008e02d
21 changed files with 1045 additions and 596 deletions

View File

@ -1,4 +1,4 @@
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import { Link, useRouterState } from '@tanstack/react-router'
import { useLeftPanel } from '@/hooks/useLeftPanel'
import { cn } from '@/lib/utils'
import {
@ -6,7 +6,6 @@ import {
IconDots,
IconCirclePlusFilled,
IconSettingsFilled,
IconTrash,
IconStar,
IconMessageFilled,
IconAppsFilled,
@ -27,17 +26,6 @@ import { useThreads } from '@/hooks/useThreads'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { useMemo, useState, useEffect, useRef } from 'react'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
import { DownloadManagement } from '@/containers/DownloadManegement'
import { useSmallScreen } from '@/hooks/useMediaQuery'
@ -45,6 +33,7 @@ import { useClickOutside } from '@/hooks/useClickOutside'
import { useDownloadStore } from '@/hooks/useDownloadStore'
import { PlatformFeatures } from '@/lib/platform/const'
import { PlatformFeature } from '@/lib/platform/types'
import { DeleteAllThreadsDialog } from '@/containers/dialogs'
const mainMenus = [
{
@ -76,7 +65,6 @@ const mainMenus = [
const LeftPanel = () => {
const { open, setLeftPanel } = useLeftPanel()
const { t } = useTranslation()
const navigate = useNavigate()
const [searchTerm, setSearchTerm] = useState('')
const isSmallScreen = useSmallScreen()
@ -362,7 +350,6 @@ const LeftPanel = () => {
{t('common:recents')}
</span>
<div className="relative">
<Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
@ -379,63 +366,9 @@ const LeftPanel = () => {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DialogTrigger asChild>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
<IconTrash size={16} />
<span>{t('common:deleteAll')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('common:dialogs.deleteAllThreads.title')}
</DialogTitle>
<DialogDescription>
{t(
'common:dialogs.deleteAllThreads.description'
)}
</DialogDescription>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<Button
variant="destructive"
size="sm"
onClick={() => {
deleteAllThreads()
toast.success(
t(
'common:toast.deleteAllThreads.title'
),
{
id: 'delete-all-thread',
description: t(
'common:toast.deleteAllThreads.description'
),
}
)
setTimeout(() => {
navigate({ to: route.home })
}, 0)
}}
>
{t('common:deleteAll')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
<DeleteAllThreadsDialog onDeleteAll={deleteAllThreads} />
</DropdownMenuContent>
</DropdownMenu>
</Dialog>
</div>
</div>
)}

View File

@ -2,14 +2,7 @@
import { ThreadMessage } from '@janhq/core'
import { RenderMarkdown } from './RenderMarkdown'
import React, { Fragment, memo, useCallback, useMemo, useState } from 'react'
import {
IconCopy,
IconCopyCheck,
IconRefresh,
IconTrash,
IconPencil,
IconInfoCircle,
} from '@tabler/icons-react'
import { IconCopy, IconCopyCheck, IconRefresh } from '@tabler/icons-react'
import { useAppState } from '@/hooks/useAppState'
import { cn } from '@/lib/utils'
import { useMessages } from '@/hooks/useMessages'
@ -17,16 +10,10 @@ import ThinkingBlock from '@/containers/ThinkingBlock'
import ToolCallBlock from '@/containers/ToolCallBlock'
import { useChat } from '@/hooks/useChat'
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
EditMessageDialog,
MessageMetadataDialog,
DeleteMessageDialog,
} from '@/containers/dialogs'
import {
Tooltip,
TooltipContent,
@ -37,8 +24,6 @@ import { AvatarEmoji } from '@/containers/AvatarEmoji'
import TokenSpeedIndicator from '@/containers/TokenSpeedIndicator'
import CodeEditor from '@uiw/react-textarea-code-editor'
import '@uiw/react-textarea-code-editor/dist.css'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { useModelProvider } from '@/hooks/useModelProvider'
@ -76,69 +61,6 @@ const CopyButton = ({ text }: { text: string }) => {
)
}
const EditDialog = ({
message,
setMessage,
}: {
message: string
setMessage: (message: string) => void
}) => {
const { t } = useTranslation()
const [draft, setDraft] = useState(message)
const handleSave = () => {
if (draft !== message) {
setMessage(draft)
}
}
return (
<Dialog>
<DialogTrigger>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex outline-0 items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconPencil size={16} />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('edit')}</p>
</TooltipContent>
</Tooltip>
</DialogTrigger>
<DialogContent className="w-3/4">
<DialogHeader>
<DialogTitle>{t('common:dialogs.editMessage.title')}</DialogTitle>
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
className="mt-2 resize-none w-full"
onKeyDown={(e) => {
// Prevent key from being captured by parent components
e.stopPropagation()
}}
/>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button variant="link" size="sm" className="hover:no-underline">
Cancel
</Button>
</DialogClose>
<DialogClose asChild>
<Button
disabled={draft === message || !draft}
onClick={handleSave}
>
Save
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}
// Use memo to prevent unnecessary re-renders, but allow re-renders when props change
export const ThreadContent = memo(
(
@ -349,32 +271,20 @@ export const ThreadContent = memo(
)}
<div className="flex items-center justify-end gap-2 text-main-view-fg/60 text-xs mt-2">
<EditDialog
<EditMessageDialog
message={
item.content?.find((c) => c.type === 'text')?.text?.value ||
''
}
setMessage={(message) => {
onSave={(message) => {
if (item.updateMessage) {
item.updateMessage(item, message)
}
}}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={() => {
deleteMessage(item.thread_id, item.id)
}}
>
<IconTrash size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('delete')}</p>
</TooltipContent>
</Tooltip>
<DeleteMessageDialog
onDelete={() => deleteMessage(item.thread_id, item.id)}
/>
</div>
</div>
)}
@ -456,68 +366,15 @@ export const ThreadContent = memo(
'hidden'
)}
>
<EditDialog
message={item.content?.[0]?.text.value}
setMessage={(message) =>
<EditMessageDialog
message={item.content?.[0]?.text.value || ''}
onSave={(message) =>
item.updateMessage && item.updateMessage(item, message)
}
/>
<CopyButton text={item.content?.[0]?.text.value || ''} />
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={() => {
removeMessage()
}}
>
<IconTrash size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('delete')}</p>
</TooltipContent>
</Tooltip>
<Dialog>
<DialogTrigger>
<Tooltip>
<TooltipTrigger asChild>
<div className="outline-0 focus:outline-0 flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconInfoCircle size={16} />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('metadata')}</p>
</TooltipContent>
</Tooltip>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('common:dialogs.messageMetadata.title')}
</DialogTitle>
<div className="space-y-2">
<div className="border border-main-view-fg/10 rounded-md overflow-hidden">
<CodeEditor
value={JSON.stringify(
item.metadata || {},
null,
2
)}
language="json"
readOnly
style={{
fontFamily: 'ui-monospace',
backgroundColor: 'transparent',
height: '100%',
}}
className="w-full h-full !text-sm"
/>
</div>
</div>
</DialogHeader>
</DialogContent>
</Dialog>
<DeleteMessageDialog onDelete={removeMessage} />
<MessageMetadataDialog metadata={item.metadata} />
{item.isLastMessage && selectedModel && (
<Tooltip>

View File

@ -15,14 +15,11 @@ import { CSS } from '@dnd-kit/utilities'
import {
IconDots,
IconStarFilled,
IconTrash,
IconEdit,
IconStar,
} from '@tabler/icons-react'
import { useThreads } from '@/hooks/useThreads'
import { useLeftPanel } from '@/hooks/useLeftPanel'
import { cn } from '@/lib/utils'
import { route } from '@/constants/routes'
import { useSmallScreen } from '@/hooks/useMediaQuery'
import {
@ -33,19 +30,10 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { DialogClose, DialogFooter, DialogHeader } from '@/components/ui/dialog'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { memo, useMemo, useState } from 'react'
import { useNavigate, useMatches } from '@tanstack/react-router'
import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
import { RenameThreadDialog, DeleteThreadDialog } from '@/containers/dialogs'
import { route } from '@/constants/routes'
const SortableItem = memo(({ thread }: { thread: Thread }) => {
const {
@ -94,9 +82,6 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
return (thread.title || '').replace(/<span[^>]*>|<\/span>/g, '')
}, [thread.title])
const [title, setTitle] = useState(
plainTitleForRename || t('common:newThread')
)
return (
<div
@ -156,116 +141,19 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
<span>{t('common:star')}</span>
</DropdownMenuItem>
)}
<Dialog
onOpenChange={(open) => {
if (open) {
setTitle(plainTitleForRename || t('common:newThread'))
} else {
setOpenDropdown(false)
}
}}
>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<IconEdit />
<span>{t('common:rename')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common:threadTitle')}</DialogTitle>
<Input
value={title}
onChange={(e) => {
setTitle(e.target.value)
}}
className="mt-2"
onKeyDown={(e) => {
// Prevent key from being captured by parent components
e.stopPropagation()
}}
<RenameThreadDialog
thread={thread}
plainTitleForRename={plainTitleForRename}
onRename={renameThread}
onDropdownClose={() => setOpenDropdown(false)}
/>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<Button
disabled={!title}
onClick={() => {
renameThread(thread.id, title)
setOpenDropdown(false)
toast.success(t('common:toast.renameThread.title'), {
id: 'rename-thread',
description: t(
'common:toast.renameThread.description',
{ title }
),
})
}}
>
{t('common:rename')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
<DropdownMenuSeparator />
<Dialog
onOpenChange={(open) => {
if (!open) setOpenDropdown(false)
}}
>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<IconTrash />
<span>{t('common:delete')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common:deleteThread')}</DialogTitle>
<DialogDescription>
{t('common:dialogs.deleteThread.description')}
</DialogDescription>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<Button
variant="destructive"
onClick={() => {
deleteThread(thread.id)
setOpenDropdown(false)
toast.success(t('common:toast.deleteThread.title'), {
id: 'delete-thread',
description: t(
'common:toast.deleteThread.description'
),
})
setTimeout(() => {
navigate({ to: route.home })
}, 0)
}}
>
{t('common:delete')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
<DeleteThreadDialog
thread={thread}
onDelete={deleteThread}
onDropdownClose={() => setOpenDropdown(false)}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@ -1,16 +1,11 @@
import { ChevronDown, ChevronUp, Loader } from 'lucide-react'
import { cn } from '@/lib/utils'
import { create } from 'zustand'
import { RenderMarkdown } from './RenderMarkdown'
import { RenderMarkdown } from '@/containers/RenderMarkdown'
import { useMemo, useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { twMerge } from 'tailwind-merge'
import { useTranslation } from '@/i18n/react-i18next-compat'
import ImageModal from '@/containers/dialogs/ImageModal'
interface Props {
result: string
@ -108,14 +103,6 @@ const ContentItemRenderer = ({
)
}
// if (item.type === 'text' && item.text) {
// return (
// <div key={index} className="mt-3">
// <RenderMarkdown content={item.text} />
// </div>
// )
// }
// For any other types, render as JSON
return (
<div key={index} className="mt-3">
@ -243,29 +230,7 @@ const ToolCallBlock = ({ id, name, result, loading, args }: Props) => {
</div>
</div>
{/* Image Modal */}
<Dialog
open={!!modalImage}
onOpenChange={(open) => !open && closeModal()}
>
<DialogContent className="max-w-4xl max-h-[90vh] p-0">
<DialogHeader className="p-6 pb-2">
<DialogTitle>{modalImage?.alt || t('common:image')}</DialogTitle>
</DialogHeader>
<div className="flex justify-center items-center p-6 pt-2">
{modalImage && (
<img
src={modalImage.url}
alt={modalImage.alt}
className="max-w-full max-h-[70vh] object-contain rounded-md"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
)}
</div>
</DialogContent>
</Dialog>
<ImageModal image={modalImage} onClose={closeModal} />
</div>
)
}

View File

@ -45,7 +45,7 @@ export function TrustedHostsInput({
onBlur={handleBlur}
placeholder={t('common:enterTrustedHosts')}
className={cn(
'w-24 h-8 text-sm',
'h-8 text-sm',
isServerRunning && 'opacity-50 pointer-events-none'
)}
/>

View File

@ -0,0 +1,105 @@
import { useState, useRef } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogClose,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
interface AddProviderDialogProps {
onCreateProvider: (name: string) => void
children: React.ReactNode
}
export function AddProviderDialog({
onCreateProvider,
children,
}: AddProviderDialogProps) {
const { t } = useTranslation()
const [name, setName] = useState('')
const [isOpen, setIsOpen] = useState(false)
const createButtonRef = useRef<HTMLButtonElement>(null)
const handleCreate = () => {
if (name.trim()) {
onCreateProvider(name.trim())
setName('')
setIsOpen(false)
}
}
const handleCancel = () => {
setName('')
setIsOpen(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && name.trim()) {
e.preventDefault()
handleCreate()
}
// Prevent key from being captured by parent components
e.stopPropagation()
}
const handleOpenChange = (open: boolean) => {
setIsOpen(open)
if (!open) {
setName('')
}
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="sm:max-w-[425px] max-w-[90vw]"
onOpenAutoFocus={(e) => {
e.preventDefault()
createButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle>{t('provider:addOpenAIProvider')}</DialogTitle>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-2"
placeholder={t('provider:enterNameForProvider')}
onKeyDown={handleKeyDown}
/>
<DialogFooter className="mt-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline w-full sm:w-auto"
onClick={handleCancel}
>
{t('common:cancel')}
</Button>
</DialogClose>
<DialogClose asChild>
<Button
ref={createButtonRef}
disabled={!name.trim()}
onClick={handleCreate}
className="w-full sm:w-auto"
size="sm"
aria-label={t('common:create')}
>
{t('common:create')}
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -33,7 +33,7 @@ export default function ChangeDataFolderLocation({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconFolder size={20} />

View File

@ -0,0 +1,103 @@
import { useState, useRef } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { IconTrash } from '@tabler/icons-react'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { toast } from 'sonner'
import { route } from '@/constants/routes'
interface DeleteAllThreadsDialogProps {
onDeleteAll: () => void
onDropdownClose?: () => void
}
export function DeleteAllThreadsDialog({
onDeleteAll,
onDropdownClose,
}: DeleteAllThreadsDialogProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const [isOpen, setIsOpen] = useState(false)
const deleteButtonRef = useRef<HTMLButtonElement>(null)
const handleOpenChange = (open: boolean) => {
setIsOpen(open)
if (!open && onDropdownClose) {
onDropdownClose()
}
}
const handleDeleteAll = () => {
onDeleteAll()
setIsOpen(false)
if (onDropdownClose) onDropdownClose()
toast.success(t('common:toast.deleteAllThreads.title'), {
id: 'delete-all-threads',
description: t('common:toast.deleteAllThreads.description'),
})
setTimeout(() => {
navigate({ to: route.home })
}, 0)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleDeleteAll()
}
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<IconTrash size={16} />
<span>{t('common:deleteAll')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault()
deleteButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle>
{t('common:dialogs.deleteAllThreads.title')}
</DialogTitle>
<DialogDescription>
{t('common:dialogs.deleteAllThreads.description')}
</DialogDescription>
<DialogFooter className="mt-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogClose asChild>
<Button variant="link" size="sm" className="w-full sm:w-auto">
{t('common:cancel')}
</Button>
</DialogClose>
<Button
ref={deleteButtonRef}
variant="destructive"
onClick={handleDeleteAll}
onKeyDown={handleKeyDown}
size="sm"
className="w-full sm:w-auto"
aria-label={t('common:deleteAll')}
>
{t('common:deleteAll')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,78 @@
import { useRef } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
interface DeleteAssistantDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
}
export function DeleteAssistantDialog({
open,
onOpenChange,
onConfirm,
}: DeleteAssistantDialogProps) {
const { t } = useTranslation()
const deleteButtonRef = useRef<HTMLButtonElement>(null)
const handleConfirm = () => {
onConfirm()
}
const handleCancel = () => {
onOpenChange(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleConfirm()
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-[425px] max-w-[90vw]"
onOpenAutoFocus={(e) => {
e.preventDefault()
deleteButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle>{t('assistants:deleteConfirmation')}</DialogTitle>
<DialogDescription>
{t('assistants:deleteConfirmationDesc')}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<Button
variant="link"
onClick={handleCancel}
className="w-full sm:w-auto"
>
{t('assistants:cancel')}
</Button>
<Button
ref={deleteButtonRef}
variant="destructive"
onClick={handleConfirm}
onKeyDown={handleKeyDown}
className="w-full sm:w-auto"
aria-label={t('assistants:delete')}
>
{t('assistants:delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,91 @@
import { useState, useRef } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { IconTrash } from '@tabler/icons-react'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
interface DeleteMessageDialogProps {
onDelete: () => void
}
export function DeleteMessageDialog({ onDelete }: DeleteMessageDialogProps) {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const deleteButtonRef = useRef<HTMLButtonElement>(null)
const handleDelete = () => {
onDelete()
setIsOpen(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleDelete()
}
}
const trigger = (
<Tooltip>
<TooltipTrigger asChild>
<button className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconTrash size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('delete')}</p>
</TooltipContent>
</Tooltip>
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger>{trigger}</DialogTrigger>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault()
deleteButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle>{t('common:deleteMessage')}</DialogTitle>
<DialogDescription>
Are you sure you want to delete this message? This action cannot be
undone.
</DialogDescription>
<DialogFooter className="mt-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogClose asChild>
<Button variant="link" size="sm" className="w-full sm:w-auto">
{t('common:cancel')}
</Button>
</DialogClose>
<Button
ref={deleteButtonRef}
variant="destructive"
onClick={handleDelete}
onKeyDown={handleKeyDown}
size="sm"
className="w-full sm:w-auto"
aria-label={t('common:deleteMessage')}
>
{t('common:delete')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -39,8 +39,14 @@ export const DialogDeleteModel = ({
removeFavorite(selectedModelId)
deleteModelCache(selectedModelId)
serviceHub.models().deleteModel(selectedModelId).then(() => {
serviceHub.providers().getProviders().then((providers) => {
serviceHub
.models()
.deleteModel(selectedModelId)
.then(() => {
serviceHub
.providers()
.getProviders()
.then((providers) => {
// Filter out the deleted model from all providers
const filteredProviders = providers.map((provider) => ({
...provider,
@ -105,7 +111,7 @@ export const DialogDeleteModel = ({
</Button>
</DialogClose>
<DialogClose asChild>
<Button variant="destructive" size="sm" onClick={removeModel}>
<Button variant="destructive" size="sm" onClick={removeModel} autoFocus>
{t('providers:deleteModel.delete')}
</Button>
</DialogClose>

View File

@ -0,0 +1,103 @@
import { useState, useRef } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { IconTrash } from '@tabler/icons-react'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { toast } from 'sonner'
import { route } from '@/constants/routes'
interface DeleteThreadDialogProps {
thread: Thread
onDelete: (threadId: string) => void
onDropdownClose: () => void
}
export function DeleteThreadDialog({
thread,
onDelete,
onDropdownClose,
}: DeleteThreadDialogProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const [isOpen, setIsOpen] = useState(false)
const deleteButtonRef = useRef<HTMLButtonElement>(null)
const handleOpenChange = (open: boolean) => {
setIsOpen(open)
if (!open) {
onDropdownClose()
}
}
const handleDelete = () => {
onDelete(thread.id)
setIsOpen(false)
onDropdownClose()
toast.success(t('common:toast.deleteThread.title'), {
id: 'delete-thread',
description: t('common:toast.deleteThread.description'),
})
setTimeout(() => {
navigate({ to: route.home })
}, 0)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleDelete()
}
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<IconTrash />
<span>{t('common:delete')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault()
deleteButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle>{t('common:deleteThread')}</DialogTitle>
<DialogDescription>
{t('common:dialogs.deleteThread.description')}
</DialogDescription>
<DialogFooter className="mt-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogClose asChild>
<Button variant="link" size="sm" className="w-full sm:w-auto">
{t('common:cancel')}
</Button>
</DialogClose>
<Button
ref={deleteButtonRef}
variant="destructive"
onClick={handleDelete}
onKeyDown={handleKeyDown}
size="sm"
className="w-full sm:w-auto"
aria-label={`${t('common:delete')} ${thread.title || t('common:newThread')}`}
>
{t('common:delete')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,111 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogClose,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { IconPencil } from '@tabler/icons-react'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
interface EditMessageDialogProps {
message: string
onSave: (message: string) => void
triggerElement?: React.ReactNode
}
export function EditMessageDialog({
message,
onSave,
triggerElement,
}: EditMessageDialogProps) {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const [draft, setDraft] = useState(message)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
setDraft(message)
}, [message])
useEffect(() => {
if (isOpen && textareaRef.current) {
setTimeout(() => {
textareaRef.current?.focus()
textareaRef.current?.select()
}, 100)
}
}, [isOpen])
const handleSave = () => {
if (draft !== message && draft.trim()) {
onSave(draft)
setIsOpen(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
e.stopPropagation()
if (e.key === 'Enter' && e.ctrlKey) {
handleSave()
}
}
const defaultTrigger = (
<Tooltip>
<TooltipTrigger asChild>
<button className="flex outline-0 items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconPencil size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('edit')}</p>
</TooltipContent>
</Tooltip>
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger>{triggerElement || defaultTrigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common:dialogs.editMessage.title')}</DialogTitle>
<Textarea
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
className="mt-2 resize-none w-full min-h-24"
onKeyDown={handleKeyDown}
placeholder={t('common:dialogs.editMessage.title')}
aria-label={t('common:dialogs.editMessage.title')}
/>
<DialogFooter className="mt-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogClose asChild>
<Button variant="link" size="sm" className="w-full sm:w-auto">
{t('common:cancel')}
</Button>
</DialogClose>
<Button
disabled={draft === message || !draft.trim()}
onClick={handleSave}
size="sm"
className="w-full sm:w-auto"
>
{t('common:save')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,80 @@
import { useRef } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
interface FactoryResetDialogProps {
onReset: () => void
children: React.ReactNode
}
export function FactoryResetDialog({
onReset,
children,
}: FactoryResetDialogProps) {
const { t } = useTranslation()
const resetButtonRef = useRef<HTMLButtonElement>(null)
const handleReset = () => {
onReset()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleReset()
}
}
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="sm:max-w-[425px] max-w-[90vw]"
onOpenAutoFocus={(e) => {
e.preventDefault()
resetButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle>{t('settings:general.factoryResetTitle')}</DialogTitle>
<DialogDescription>
{t('settings:general.factoryResetDesc')}
</DialogDescription>
<DialogFooter className="mt-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline w-full sm:w-auto"
>
{t('settings:general.cancel')}
</Button>
</DialogClose>
<DialogClose asChild>
<Button
ref={resetButtonRef}
variant="destructive"
onClick={handleReset}
onKeyDown={handleKeyDown}
size="sm"
className="w-full sm:w-auto"
aria-label={t('settings:general.reset')}
>
{t('settings:general.reset')}
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,40 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useTranslation } from '@/i18n/react-i18next-compat'
interface ImageModalProps {
image: { url: string; alt: string } | null
onClose: () => void
}
const ImageModal = ({ image, onClose }: ImageModalProps) => {
const { t } = useTranslation()
return (
<Dialog open={!!image} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] p-0">
<DialogHeader className="p-6 pb-2">
<DialogTitle>{image?.alt || t('common:image')}</DialogTitle>
</DialogHeader>
<div className="flex justify-center items-center p-6 pt-2">
{image && (
<img
src={image.url}
alt={image.alt}
className="max-w-full max-h-[70vh] object-contain rounded-md"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
)}
</div>
</DialogContent>
</Dialog>
)
}
export default ImageModal

View File

@ -0,0 +1,71 @@
import { useState } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogHeader,
} from '@/components/ui/dialog'
import { IconInfoCircle } from '@tabler/icons-react'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import CodeEditor from '@uiw/react-textarea-code-editor'
import '@uiw/react-textarea-code-editor/dist.css'
interface MessageMetadataDialogProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metadata: any
triggerElement?: React.ReactNode
}
export function MessageMetadataDialog({
metadata,
triggerElement,
}: MessageMetadataDialogProps) {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const defaultTrigger = (
<Tooltip>
<TooltipTrigger asChild>
<button className="outline-0 focus:outline-0 flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconInfoCircle size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('metadata')}</p>
</TooltipContent>
</Tooltip>
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger>{triggerElement || defaultTrigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common:dialogs.messageMetadata.title')}</DialogTitle>
<div className="space-y-2 mt-4">
<div className="border border-main-view-fg/10 rounded-md">
<CodeEditor
value={JSON.stringify(metadata || {}, null, 2)}
language="json"
readOnly
data-color-mode="dark"
style={{
fontSize: 12,
backgroundColor: 'transparent',
fontFamily: 'monospace',
}}
className="w-full h-full !text-sm "
/>
</div>
</div>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogClose,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { IconEdit } from '@tabler/icons-react'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { toast } from 'sonner'
interface RenameThreadDialogProps {
thread: Thread
plainTitleForRename: string
onRename: (threadId: string, title: string) => void
onDropdownClose: () => void
}
export function RenameThreadDialog({
thread,
plainTitleForRename,
onRename,
onDropdownClose,
}: RenameThreadDialogProps) {
const { t } = useTranslation()
const [title, setTitle] = useState('')
const [isOpen, setIsOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (isOpen && inputRef.current) {
setTimeout(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, 100)
}
}, [isOpen])
const handleOpenChange = (open: boolean) => {
setIsOpen(open)
if (open) {
setTitle(plainTitleForRename || t('common:newThread'))
} else {
onDropdownClose()
}
}
const handleRename = () => {
if (title.trim()) {
onRename(thread.id, title.trim())
setIsOpen(false)
onDropdownClose()
toast.success(t('common:toast.renameThread.title'), {
id: 'rename-thread',
description: t('common:toast.renameThread.description', { title }),
})
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
e.stopPropagation()
if (e.key === 'Enter' && title.trim()) {
handleRename()
}
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<IconEdit />
<span>{t('common:rename')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common:threadTitle')}</DialogTitle>
<Input
ref={inputRef}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-2"
onKeyDown={handleKeyDown}
placeholder={t('common:threadTitle')}
aria-label={t('common:threadTitle')}
/>
<DialogFooter className="mt-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogClose asChild>
<Button variant="link" size="sm" className="w-full sm:w-auto">
{t('common:cancel')}
</Button>
</DialogClose>
<Button
disabled={!title.trim()}
onClick={handleRename}
size="sm"
className="w-full sm:w-auto"
>
{t('common:rename')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,9 @@
export { RenameThreadDialog } from './RenameThreadDialog'
export { DeleteThreadDialog } from './DeleteThreadDialog'
export { DeleteAllThreadsDialog } from './DeleteAllThreadsDialog'
export { EditMessageDialog } from './EditMessageDialog'
export { MessageMetadataDialog } from './MessageMetadataDialog'
export { DeleteMessageDialog } from './DeleteMessageDialog'
export { FactoryResetDialog } from './FactoryResetDialog'
export { DeleteAssistantDialog } from './DeleteAssistantDialog'
export { AddProviderDialog } from './AddProviderDialog'

View File

@ -7,15 +7,7 @@ import { useAssistant } from '@/hooks/useAssistant'
import HeaderPage from '@/containers/HeaderPage'
import { IconCirclePlus, IconPencil, IconTrash } from '@tabler/icons-react'
import AddEditAssistant from '@/containers/dialogs/AddEditAssistant'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { DeleteAssistantDialog } from '@/containers/dialogs'
import { AvatarEmoji } from '@/containers/AvatarEmoji'
import { useTranslation } from '@/i18n/react-i18next-compat'
@ -64,7 +56,8 @@ function Assistant() {
<div className="h-full p-4 overflow-y-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{assistants
.slice().sort((a, b) => a.created_at - b.created_at)
.slice()
.sort((a, b) => a.created_at - b.created_at)
.map((assistant) => (
<div
className="bg-main-view-fg/3 p-3 rounded-md"
@ -134,27 +127,11 @@ function Assistant() {
}
onSave={handleSave}
/>
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('assistants:deleteConfirmation')}</DialogTitle>
<DialogDescription>
{t('assistants:deleteConfirmationDesc')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="link"
onClick={() => setDeleteConfirmOpen(false)}
>
{t('assistants:cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} autoFocus>
{t('assistants:delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DeleteAssistantDialog
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
onConfirm={confirmDelete}
/>
</div>
</div>
)

View File

@ -10,17 +10,7 @@ import { useGeneralSetting } from '@/hooks/useGeneralSetting'
import { useAppUpdater } from '@/hooks/useAppUpdater'
import { useEffect, useState, useCallback } from 'react'
import ChangeDataFolderLocation from '@/containers/dialogs/ChangeDataFolderLocation'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { FactoryResetDialog } from '@/containers/dialogs'
import { useServiceHub } from '@/hooks/useServiceHub'
import {
IconBrandDiscord,
@ -372,42 +362,11 @@ function General() {
ns: 'settings',
})}
actions={
<Dialog>
<DialogTrigger asChild>
<FactoryResetDialog onReset={resetApp}>
<Button variant="destructive" size="sm">
{t('common:reset')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('settings:general.factoryResetTitle')}
</DialogTitle>
<DialogDescription>
{t('settings:general.factoryResetDesc')}
</DialogDescription>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('settings:general.cancel')}
</Button>
</DialogClose>
<DialogClose asChild>
<Button
variant="destructive"
onClick={() => resetApp()}
>
{t('settings:general.reset')}
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
</FactoryResetDialog>
}
/>
</Card>

View File

@ -10,18 +10,9 @@ import { useNavigate } from '@tanstack/react-router'
import { IconCirclePlus, IconSettings } from '@tabler/icons-react'
import { getProviderTitle } from '@/lib/utils'
import ProvidersAvatar from '@/containers/ProvidersAvatar'
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { AddProviderDialog } from '@/containers/dialogs'
import { Switch } from '@/components/ui/switch'
import { useCallback, useState } from 'react'
import { useCallback } from 'react'
import { openAIProviderSettings } from '@/consts/providers'
import cloneDeep from 'lodash/cloneDeep'
import { toast } from 'sonner'
@ -39,9 +30,9 @@ function ModelProviders() {
const serviceHub = useServiceHub()
const { providers, addProvider, updateProvider } = useModelProvider()
const navigate = useNavigate()
const [name, setName] = useState('')
const createProvider = useCallback(() => {
const createProvider = useCallback(
(name: string) => {
if (
providers.some((e) => e.provider.toLowerCase() === name.toLowerCase())
) {
@ -65,7 +56,9 @@ function ModelProviders() {
},
})
}, 0)
}, [providers, name, addProvider, t, navigate])
},
[providers, addProvider, t, navigate]
)
// Check if model provider settings are enabled for this platform
if (!PlatformFeatures[PlatformFeature.MODEL_PROVIDER_SETTINGS]) {
@ -107,8 +100,7 @@ function ModelProviders() {
<span className="text-main-view-fg font-medium text-base">
{t('common:modelProviders')}
</span>
<Dialog>
<DialogTrigger asChild>
<AddProviderDialog onCreateProvider={createProvider}>
<Button
variant="link"
size="sm"
@ -119,41 +111,7 @@ function ModelProviders() {
<span>{t('provider:addProvider')}</span>
</div>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('provider:addOpenAIProvider')}
</DialogTitle>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-2"
placeholder={t('provider:enterNameForProvider')}
onKeyDown={(e) => {
// Prevent key from being captured by parent components
e.stopPropagation()
}}
/>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<DialogClose asChild>
<Button disabled={!name} onClick={createProvider}>
{t('common:create')}
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
</AddProviderDialog>
</div>
}
>
@ -198,7 +156,10 @@ function ModelProviders() {
<Switch
checked={provider.active}
onCheckedChange={async (e) => {
if (!e && provider.provider.toLowerCase() === 'llamacpp') {
if (
!e &&
provider.provider.toLowerCase() === 'llamacpp'
) {
await serviceHub.models().stopAllModels()
}
updateProvider(provider.provider, {