diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 04191e842..210322297 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -17,6 +17,8 @@ "label": "main", "title": "Jan", "width": 1024, + "minWidth": 375, + "minHeight": 667, "height": 800, "resizable": true, "fullscreen": false, diff --git a/web-app/src/components/ui/dialog.tsx b/web-app/src/components/ui/dialog.tsx index 98f4da71c..ed95f00b4 100644 --- a/web-app/src/components/ui/dialog.tsx +++ b/web-app/src/components/ui/dialog.tsx @@ -67,7 +67,7 @@ function DialogContent({ data-slot="dialog-content" aria-describedby={ariaDescribedBy} className={cn( - 'bg-main-view max-h-[calc(100%-48px)] overflow-auto border-main-view-fg/10 text-main-view-fg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', + 'bg-main-view max-h-[calc(100%-80px)] overflow-auto border-main-view-fg/10 text-main-view-fg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', className )} {...props} diff --git a/web-app/src/containers/LeftPanel.tsx b/web-app/src/containers/LeftPanel.tsx index 5d0b1542e..19dc30972 100644 --- a/web-app/src/containers/LeftPanel.tsx +++ b/web-app/src/containers/LeftPanel.tsx @@ -26,7 +26,7 @@ import { import { useThreads } from '@/hooks/useThreads' import { useTranslation } from '@/i18n/react-i18next-compat' -import { useMemo, useState } from 'react' +import { useMemo, useState, useEffect, useRef } from 'react' import { Dialog, DialogClose, @@ -40,6 +40,8 @@ import { import { Button } from '@/components/ui/button' import { toast } from 'sonner' import { DownloadManagement } from '@/containers/DownloadManegement' +import { useSmallScreen } from '@/hooks/useMediaQuery' +import { useClickOutside } from '@/hooks/useClickOutside' const mainMenus = [ { @@ -70,6 +72,68 @@ const LeftPanel = () => { const navigate = useNavigate() const [searchTerm, setSearchTerm] = useState('') + const isSmallScreen = useSmallScreen() + const prevScreenSizeRef = useRef(null) + const isInitialMountRef = useRef(true) + const panelRef = useRef(null) + const searchContainerRef = useRef(null) + const searchContainerMacRef = useRef(null) + + // Use click outside hook for panel with debugging + useClickOutside( + () => { + if (isSmallScreen && open) { + setLeftPanel(false) + } + }, + null, + [ + panelRef.current, + searchContainerRef.current, + searchContainerMacRef.current, + ] + ) + + // Auto-collapse panel only when window is resized + useEffect(() => { + const handleResize = () => { + const currentIsSmallScreen = window.innerWidth <= 768 + + // Skip on initial mount + if (isInitialMountRef.current) { + isInitialMountRef.current = false + prevScreenSizeRef.current = currentIsSmallScreen + return + } + + // Only trigger if the screen size actually changed + if ( + prevScreenSizeRef.current !== null && + prevScreenSizeRef.current !== currentIsSmallScreen + ) { + if (currentIsSmallScreen) { + setLeftPanel(false) + } else { + setLeftPanel(true) + } + prevScreenSizeRef.current = currentIsSmallScreen + } + } + + // Add resize listener + window.addEventListener('resize', handleResize) + + // Initialize the previous screen size on mount + if (isInitialMountRef.current) { + prevScreenSizeRef.current = window.innerWidth <= 768 + isInitialMountRef.current = false + } + + return () => { + window.removeEventListener('resize', handleResize) + } + }, [setLeftPanel]) + const currentPath = useRouterState({ select: (state) => state.location.pathname, }) @@ -91,50 +155,63 @@ const LeftPanel = () => { return filteredThreads.filter((t) => !t.isFavorite) }, [filteredThreads]) - return ( - + ) } diff --git a/web-app/src/containers/ThreadList.tsx b/web-app/src/containers/ThreadList.tsx index e4ed8aa93..bd3825eee 100644 --- a/web-app/src/containers/ThreadList.tsx +++ b/web-app/src/containers/ThreadList.tsx @@ -20,8 +20,10 @@ import { 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 { DropdownMenu, @@ -55,6 +57,9 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => { isDragging, } = useSortable({ id: thread.id, disabled: true }) + const isSmallScreen = useSmallScreen() + const { setLeftPanel } = useLeftPanel() + const style = { transform: CSS.Transform.toString(transform), transition, @@ -75,7 +80,11 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => { const handleClick = () => { if (!isDragging) { - navigate({ to: route.threadsDetail, params: { threadId: thread.id } }) + // Only close panel and navigate if the thread is not already active + if (!isActive) { + if (isSmallScreen) setLeftPanel(false) + navigate({ to: route.threadsDetail, params: { threadId: thread.id } }) + } } } @@ -85,7 +94,9 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => { return (thread.title || '').replace(/]*>|<\/span>/g, '') }, [thread.title]) - const [title, setTitle] = useState(plainTitleForRename || t('common:newThread')) + const [title, setTitle] = useState( + plainTitleForRename || t('common:newThread') + ) return (
{ setOpenDropdown(false) toast.success(t('common:toast.renameThread.title'), { id: 'rename-thread', - description: t('common:toast.renameThread.description', { title }), + description: t( + 'common:toast.renameThread.description', + { title } + ), }) }} > @@ -231,7 +245,9 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => { setOpenDropdown(false) toast.success(t('common:toast.deleteThread.title'), { id: 'delete-thread', - description: t('common:toast.deleteThread.description'), + description: t( + 'common:toast.deleteThread.description' + ), }) setTimeout(() => { navigate({ to: route.home }) diff --git a/web-app/src/containers/dialogs/AddEditAssistant.tsx b/web-app/src/containers/dialogs/AddEditAssistant.tsx index 7a1242027..f4e327e35 100644 --- a/web-app/src/containers/dialogs/AddEditAssistant.tsx +++ b/web-app/src/containers/dialogs/AddEditAssistant.tsx @@ -378,73 +378,27 @@ export default function AddEditAssistant({
{paramsKeys.map((key, index) => ( -
- - handleParameterChange(index, e.target.value, 'key') - } - placeholder={t('assistants:key')} - className="w-24" - /> +
+
+ + handleParameterChange(index, e.target.value, 'key') + } + placeholder={t('assistants:key')} + className="w-full sm:w-24" + /> - - -
- - -
-
- - - handleParameterChange(index, 'string', 'type') - } - > - {t('assistants:stringValue')} - - - handleParameterChange(index, 'number', 'type') - } - > - {t('assistants:numberValue')} - - - handleParameterChange(index, 'boolean', 'type') - } - > - {t('assistants:booleanValue')} - - - handleParameterChange(index, 'json', 'type') - } - > - {t('assistants:jsonValue')} - - -
- - {paramsTypes[index] === 'boolean' ? ( -
+
@@ -454,48 +408,98 @@ export default function AddEditAssistant({ />
- + - handleParameterChange(index, true, 'value') + handleParameterChange(index, 'string', 'type') } > - {t('assistants:trueValue')} + {t('assistants:stringValue')} - handleParameterChange(index, false, 'value') + handleParameterChange(index, 'number', 'type') } > - {t('assistants:falseValue')} + {t('assistants:numberValue')} + + + handleParameterChange(index, 'boolean', 'type') + } + > + {t('assistants:booleanValue')} + + + handleParameterChange(index, 'json', 'type') + } + > + {t('assistants:jsonValue')} - ) : paramsTypes[index] === 'json' ? ( - - handleParameterChange(index, e.target.value, 'value') - } - placeholder={t('assistants:jsonValuePlaceholder')} - className="flex-1" - /> - ) : ( - - handleParameterChange(index, e.target.value, 'value') - } - type={paramsTypes[index] === 'number' ? 'number' : 'text'} - placeholder={t('assistants:value')} - className="flex-1" - /> - )} + {paramsTypes[index] === 'boolean' ? ( + + +
+ + +
+
+ + + handleParameterChange(index, true, 'value') + } + > + {t('assistants:trueValue')} + + + handleParameterChange(index, false, 'value') + } + > + {t('assistants:falseValue')} + + +
+ ) : paramsTypes[index] === 'json' ? ( + + handleParameterChange(index, e.target.value, 'value') + } + placeholder={t('assistants:jsonValuePlaceholder')} + className="sm:flex-1 h-[36px] w-full" + /> + ) : ( + + handleParameterChange(index, e.target.value, 'value') + } + type={paramsTypes[index] === 'number' ? 'number' : 'text'} + placeholder={t('assistants:value')} + className="sm:flex-1 h-[36px] w-full" + /> + )} +
handleRemoveParameter(index)} diff --git a/web-app/src/hooks/useClickOutside.ts b/web-app/src/hooks/useClickOutside.ts new file mode 100644 index 000000000..237b9dff1 --- /dev/null +++ b/web-app/src/hooks/useClickOutside.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useEffect, useRef } from 'react' + +const DEFAULT_EVENTS = ['mousedown', 'touchstart'] + +export function useClickOutside( + handler: () => void, + events?: string[] | null, + nodes?: (HTMLElement | null)[] +) { + const ref = useRef(null) + + useEffect(() => { + const listener = (event: any) => { + const { target } = event ?? {} + if (Array.isArray(nodes)) { + const shouldIgnore = + target?.hasAttribute('data-ignore-outside-clicks') || + (!document.body.contains(target) && target.tagName !== 'HTML') + const shouldTrigger = nodes.every( + (node) => !!node && !event.composedPath().includes(node) + ) + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + shouldTrigger && !shouldIgnore && handler() + } else if (ref.current && !ref.current.contains(target)) { + handler() + } + } + + ;(events || DEFAULT_EVENTS).forEach((fn) => + document.addEventListener(fn, listener) + ) + + return () => { + ;(events || DEFAULT_EVENTS).forEach((fn) => + document.removeEventListener(fn, listener) + ) + } + }, [ref, handler, nodes, events]) + + return ref +} diff --git a/web-app/src/hooks/useMediaQuery.ts b/web-app/src/hooks/useMediaQuery.ts new file mode 100644 index 000000000..9e479eec4 --- /dev/null +++ b/web-app/src/hooks/useMediaQuery.ts @@ -0,0 +1,90 @@ +import { useEffect, useRef, useState } from 'react' +import { create } from 'zustand' + +export interface UseMediaQueryOptions { + getInitialValueInEffect: boolean +} + +type MediaQueryCallback = (event: { matches: boolean; media: string }) => void + +// Zustand store for small screen state +type SmallScreenState = { + isSmallScreen: boolean + setIsSmallScreen: (isSmall: boolean) => void +} + +export const useSmallScreenStore = create((set) => ({ + isSmallScreen: false, + setIsSmallScreen: (isSmall) => set({ isSmallScreen: isSmall }), +})) + +/** + * Older versions of Safari (shipped withCatalina and before) do not support addEventListener on matchMedia + * https://stackoverflow.com/questions/56466261/matchmedia-addlistener-marked-as-deprecated-addeventlistener-equivalent + * */ +function attachMediaListener( + query: MediaQueryList, + callback: MediaQueryCallback +) { + try { + query.addEventListener('change', callback) + return () => query.removeEventListener('change', callback) + } catch (e) { + console.warn(e) + // eslint-disable @typescript-eslint/no-deprecated + query.addListener(callback) + return () => query.removeListener(callback) + // eslint-enable @typescript-eslint/no-deprecated + } +} + +function getInitialValue(query: string, initialValue?: boolean) { + if (typeof initialValue === 'boolean') { + return initialValue + } + + if (typeof window !== 'undefined' && 'matchMedia' in window) { + return window.matchMedia(query).matches + } + + return false +} + +export function useMediaQuery( + query: string, + initialValue?: boolean, + { getInitialValueInEffect }: UseMediaQueryOptions = { + getInitialValueInEffect: true, + } +): boolean { + const [matches, setMatches] = useState( + getInitialValueInEffect ? initialValue : getInitialValue(query) + ) + const queryRef = useRef(null) + + useEffect(() => { + if ('matchMedia' in window) { + queryRef.current = window.matchMedia(query) + setMatches(queryRef.current.matches) + return attachMediaListener(queryRef.current, (event) => + setMatches(event.matches) + ) + } + + return undefined + }, [query]) + + return matches || false +} + +// Specific hook for small screen detection with state management +export const useSmallScreen = (): boolean => { + const { isSmallScreen, setIsSmallScreen } = useSmallScreenStore() + const mediaQuery = useMediaQuery('(max-width: 768px)') + + useEffect(() => { + setIsSmallScreen(mediaQuery) + }, [mediaQuery, setIsSmallScreen]) + + return isSmallScreen +} diff --git a/web-app/src/locales/en/hub.json b/web-app/src/locales/en/hub.json index bdc83bd50..e082c05b5 100644 --- a/web-app/src/locales/en/hub.json +++ b/web-app/src/locales/en/hub.json @@ -13,7 +13,6 @@ "useModel": "Use this model", "downloadModel": "Download model", "searchPlaceholder": "Search for models on Hugging Face...", - "editTheme": "Edit Theme", "joyride": { "recommendedModelTitle": "Recommended Model", "recommendedModelContent": "Browse and download powerful AI models from various providers, all in one place. We suggest starting with Jan-Nano - a model optimized for function calling, tool integration, and research capabilities. It's ideal for building interactive AI agents.", @@ -28,4 +27,4 @@ "next": "Next", "skip": "Skip" } -} \ No newline at end of file +} diff --git a/web-app/src/locales/id/hub.json b/web-app/src/locales/id/hub.json index 7d9fe733f..5aa1e7d1c 100644 --- a/web-app/src/locales/id/hub.json +++ b/web-app/src/locales/id/hub.json @@ -13,7 +13,6 @@ "useModel": "Gunakan model ini", "downloadModel": "Unduh model", "searchPlaceholder": "Cari model di Hugging Face...", - "editTheme": "Edit Tema", "joyride": { "recommendedModelTitle": "Model yang Direkomendasikan", "recommendedModelContent": "Jelajahi dan unduh model AI yang kuat dari berbagai penyedia, semuanya di satu tempat. Kami sarankan memulai dengan Jan-Nano - model yang dioptimalkan untuk pemanggilan fungsi, integrasi alat, dan kemampuan penelitian. Ini ideal untuk membangun agen AI interaktif.", @@ -28,4 +27,4 @@ "next": "Berikutnya", "skip": "Lewati" } -} \ No newline at end of file +} diff --git a/web-app/src/locales/vn/hub.json b/web-app/src/locales/vn/hub.json index 34f6b485b..8b38d84cc 100644 --- a/web-app/src/locales/vn/hub.json +++ b/web-app/src/locales/vn/hub.json @@ -13,7 +13,6 @@ "useModel": "Sử dụng mô hình này", "downloadModel": "Tải xuống mô hình", "searchPlaceholder": "Tìm kiếm các mô hình trên Hugging Face...", - "editTheme": "Chỉnh sửa chủ đề", "joyride": { "recommendedModelTitle": "Mô hình được đề xuất", "recommendedModelContent": "Duyệt và tải xuống các mô hình AI mạnh mẽ từ nhiều nhà cung cấp khác nhau, tất cả ở cùng một nơi. Chúng tôi khuyên bạn nên bắt đầu với Jan-Nano - một mô hình được tối ưu hóa cho các khả năng gọi hàm, tích hợp công cụ và nghiên cứu. Nó lý tưởng để xây dựng các tác nhân AI tương tác.", @@ -28,4 +27,4 @@ "next": "Tiếp theo", "skip": "Bỏ qua" } -} \ No newline at end of file +} diff --git a/web-app/src/locales/zh-CN/hub.json b/web-app/src/locales/zh-CN/hub.json index 39231c2fb..dc005611a 100644 --- a/web-app/src/locales/zh-CN/hub.json +++ b/web-app/src/locales/zh-CN/hub.json @@ -13,7 +13,6 @@ "useModel": "使用此模型", "downloadModel": "下载模型", "searchPlaceholder": "在 Hugging Face 上搜索模型...", - "editTheme": "编辑主题", "joyride": { "recommendedModelTitle": "推荐模型", "recommendedModelContent": "在一个地方浏览和下载来自不同提供商的强大 AI 模型。我们建议从 Jan-Nano 开始 - 这是一个针对函数调用、工具集成和研究功能进行优化的模型。它非常适合构建交互式 AI 代理。", @@ -28,4 +27,4 @@ "next": "下一步", "skip": "跳过" } -} \ No newline at end of file +} diff --git a/web-app/src/locales/zh-TW/hub.json b/web-app/src/locales/zh-TW/hub.json index e4a4df0d5..f35a4485a 100644 --- a/web-app/src/locales/zh-TW/hub.json +++ b/web-app/src/locales/zh-TW/hub.json @@ -13,7 +13,6 @@ "useModel": "使用此模型", "downloadModel": "下載模型", "searchPlaceholder": "在 Hugging Face 上搜尋模型...", - "editTheme": "編輯主題", "joyride": { "recommendedModelTitle": "推薦模型", "recommendedModelContent": "在一個地方瀏覽和下載來自不同提供商的強大 AI 模型。我們建議從 Jan-Nano 開始 - 這是一個針對函數調用、工具整合和研究功能進行優化的模型。它非常適合構建互動式 AI 代理。", @@ -28,4 +27,4 @@ "next": "下一步", "skip": "略過" } -} \ No newline at end of file +} diff --git a/web-app/src/routes/__root.tsx b/web-app/src/routes/__root.tsx index 1014c2aa1..8c09addf5 100644 --- a/web-app/src/routes/__root.tsx +++ b/web-app/src/routes/__root.tsx @@ -43,8 +43,8 @@ const AppLayout = () => { {/* Main content panel */}
diff --git a/web-app/src/routes/assistant.tsx b/web-app/src/routes/assistant.tsx index 031003766..fe0cf6fe7 100644 --- a/web-app/src/routes/assistant.tsx +++ b/web-app/src/routes/assistant.tsx @@ -62,57 +62,60 @@ function Assistant() { {t('assistants:title')}
-
- {assistants.map((assistant) => ( -
-
-

-
- {assistant?.avatar && ( - - - - )} - {assistant.name} -
-

-
-
{ - setEditingKey(assistant.id) - setOpen(true) - }} - > - -
-
handleDelete(assistant.id)} - > - +
+ {assistants + .slice().sort((a, b) => a.created_at - b.created_at) + .map((assistant) => ( +
+
+

+
+ {assistant?.avatar && ( + + + + )} + {assistant.name} +
+

+
+
{ + setEditingKey(assistant.id) + setOpen(true) + }} + > + +
+
handleDelete(assistant.id)} + > + +
+

+ {assistant.description} +

-

- {assistant.description} -

-
- ))} + ))} +
{ setEditingKey(null) diff --git a/web-app/src/routes/hub.tsx b/web-app/src/routes/hub.tsx index 20f057073..5ac51b1af 100644 --- a/web-app/src/routes/hub.tsx +++ b/web-app/src/routes/hub.tsx @@ -358,6 +358,46 @@ function Hub() { // Check if we're on the last step const isLastStep = currentStepIndex === steps.length - 1 + const renderFilter = () => { + return ( + <> + + + + { + sortOptions.find((option) => option.value === sortSelected) + ?.name + } + + + + {sortOptions.map((option) => ( + setSortSelected(option.value)} + > + {option.name} + + ))} + + +
+ + + {t('hub:downloaded')} + +
+ + ) + } + return ( <>
{isSearching ? ( - + ) : ( - + )}
-
- - - - { - sortOptions.find( - (option) => option.value === sortSelected - )?.name - } - - - - {sortOptions.map((option) => ( - setSortSelected(option.value)} - > - {option.name} - - ))} - - -
- - - {t('hub:downloaded')} - -
+
+ {renderFilter()}
-
+
{loading ? (
@@ -459,6 +466,9 @@ function Hub() {
) : (
+
+ {renderFilter()} +
{filteredModels.map((model) => (

{extractModelName(model.metadata?.id) || ''}

diff --git a/web-app/src/routes/index.tsx b/web-app/src/routes/index.tsx index cc072fb75..89a9172c3 100644 --- a/web-app/src/routes/index.tsx +++ b/web-app/src/routes/index.tsx @@ -53,8 +53,8 @@ function Index() { -
-
+
+

{t('chat:welcome')} diff --git a/web-app/src/routes/threads/$threadId.tsx b/web-app/src/routes/threads/$threadId.tsx index 3984ace00..d2c4a3e3e 100644 --- a/web-app/src/routes/threads/$threadId.tsx +++ b/web-app/src/routes/threads/$threadId.tsx @@ -20,6 +20,7 @@ import { useAssistant } from '@/hooks/useAssistant' import { useAppearance } from '@/hooks/useAppearance' import { useOutOfContextPromiseModal } from '@/containers/dialogs/OutOfContextDialog' import { useTranslation } from '@/i18n/react-i18next-compat' +import { useSmallScreen } from '@/hooks/useMediaQuery' // as route.threadsDetail export const Route = createFileRoute('/threads/$threadId')({ @@ -38,6 +39,7 @@ function ThreadDetail() { const { setMessages } = useMessages() const { streamingContent } = useAppState() const { appMainViewBgColor, chatWidth } = useAppearance() + const isSmallScreen = useSmallScreen() const { messages } = useMessages( useShallow((state) => ({ @@ -218,7 +220,8 @@ function ThreadDetail() {
{messages && @@ -256,8 +259,9 @@ function ThreadDetail() {