feat(desktop): implement history navigation stack (#11341)

*  feat(navigation): implement history navigation in the desktop app

- Add 'Back' and 'Forward' options to the menu for navigating history.
- Introduce a new NavigationBar component to handle navigation actions.
- Implement hooks for managing navigation history and updating the UI accordingly.
- Enhance the Electron store to support navigation history state management.
- Add route metadata for better navigation context.

This update improves user experience by allowing easy back and forward navigation within the app.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(localization): add navigation labels in multiple languages

- Introduced new localization entries for navigation history in various languages, including Arabic, Bulgarian, German, Spanish, Persian, French, Italian, Japanese, Korean, Dutch, Polish, Portuguese, Russian, Turkish, Vietnamese, Chinese (Simplified and Traditional).
- Updated existing localization files to include 'Back', 'Forward', and 'Go' labels for improved user navigation experience.

This enhancement supports a more inclusive user interface by providing localized navigation options.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(desktop): add Home menu item and simplify navigation UI

- Remove keyboard shortcut hints from Recently Viewed tooltip
- Add Home menu item to Go menu on all platforms (macOS, Linux, Windows)
- Add Home translations for all 17 supported locales

* 🌐 i18n(desktop): use i18n for Recently Viewed tooltip

*  feat(macOS): update history navigation accelerators in menu

- Change keyboard shortcuts for 'Back', 'Forward', and 'Home' menu items to use macOS conventions.
- Add unit test to verify correct accelerators are set for history navigation.

Signed-off-by: Innei <tukon479@gmail.com>

*  refactor(ElectronTitlebar): remove unused navigation history hook

- Deleted the `useInitNavigationHistory` hook and its associated logic from the ElectronTitlebar component.
- Cleaned up the code to improve maintainability and reduce unnecessary complexity.

This change streamlines the title bar functionality by eliminating unused code.

Signed-off-by: Innei <tukon479@gmail.com>

*  refactor(NavPanel): streamline navigation panel functionality

- Replaced the `useNavPanel` hook with a new `useNavPanelSizeChangeHandler` for better size management.
- Introduced `NavPanelDraggable` component to encapsulate draggable panel logic, improving code organization and readability.
- Updated `NavHeader` to utilize global store for panel state management, enhancing state consistency across components.
- Removed unused styles and logic from `NavPanel`, simplifying the component structure.

This refactor enhances maintainability and performance of the navigation panel system.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-01-08 19:37:51 +08:00
committed by GitHub
parent 45ad33094e
commit db270d5aba
57 changed files with 1746 additions and 191 deletions

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "الإبلاغ عن مشكلة",
"help.title": "مساعدة",
"help.visitWebsite": "زيارة الموقع الرسمي",
"history.back": "رجوع",
"history.forward": "تقدم",
"history.home": "الرئيسية",
"history.title": "انتقل",
"macOS.about": "حول {{appName}}",
"macOS.devTools": "أدوات مطور LobeHub",
"macOS.hide": "إخفاء {{appName}}",
@@ -50,4 +54,4 @@
"window.title": "نافذة",
"window.toggleFullscreen": "تبديل وضع ملء الشاشة",
"window.zoom": "تكبير"
}
}

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "Докладвай проблем",
"help.title": "Помощ",
"help.visitWebsite": "Посети уебсайта",
"history.back": "Назад",
"history.forward": "Напред",
"history.home": "Начало",
"history.title": "Отиди",
"macOS.about": "За {{appName}}",
"macOS.devTools": "Инструменти за разработчици на LobeHub",
"macOS.hide": "Скрий {{appName}}",
@@ -50,4 +54,4 @@
"window.title": "Прозорец",
"window.toggleFullscreen": "Превключи на цял екран",
"window.zoom": "Мащаб"
}
}

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "Problem melden",
"help.title": "Hilfe",
"help.visitWebsite": "Besuche die Website",
"history.back": "Zurück",
"history.forward": "Vorwärts",
"history.home": "Start",
"history.title": "Gehen",
"macOS.about": "Über {{appName}}",
"macOS.devTools": "LobeHub Entwicklerwerkzeuge",
"macOS.hide": "{{appName}} ausblenden",
@@ -50,4 +54,4 @@
"window.title": "Fenster",
"window.toggleFullscreen": "Vollbild umschalten",
"window.zoom": "Zoom"
}
}

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "Reportar un problema",
"help.title": "Ayuda",
"help.visitWebsite": "Visitar el sitio web",
"history.back": "Atrás",
"history.forward": "Adelante",
"history.home": "Inicio",
"history.title": "Ir",
"macOS.about": "Acerca de {{appName}}",
"macOS.devTools": "Herramientas de desarrollador de LobeHub",
"macOS.hide": "Ocultar {{appName}}",
@@ -50,4 +54,4 @@
"window.title": "Ventana",
"window.toggleFullscreen": "Alternar pantalla completa",
"window.zoom": "Zoom"
}
}

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "گزارش مشکل",
"help.title": "کمک",
"help.visitWebsite": "بازدید از وب‌سایت",
"history.back": "بازگشت",
"history.forward": "جلو",
"history.home": "خانه",
"history.title": "برو",
"macOS.about": "درباره {{appName}}",
"macOS.devTools": "ابزارهای توسعه‌دهنده LobeHub",
"macOS.hide": "پنهان کردن {{appName}}",
@@ -50,4 +54,4 @@
"window.title": "پنجره",
"window.toggleFullscreen": "تغییر به حالت تمام صفحه",
"window.zoom": "زوم"
}
}

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "Signaler un problème",
"help.title": "Aide",
"help.visitWebsite": "Visiter le site officiel",
"history.back": "Retour",
"history.forward": "Avancer",
"history.home": "Accueil",
"history.title": "Aller",
"macOS.about": "À propos de {{appName}}",
"macOS.devTools": "Outils de développement LobeHub",
"macOS.hide": "Masquer {{appName}}",
@@ -50,4 +54,4 @@
"window.title": "Fenêtre",
"window.toggleFullscreen": "Basculer en plein écran",
"window.zoom": "Zoom"
}
}

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "Segnala un problema",
"help.title": "Aiuto",
"help.visitWebsite": "Visita il sito ufficiale",
"history.back": "Indietro",
"history.forward": "Avanti",
"history.home": "Home",
"history.title": "Vai",
"macOS.about": "Informazioni su {{appName}}",
"macOS.devTools": "Strumenti per sviluppatori LobeHub",
"macOS.hide": "Nascondi {{appName}}",
@@ -50,4 +54,4 @@
"window.title": "Finestra",
"window.toggleFullscreen": "Attiva/disattiva schermo intero",
"window.zoom": "Zoom"
}
}

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "問題を報告",
"help.title": "ヘルプ",
"help.visitWebsite": "公式ウェブサイトを訪問",
"history.back": "戻る",
"history.forward": "進む",
"history.home": "ホーム",
"history.title": "移動",
"macOS.about": "{{appName}} について",
"macOS.devTools": "LobeHub 開発者ツール",
"macOS.hide": "{{appName}} を隠す",
@@ -50,4 +54,4 @@
"window.title": "ウィンドウ",
"window.toggleFullscreen": "フルスクリーン切替",
"window.zoom": "ズーム"
}
}

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "문제 보고",
"help.title": "도움말",
"help.visitWebsite": "웹사이트 방문",
"history.back": "뒤로",
"history.forward": "앞으로",
"history.home": "홈",
"history.title": "이동",
"macOS.about": "{{appName}} 정보",
"macOS.devTools": "LobeHub 개발자 도구",
"macOS.hide": "{{appName}} 숨기기",
@@ -50,4 +54,4 @@
"window.title": "창",
"window.toggleFullscreen": "전체 화면 전환",
"window.zoom": "줌"
}
}

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "Probleem melden",
"help.title": "Hulp",
"help.visitWebsite": "Bezoek de website",
"history.back": "Terug",
"history.forward": "Vooruit",
"history.home": "Home",
"history.title": "Ga",
"macOS.about": "Over {{appName}}",
"macOS.devTools": "LobeHub Ontwikkelaarstools",
"macOS.hide": "Verberg {{appName}}",
@@ -50,4 +54,4 @@
"window.title": "Venster",
"window.toggleFullscreen": "Schakel volledig scherm in/uit",
"window.zoom": "Inzoomen"
}
}

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "Zgłoś problem",
"help.title": "Pomoc",
"help.visitWebsite": "Odwiedź stronę internetową",
"history.back": "Wstecz",
"history.forward": "Naprzód",
"history.home": "Strona główna",
"history.title": "Idź",
"macOS.about": "O {{appName}}",
"macOS.devTools": "Narzędzia dewelopera LobeHub",
"macOS.hide": "Ukryj {{appName}}",
@@ -50,4 +54,4 @@
"window.title": "Okno",
"window.toggleFullscreen": "Przełącz tryb pełnoekranowy",
"window.zoom": "Powiększenie"
}
}

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "Reportar Problema",
"help.title": "Ajuda",
"help.visitWebsite": "Visitar o Site",
"history.back": "Voltar",
"history.forward": "Avançar",
"history.home": "Início",
"history.title": "Ir",
"macOS.about": "Sobre {{appName}}",
"macOS.devTools": "Ferramentas do Desenvolvedor LobeHub",
"macOS.hide": "Ocultar {{appName}}",
@@ -50,4 +54,4 @@
"window.title": "Janela",
"window.toggleFullscreen": "Alternar Tela Cheia",
"window.zoom": "Zoom"
}
}

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "Сообщить о проблеме",
"help.title": "Помощь",
"help.visitWebsite": "Посетить сайт",
"history.back": "Назад",
"history.forward": "Вперёд",
"history.home": "Домой",
"history.title": "Перейти",
"macOS.about": "О {{appName}}",
"macOS.devTools": "Инструменты разработчика LobeHub",
"macOS.hide": "Скрыть {{appName}}",
@@ -50,4 +54,4 @@
"window.title": "Окно",
"window.toggleFullscreen": "Переключить полноэкранный режим",
"window.zoom": "Масштаб"
}
}

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "Sorun Bildir",
"help.title": "Yardım",
"help.visitWebsite": "Resmi Web Sitesini Ziyaret Et",
"history.back": "Geri",
"history.forward": "İleri",
"history.home": "Ana Sayfa",
"history.title": "Git",
"macOS.about": "{{appName}} Hakkında",
"macOS.devTools": "LobeHub Geliştirici Araçları",
"macOS.hide": "{{appName}}'i Gizle",
@@ -50,4 +54,4 @@
"window.title": "Pencere",
"window.toggleFullscreen": "Tam Ekrana Geç",
"window.zoom": "Yakınlaştır"
}
}

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "Báo cáo sự cố",
"help.title": "Trợ giúp",
"help.visitWebsite": "Truy cập trang web",
"history.back": "Quay lại",
"history.forward": "Tiến tới",
"history.home": "Trang chủ",
"history.title": "Đi",
"macOS.about": "Về {{appName}}",
"macOS.devTools": "Công cụ phát triển LobeHub",
"macOS.hide": "Ẩn {{appName}}",
@@ -50,4 +54,4 @@
"window.title": "Cửa sổ",
"window.toggleFullscreen": "Chuyển đổi toàn màn hình",
"window.zoom": "Thu phóng"
}
}

View File

@@ -35,6 +35,10 @@
"help.reportIssue": "反馈问题",
"help.title": "帮助",
"help.visitWebsite": "打开官网",
"history.back": "后退",
"history.forward": "前进",
"history.home": "主页",
"history.title": "前往",
"macOS.about": "关于 {{appName}}",
"macOS.devTools": "LobeHub 开发者工具",
"macOS.hide": "隐藏 {{appName}}",
@@ -59,4 +63,4 @@
"window.title": "窗口",
"window.toggleFullscreen": "切换全屏",
"window.zoom": "缩放"
}
}

View File

@@ -26,6 +26,10 @@
"help.reportIssue": "報告問題",
"help.title": "幫助",
"help.visitWebsite": "訪問網站",
"history.back": "後退",
"history.forward": "前進",
"history.home": "首頁",
"history.title": "前往",
"macOS.about": "關於 {{appName}}",
"macOS.devTools": "LobeHub 開發者工具",
"macOS.hide": "隱藏 {{appName}}",
@@ -50,4 +54,4 @@
"window.title": "視窗",
"window.toggleFullscreen": "切換全螢幕",
"window.zoom": "縮放"
}
}

View File

@@ -35,6 +35,10 @@ const menu = {
'help.reportIssue': 'Send Feedback',
'help.title': 'Help',
'help.visitWebsite': 'Open Website',
'history.back': 'Back',
'history.forward': 'Forward',
'history.home': 'Home',
'history.title': 'Go',
'macOS.about': 'About {{appName}}',
'macOS.devTools': 'LobeHub Developer Tools',
'macOS.hide': 'Hide {{appName}}',
@@ -61,4 +65,4 @@ const menu = {
'window.zoom': 'Zoom',
};
export default menu;
export default menu;

View File

@@ -102,6 +102,36 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
{ accelerator: 'F11', label: t('view.toggleFullscreen'), role: 'togglefullscreen' },
],
},
{
label: t('history.title'),
submenu: [
{
accelerator: 'Alt+Left',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.broadcast('historyGoBack');
},
label: t('history.back'),
},
{
accelerator: 'Alt+Right',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.broadcast('historyGoForward');
},
label: t('history.forward'),
},
{ type: 'separator' },
{
accelerator: 'Ctrl+Shift+H',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.broadcast('navigate', { path: '/' });
},
label: t('history.home'),
},
],
},
{
label: t('window.title'),
submenu: [

View File

@@ -292,6 +292,23 @@ describe('MacOSMenu', () => {
expect(copyItem.accelerator).toBe('Command+C');
});
it('should set correct accelerators for history navigation', () => {
macOSMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const historyMenu = template.find(
(item: any) => item.label === menuTranslations['history.title'],
);
const backItem = historyMenu.submenu.find((item: any) => item.label === 'Back');
const forwardItem = historyMenu.submenu.find((item: any) => item.label === 'Forward');
const homeItem = historyMenu.submenu.find((item: any) => item.label === 'Home');
expect(backItem.accelerator).toBe('Command+[');
expect(forwardItem.accelerator).toBe('Command+]');
expect(homeItem.accelerator).toBe('Shift+Command+H');
});
});
describe('developer menu items', () => {

View File

@@ -166,6 +166,39 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
{ accelerator: 'F11', label: t('view.toggleFullscreen'), role: 'togglefullscreen' },
],
},
{
label: t('history.title'),
submenu: [
{
accelerator: 'Command+[',
acceleratorWorksWhenHidden: true,
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.broadcast('historyGoBack');
},
label: t('history.back'),
},
{
accelerator: 'Command+]',
acceleratorWorksWhenHidden: true,
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.broadcast('historyGoForward');
},
label: t('history.forward'),
},
{ type: 'separator' },
{
accelerator: 'Shift+Command+H',
acceleratorWorksWhenHidden: true,
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.broadcast('navigate', { path: '/' });
},
label: t('history.home'),
},
],
},
{
label: t('window.title'),
role: 'windowMenu',

View File

@@ -101,6 +101,36 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
{ accelerator: 'F11', label: t('view.toggleFullscreen'), role: 'togglefullscreen' },
],
},
{
label: t('history.title'),
submenu: [
{
accelerator: 'Alt+Left',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.broadcast('historyGoBack');
},
label: t('history.back'),
},
{
accelerator: 'Alt+Right',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.broadcast('historyGoForward');
},
label: t('history.forward'),
},
{ type: 'separator' },
{
accelerator: 'Ctrl+Shift+H',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.broadcast('navigate', { path: '/' });
},
label: t('history.home'),
},
],
},
{
label: t('window.title'),
submenu: [

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "محادثة",
"navigation.discover": "اكتشف",
"navigation.discoverAssistants": "اكتشف المساعدين",
"navigation.discoverMcp": "اكتشف MCP",
"navigation.discoverModels": "اكتشف النماذج",
"navigation.discoverProviders": "اكتشف المزودين",
"navigation.group": "مجموعة",
"navigation.groupChat": "محادثة جماعية",
"navigation.home": "الرئيسية",
"navigation.image": "صورة",
"navigation.knowledgeBase": "قاعدة المعرفة",
"navigation.lobehub": "LobeHub",
"navigation.memory": "الذاكرة",
"navigation.memoryContexts": "الذاكرة - السياقات",
"navigation.memoryExperiences": "الذاكرة - التجارب",
"navigation.memoryIdentities": "الذاكرة - الهويات",
"navigation.memoryPreferences": "الذاكرة - التفضيلات",
"navigation.onboarding": "البدء",
"navigation.page": "صفحة",
"navigation.pages": "الصفحات",
"navigation.provider": "المزود",
"navigation.recentView": "المشاهدات الأخيرة",
"navigation.resources": "الموارد",
"navigation.settings": "الإعدادات",
"notification.finishChatGeneration": "اكتمل توليد الرسالة بواسطة الذكاء الاصطناعي",
"proxy.auth": "يتطلب المصادقة",
"proxy.authDesc": "إذا كان خادم البروكسي يتطلب اسم مستخدم وكلمة مرور",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "Чат",
"navigation.discover": "Откриване",
"navigation.discoverAssistants": "Откриване на Асистенти",
"navigation.discoverMcp": "Откриване на MCP",
"navigation.discoverModels": "Откриване на Модели",
"navigation.discoverProviders": "Откриване на Доставчици",
"navigation.group": "Група",
"navigation.groupChat": "Групов Чат",
"navigation.home": "Начало",
"navigation.image": "Изображение",
"navigation.knowledgeBase": "База Знания",
"navigation.lobehub": "LobeHub",
"navigation.memory": "Памят",
"navigation.memoryContexts": "Памят - Контексти",
"navigation.memoryExperiences": "Памят - Преживявания",
"navigation.memoryIdentities": "Памят - Идентичности",
"navigation.memoryPreferences": "Памят - Предпочитания",
"navigation.onboarding": "Въведение",
"navigation.page": "Страница",
"navigation.pages": "Страници",
"navigation.provider": "Доставчик",
"navigation.recentView": "Последни преглеждания",
"navigation.resources": "Ресурси",
"navigation.settings": "Настройки",
"notification.finishChatGeneration": "Генерирането на съобщение от ИИ е завършено",
"proxy.auth": "Изисква се удостоверяване",
"proxy.authDesc": "Ако прокси сървърът изисква потребителско име и парола",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "Chat",
"navigation.discover": "Entdecken",
"navigation.discoverAssistants": "Assistenten entdecken",
"navigation.discoverMcp": "MCP entdecken",
"navigation.discoverModels": "Modelle entdecken",
"navigation.discoverProviders": "Anbieter entdecken",
"navigation.group": "Gruppe",
"navigation.groupChat": "Gruppen-Chat",
"navigation.home": "Startseite",
"navigation.image": "Bild",
"navigation.knowledgeBase": "Wissensdatenbank",
"navigation.lobehub": "LobeHub",
"navigation.memory": "Speicher",
"navigation.memoryContexts": "Speicher - Kontexte",
"navigation.memoryExperiences": "Speicher - Erfahrungen",
"navigation.memoryIdentities": "Speicher - Identitäten",
"navigation.memoryPreferences": "Speicher - Präferenzen",
"navigation.onboarding": "Einführung",
"navigation.page": "Seite",
"navigation.pages": "Seiten",
"navigation.provider": "Anbieter",
"navigation.recentView": "Zuletzt angesehen",
"navigation.resources": "Ressourcen",
"navigation.settings": "Einstellungen",
"notification.finishChatGeneration": "KI-Nachrichtenerstellung abgeschlossen",
"proxy.auth": "Authentifizierung erforderlich",
"proxy.authDesc": "Falls der Proxy-Server einen Benutzernamen und ein Passwort benötigt",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "Chat",
"navigation.discover": "Discover",
"navigation.discoverAssistants": "Discover Assistants",
"navigation.discoverMcp": "Discover MCP",
"navigation.discoverModels": "Discover Models",
"navigation.discoverProviders": "Discover Providers",
"navigation.group": "Group",
"navigation.groupChat": "Group Chat",
"navigation.home": "Home",
"navigation.image": "Image",
"navigation.knowledgeBase": "Knowledge Base",
"navigation.lobehub": "LobeHub",
"navigation.memory": "Memory",
"navigation.memoryContexts": "Memory - Contexts",
"navigation.memoryExperiences": "Memory - Experiences",
"navigation.memoryIdentities": "Memory - Identities",
"navigation.memoryPreferences": "Memory - Preferences",
"navigation.onboarding": "Onboarding",
"navigation.page": "Page",
"navigation.pages": "Pages",
"navigation.provider": "Provider",
"navigation.recentView": "Recent pages",
"navigation.resources": "Resources",
"navigation.settings": "Settings",
"notification.finishChatGeneration": "AI message generation completed",
"proxy.auth": "Authentication Required",
"proxy.authDesc": "If the proxy server requires a username and password",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "Chat",
"navigation.discover": "Descubrir",
"navigation.discoverAssistants": "Descubrir Asistentes",
"navigation.discoverMcp": "Descubrir MCP",
"navigation.discoverModels": "Descubrir Modelos",
"navigation.discoverProviders": "Descubrir Proveedores",
"navigation.group": "Grupo",
"navigation.groupChat": "Chat de Grupo",
"navigation.home": "Inicio",
"navigation.image": "Imagen",
"navigation.knowledgeBase": "Base de Conocimiento",
"navigation.lobehub": "LobeHub",
"navigation.memory": "Memoria",
"navigation.memoryContexts": "Memoria - Contextos",
"navigation.memoryExperiences": "Memoria - Experiencias",
"navigation.memoryIdentities": "Memoria - Identidades",
"navigation.memoryPreferences": "Memoria - Preferencias",
"navigation.onboarding": "Incorporación",
"navigation.page": "Página",
"navigation.pages": "Páginas",
"navigation.provider": "Proveedor",
"navigation.recentView": "Vistas recientes",
"navigation.resources": "Recursos",
"navigation.settings": "Configuración",
"notification.finishChatGeneration": "Generación de mensaje por IA completada",
"proxy.auth": "Autenticación requerida",
"proxy.authDesc": "Si el servidor proxy requiere un nombre de usuario y una contraseña",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "چت",
"navigation.discover": "کشف",
"navigation.discoverAssistants": "کشف دستیاران",
"navigation.discoverMcp": "کشف MCP",
"navigation.discoverModels": "کشف مدل‌ها",
"navigation.discoverProviders": "کشف ارائه‌دهندگان",
"navigation.group": "گروه",
"navigation.groupChat": "چت گروهی",
"navigation.home": "خانه",
"navigation.image": "تصویر",
"navigation.knowledgeBase": "پایگاه دانش",
"navigation.lobehub": "LobeHub",
"navigation.memory": "حافظه",
"navigation.memoryContexts": "حافظه - زمینه‌ها",
"navigation.memoryExperiences": "حافظه - تجربیات",
"navigation.memoryIdentities": "حافظه - هویت‌ها",
"navigation.memoryPreferences": "حافظه - ترجیحات",
"navigation.onboarding": "راه‌اندازی",
"navigation.page": "صفحه",
"navigation.pages": "صفحات",
"navigation.provider": "ارائه‌دهنده",
"navigation.recentView": "مشاهدات اخیر",
"navigation.resources": "منابع",
"navigation.settings": "تنظیمات",
"notification.finishChatGeneration": "تولید پیام توسط هوش مصنوعی به پایان رسید",
"proxy.auth": "احراز هویت لازم است",
"proxy.authDesc": "در صورتی که سرور پروکسی نیاز به نام کاربری و رمز عبور داشته باشد",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "Chat",
"navigation.discover": "Découvrir",
"navigation.discoverAssistants": "Découvrir les Assistants",
"navigation.discoverMcp": "Découvrir MCP",
"navigation.discoverModels": "Découvrir les Modèles",
"navigation.discoverProviders": "Découvrir les Fournisseurs",
"navigation.group": "Groupe",
"navigation.groupChat": "Chat de Groupe",
"navigation.home": "Accueil",
"navigation.image": "Image",
"navigation.knowledgeBase": "Base de Connaissances",
"navigation.lobehub": "LobeHub",
"navigation.memory": "Mémoire",
"navigation.memoryContexts": "Mémoire - Contextes",
"navigation.memoryExperiences": "Mémoire - Expériences",
"navigation.memoryIdentities": "Mémoire - Identités",
"navigation.memoryPreferences": "Mémoire - Préférences",
"navigation.onboarding": "Intégration",
"navigation.page": "Page",
"navigation.pages": "Pages",
"navigation.provider": "Fournisseur",
"navigation.recentView": "Vues récentes",
"navigation.resources": "Ressources",
"navigation.settings": "Paramètres",
"notification.finishChatGeneration": "Génération du message par l'IA terminée",
"proxy.auth": "Authentification requise",
"proxy.authDesc": "Si le serveur proxy nécessite un nom d'utilisateur et un mot de passe",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "Chat",
"navigation.discover": "Scopri",
"navigation.discoverAssistants": "Scopri Assistenti",
"navigation.discoverMcp": "Scopri MCP",
"navigation.discoverModels": "Scopri Modelli",
"navigation.discoverProviders": "Scopri Provider",
"navigation.group": "Gruppo",
"navigation.groupChat": "Chat di Gruppo",
"navigation.home": "Home",
"navigation.image": "Immagine",
"navigation.knowledgeBase": "Base di Conoscenza",
"navigation.lobehub": "LobeHub",
"navigation.memory": "Memoria",
"navigation.memoryContexts": "Memoria - Contesti",
"navigation.memoryExperiences": "Memoria - Esperienze",
"navigation.memoryIdentities": "Memoria - Identità",
"navigation.memoryPreferences": "Memoria - Preferenze",
"navigation.onboarding": "Onboarding",
"navigation.page": "Pagina",
"navigation.pages": "Pagine",
"navigation.provider": "Provider",
"navigation.recentView": "Visualizzazioni recenti",
"navigation.resources": "Risorse",
"navigation.settings": "Impostazioni",
"notification.finishChatGeneration": "Generazione del messaggio AI completata",
"proxy.auth": "Autenticazione richiesta",
"proxy.authDesc": "Se il server proxy richiede nome utente e password",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "チャット",
"navigation.discover": "発見",
"navigation.discoverAssistants": "アシスタントを発見",
"navigation.discoverMcp": "MCP を発見",
"navigation.discoverModels": "モデルを発見",
"navigation.discoverProviders": "プロバイダーを発見",
"navigation.group": "グループ",
"navigation.groupChat": "グループチャット",
"navigation.home": "ホーム",
"navigation.image": "画像",
"navigation.knowledgeBase": "ナレッジベース",
"navigation.lobehub": "LobeHub",
"navigation.memory": "メモリ",
"navigation.memoryContexts": "メモリ - コンテキスト",
"navigation.memoryExperiences": "メモリ - 経験",
"navigation.memoryIdentities": "メモリ - アイデンティティ",
"navigation.memoryPreferences": "メモリ - 設定",
"navigation.onboarding": "オンボーディング",
"navigation.page": "ページ",
"navigation.pages": "ページ",
"navigation.provider": "プロバイダー",
"navigation.recentView": "最近の閲覧",
"navigation.resources": "リソース",
"navigation.settings": "設定",
"notification.finishChatGeneration": "AI メッセージの生成が完了しました",
"proxy.auth": "認証が必要",
"proxy.authDesc": "プロキシサーバーがユーザー名とパスワードを必要とする場合",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "채팅",
"navigation.discover": "발견",
"navigation.discoverAssistants": "어시스턴트 발견",
"navigation.discoverMcp": "MCP 발견",
"navigation.discoverModels": "모델 발견",
"navigation.discoverProviders": "프로바이더 발견",
"navigation.group": "그룹",
"navigation.groupChat": "그룹 채팅",
"navigation.home": "홈",
"navigation.image": "이미지",
"navigation.knowledgeBase": "지식 베이스",
"navigation.lobehub": "LobeHub",
"navigation.memory": "메모리",
"navigation.memoryContexts": "메모리 - 컨텍스트",
"navigation.memoryExperiences": "메모리 - 경험",
"navigation.memoryIdentities": "메모리 - 신원",
"navigation.memoryPreferences": "메모리 - 선호도",
"navigation.onboarding": "온보딩",
"navigation.page": "페이지",
"navigation.pages": "페이지",
"navigation.provider": "프로바이더",
"navigation.recentView": "최근 조회",
"navigation.resources": "리소스",
"navigation.settings": "설정",
"notification.finishChatGeneration": "AI 메시지 생성이 완료되었습니다",
"proxy.auth": "인증 필요",
"proxy.authDesc": "프록시 서버가 사용자 이름과 비밀번호를 요구하는 경우",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "Chat",
"navigation.discover": "Ontdekken",
"navigation.discoverAssistants": "Assistenten Ontdekken",
"navigation.discoverMcp": "MCP Ontdekken",
"navigation.discoverModels": "Modellen Ontdekken",
"navigation.discoverProviders": "Providers Ontdekken",
"navigation.group": "Groep",
"navigation.groupChat": "Groepschat",
"navigation.home": "Startpagina",
"navigation.image": "Afbeelding",
"navigation.knowledgeBase": "Kennisbank",
"navigation.lobehub": "LobeHub",
"navigation.memory": "Geheugen",
"navigation.memoryContexts": "Geheugen - Contexten",
"navigation.memoryExperiences": "Geheugen - Ervaringen",
"navigation.memoryIdentities": "Geheugen - Identiteiten",
"navigation.memoryPreferences": "Geheugen - Voorkeuren",
"navigation.onboarding": "Onboarding",
"navigation.page": "Pagina",
"navigation.pages": "Pagina's",
"navigation.provider": "Provider",
"navigation.recentView": "Recente weergaven",
"navigation.resources": "Bronnen",
"navigation.settings": "Instellingen",
"notification.finishChatGeneration": "AI-berichtgeneratie voltooid",
"proxy.auth": "Authenticatie vereist",
"proxy.authDesc": "Indien de proxyserver een gebruikersnaam en wachtwoord vereist",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "Czat",
"navigation.discover": "Odkryj",
"navigation.discoverAssistants": "Odkryj Asystentów",
"navigation.discoverMcp": "Odkryj MCP",
"navigation.discoverModels": "Odkryj Modele",
"navigation.discoverProviders": "Odkryj Dostawców",
"navigation.group": "Grupa",
"navigation.groupChat": "Czat Grupowy",
"navigation.home": "Strona główna",
"navigation.image": "Obraz",
"navigation.knowledgeBase": "Baza Wiedzy",
"navigation.lobehub": "LobeHub",
"navigation.memory": "Pamięć",
"navigation.memoryContexts": "Pamięć - Konteksty",
"navigation.memoryExperiences": "Pamięć - Doświadczenia",
"navigation.memoryIdentities": "Pamięć - Tożsamości",
"navigation.memoryPreferences": "Pamięć - Preferencje",
"navigation.onboarding": "Wprowadzenie",
"navigation.page": "Strona",
"navigation.pages": "Strony",
"navigation.provider": "Dostawca",
"navigation.recentView": "Ostatnie wyświetlenia",
"navigation.resources": "Zasoby",
"navigation.settings": "Ustawienia",
"notification.finishChatGeneration": "Generowanie wiadomości AI zakończone",
"proxy.auth": "Wymagana autoryzacja",
"proxy.authDesc": "Jeśli serwer proxy wymaga nazwy użytkownika i hasła",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "Chat",
"navigation.discover": "Descobrir",
"navigation.discoverAssistants": "Descobrir Assistentes",
"navigation.discoverMcp": "Descobrir MCP",
"navigation.discoverModels": "Descobrir Modelos",
"navigation.discoverProviders": "Descobrir Provedores",
"navigation.group": "Grupo",
"navigation.groupChat": "Chat em Grupo",
"navigation.home": "Início",
"navigation.image": "Imagem",
"navigation.knowledgeBase": "Base de Conhecimento",
"navigation.lobehub": "LobeHub",
"navigation.memory": "Memória",
"navigation.memoryContexts": "Memória - Contextos",
"navigation.memoryExperiences": "Memória - Experiências",
"navigation.memoryIdentities": "Memória - Identidades",
"navigation.memoryPreferences": "Memória - Preferências",
"navigation.onboarding": "Integração",
"navigation.page": "Página",
"navigation.pages": "Páginas",
"navigation.provider": "Provedor",
"navigation.recentView": "Visualizações recentes",
"navigation.resources": "Recursos",
"navigation.settings": "Configurações",
"notification.finishChatGeneration": "Geração de mensagem pela IA concluída",
"proxy.auth": "Autenticação necessária",
"proxy.authDesc": "Se o servidor proxy exigir nome de usuário e senha",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "Чат",
"navigation.discover": "Открыть",
"navigation.discoverAssistants": "Открыть Ассистентов",
"navigation.discoverMcp": "Открыть MCP",
"navigation.discoverModels": "Открыть Модели",
"navigation.discoverProviders": "Открыть Провайдеров",
"navigation.group": "Группа",
"navigation.groupChat": "Групповой Чат",
"navigation.home": "Главная",
"navigation.image": "Изображение",
"navigation.knowledgeBase": "База Знаний",
"navigation.lobehub": "LobeHub",
"navigation.memory": "Память",
"navigation.memoryContexts": "Память - Контексты",
"navigation.memoryExperiences": "Память - Опыт",
"navigation.memoryIdentities": "Память - Идентичности",
"navigation.memoryPreferences": "Память - Предпочтения",
"navigation.onboarding": "Введение",
"navigation.page": "Страница",
"navigation.pages": "Страницы",
"navigation.provider": "Провайдер",
"navigation.recentView": "Недавние просмотры",
"navigation.resources": "Ресурсы",
"navigation.settings": "Настройки",
"notification.finishChatGeneration": "Генерация сообщения ИИ завершена",
"proxy.auth": "Требуется аутентификация",
"proxy.authDesc": "Если прокси-сервер требует имя пользователя и пароль",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "Sohbet",
"navigation.discover": "Keşfet",
"navigation.discoverAssistants": "Asistanları Keşfet",
"navigation.discoverMcp": "MCP'yi Keşfet",
"navigation.discoverModels": "Modelleri Keşfet",
"navigation.discoverProviders": "Sağlayıcıları Keşfet",
"navigation.group": "Grup",
"navigation.groupChat": "Grup Sohbeti",
"navigation.home": "Ana Sayfa",
"navigation.image": "Görsel",
"navigation.knowledgeBase": "Bilgi Bankası",
"navigation.lobehub": "LobeHub",
"navigation.memory": "Bellek",
"navigation.memoryContexts": "Bellek - Bağlamlar",
"navigation.memoryExperiences": "Bellek - Deneyimler",
"navigation.memoryIdentities": "Bellek - Kimlikler",
"navigation.memoryPreferences": "Bellek - Tercihler",
"navigation.onboarding": "Hoş Geldiniz",
"navigation.page": "Sayfa",
"navigation.pages": "Sayfalar",
"navigation.provider": "Sağlayıcı",
"navigation.recentView": "Son görüntülemeler",
"navigation.resources": "Kaynaklar",
"navigation.settings": "Ayarlar",
"notification.finishChatGeneration": "Yapay zeka mesaj oluşturma tamamlandı",
"proxy.auth": "Kimlik Doğrulama Gerekli",
"proxy.authDesc": "Proxy sunucusu kullanıcı adı ve şifre gerektiriyorsa",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "Trò chuyện",
"navigation.discover": "Khám phá",
"navigation.discoverAssistants": "Khám phá Trợ lý",
"navigation.discoverMcp": "Khám phá MCP",
"navigation.discoverModels": "Khám phá Mô hình",
"navigation.discoverProviders": "Khám phá Nhà cung cấp",
"navigation.group": "Nhóm",
"navigation.groupChat": "Trò chuyện Nhóm",
"navigation.home": "Trang chủ",
"navigation.image": "Hình ảnh",
"navigation.knowledgeBase": "Cơ sở Tri thức",
"navigation.lobehub": "LobeHub",
"navigation.memory": "Bộ nhớ",
"navigation.memoryContexts": "Bộ nhớ - Ngữ cảnh",
"navigation.memoryExperiences": "Bộ nhớ - Trải nghiệm",
"navigation.memoryIdentities": "Bộ nhớ - Danh tính",
"navigation.memoryPreferences": "Bộ nhớ - Tùy chọn",
"navigation.onboarding": "Giới thiệu",
"navigation.page": "Trang",
"navigation.pages": "Trang",
"navigation.provider": "Nhà cung cấp",
"navigation.recentView": "Đã xem gần đây",
"navigation.resources": "Tài nguyên",
"navigation.settings": "Cài đặt",
"notification.finishChatGeneration": "Đã hoàn tất tạo tin nhắn AI",
"proxy.auth": "Yêu cầu xác thực",
"proxy.authDesc": "Nếu máy chủ proxy yêu cầu tên người dùng và mật khẩu",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "对话",
"navigation.discover": "发现",
"navigation.discoverAssistants": "发现助理",
"navigation.discoverMcp": "发现 MCP",
"navigation.discoverModels": "发现模型",
"navigation.discoverProviders": "发现模型服务商",
"navigation.group": "群组",
"navigation.groupChat": "群组对话",
"navigation.home": "首页",
"navigation.image": "图像",
"navigation.knowledgeBase": "知识库",
"navigation.lobehub": "LobeHub",
"navigation.memory": "记忆",
"navigation.memoryContexts": "记忆 - 上下文",
"navigation.memoryExperiences": "记忆 - 经历",
"navigation.memoryIdentities": "记忆 - 身份",
"navigation.memoryPreferences": "记忆 - 偏好",
"navigation.onboarding": "引导",
"navigation.page": "文稿",
"navigation.pages": "文稿",
"navigation.provider": "模型服务商",
"navigation.recentView": "最近访问",
"navigation.resources": "资源",
"navigation.settings": "设置",
"notification.finishChatGeneration": "AI 消息已生成完毕",
"proxy.auth": "需要认证",
"proxy.authDesc": "如果代理服务器需要用户名和密码",

View File

@@ -1,4 +1,28 @@
{
"navigation.chat": "對話",
"navigation.discover": "發現",
"navigation.discoverAssistants": "發現助理",
"navigation.discoverMcp": "發現 MCP",
"navigation.discoverModels": "發現模型",
"navigation.discoverProviders": "發現模型服務商",
"navigation.group": "群組",
"navigation.groupChat": "群組對話",
"navigation.home": "首頁",
"navigation.image": "圖像",
"navigation.knowledgeBase": "知識庫",
"navigation.lobehub": "LobeHub",
"navigation.memory": "記憶",
"navigation.memoryContexts": "記憶 - 上下文",
"navigation.memoryExperiences": "記憶 - 經歷",
"navigation.memoryIdentities": "記憶 - 身份",
"navigation.memoryPreferences": "記憶 - 偏好",
"navigation.onboarding": "引導",
"navigation.page": "文稿",
"navigation.pages": "文稿",
"navigation.provider": "模型服務商",
"navigation.recentView": "最近訪問",
"navigation.resources": "資源",
"navigation.settings": "設定",
"notification.finishChatGeneration": "AI 訊息已生成完畢",
"proxy.auth": "需要認證",
"proxy.authDesc": "如果代理伺服器需要使用者名稱和密碼",

View File

@@ -1,4 +1,16 @@
export interface NavigationBroadcastEvents {
/**
* Ask renderer to go back in navigation history.
* Triggered from the main process menu.
*/
historyGoBack: () => void;
/**
* Ask renderer to go forward in navigation history.
* Triggered from the main process menu.
*/
historyGoForward: () => void;
/**
* Ask renderer to navigate within the SPA without reloading the whole page.
*/

View File

@@ -1,10 +1,20 @@
import { BRANDING_NAME } from '@lobechat/business-const';
import { memo, useEffect } from 'react';
import { isDesktop } from '@/const/version';
import { useElectronStore } from '@/store/electron';
const PageTitle = memo<{ title: string }>(({ title }) => {
const setCurrentPageTitle = useElectronStore((s) => s.setCurrentPageTitle);
useEffect(() => {
document.title = title ? `${title} · ${BRANDING_NAME}` : BRANDING_NAME;
}, [title]);
// Sync title to electron store for navigation history
if (isDesktop) {
setCurrentPageTitle(title);
}
}, [title, setCurrentPageTitle]);
return null;
});

View File

@@ -0,0 +1,137 @@
'use client';
import { Flexbox, Icon } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useElectronStore } from '@/store/electron';
import type { HistoryEntry } from '@/store/electron/actions/navigationHistory';
import { getRouteIcon } from '../helpers/routeMetadata';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
overflow-y: auto;
width: 260px;
max-height: 320px;
padding: 4px;
`,
empty: css`
padding-block: 16px;
padding-inline: 12px;
font-size: 12px;
color: ${cssVar.colorTextTertiary};
text-align: center;
`,
icon: css`
flex-shrink: 0;
color: ${cssVar.colorTextSecondary};
`,
item: css`
cursor: pointer;
overflow: hidden;
padding-block: 6px;
padding-inline: 8px;
border-radius: ${cssVar.borderRadiusSM};
transition: background-color 0.15s ${cssVar.motionEaseInOut};
&:hover {
background-color: ${cssVar.colorFillSecondary};
}
`,
itemActive: css`
background-color: ${cssVar.colorFillTertiary};
`,
itemTitle: css`
overflow: hidden;
flex: 1;
font-size: 12px;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
`,
title: css`
padding-block: 4px;
padding-inline: 8px;
font-size: 11px;
font-weight: 500;
color: ${cssVar.colorTextTertiary};
text-transform: uppercase;
letter-spacing: 0.5px;
`,
}));
interface RecentlyViewedProps {
onClose: () => void;
}
const RecentlyViewed = memo<RecentlyViewedProps>(({ onClose }) => {
const { t } = useTranslation('electron');
const navigate = useNavigate();
const historyEntries = useElectronStore((s) => s.historyEntries);
const historyCurrentIndex = useElectronStore((s) => s.historyCurrentIndex);
const setIsNavigatingHistory = useElectronStore((s) => s.setIsNavigatingHistory);
const handleClick = (entry: HistoryEntry, index: number) => {
// Set flag to prevent adding duplicate history entry
setIsNavigatingHistory(true);
// Update the current index in store
useElectronStore.setState({ historyCurrentIndex: index });
// Navigate to the selected entry
navigate(entry.url);
// Close the popover
onClose();
};
// Show entries in reverse order (most recent first), excluding current
const recentEntries = [...historyEntries]
.map((entry, index) => ({ entry, originalIndex: index }))
.reverse();
if (recentEntries.length === 0) {
return (
<div className={styles.container}>
<div className={styles.empty}>{t('navigation.recentView')}</div>
</div>
);
}
return (
<Flexbox className={styles.container}>
<div className={styles.title}>{t('navigation.recentView')}</div>
{recentEntries.map(({ entry, originalIndex }) => {
const isActive = originalIndex === historyCurrentIndex;
const RouteIcon = getRouteIcon(entry.url);
return (
<Flexbox
align="center"
className={`${styles.item} ${isActive ? styles.itemActive : ''}`}
gap={8}
horizontal
key={`${entry.url}-${originalIndex}`}
onClick={() => handleClick(entry, originalIndex)}
>
{RouteIcon && <Icon className={styles.icon} icon={RouteIcon} size="small" />}
<span className={styles.itemTitle}>{entry.title}</span>
</Flexbox>
);
})}
</Flexbox>
);
});
RecentlyViewed.displayName = 'RecentlyViewed';
export default RecentlyViewed;

View File

@@ -0,0 +1,86 @@
'use client';
import { ActionIcon, Flexbox, Popover, Tooltip } from '@lobehub/ui';
import { ArrowLeft, ArrowRight, Clock } from 'lucide-react';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { electronStylish } from '@/styles/electron';
import { isMacOS } from '@/utils/platform';
import { useNavigationHistory } from '../hooks/useNavigationHistory';
import RecentlyViewed from './RecentlyViewed';
const isMac = isMacOS();
const useNavPanelWidth = () => {
return useGlobalStore(systemStatusSelectors.leftPanelWidth);
};
const NavigationBar = memo(() => {
const { t } = useTranslation('electron');
const { canGoBack, canGoForward, goBack, goForward } = useNavigationHistory();
const [historyOpen, setHistoryOpen] = useState(false);
// Use ResizeObserver for real-time width updates during resize
const leftPanelWidth = useNavPanelWidth();
// Toggle history popover
const toggleHistoryOpen = useCallback(() => {
setHistoryOpen((prev) => !prev);
}, []);
// Listen for keyboard shortcut ⌘Y / Ctrl+Y
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const isCmdOrCtrl = isMac ? event.metaKey : event.ctrlKey;
if (isCmdOrCtrl && event.key.toLowerCase() === 'y') {
event.preventDefault();
toggleHistoryOpen();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [toggleHistoryOpen]);
// Tooltip content for the clock button
const tooltipContent = t('navigation.recentView');
return (
<Flexbox
align="center"
className={electronStylish.nodrag}
data-width={leftPanelWidth}
horizontal
justify="end"
style={{ width: `${leftPanelWidth - 12}px` }}
>
<Flexbox align="center" gap={2} horizontal>
<ActionIcon disabled={!canGoBack} icon={ArrowLeft} onClick={goBack} size="small" />
<ActionIcon disabled={!canGoForward} icon={ArrowRight} onClick={goForward} size="small" />
<Popover
content={<RecentlyViewed onClose={() => setHistoryOpen(false)} />}
onOpenChange={setHistoryOpen}
open={historyOpen}
placement="bottomLeft"
styles={{ content: { padding: 0 } }}
trigger="click"
>
<div>
<Tooltip open={historyOpen ? false : undefined} title={tooltipContent}>
<ActionIcon icon={Clock} size="small" />
</Tooltip>
</div>
</Popover>
</Flexbox>
</Flexbox>
);
});
NavigationBar.displayName = 'NavigationBar';
export default NavigationBar;

View File

@@ -0,0 +1,214 @@
/**
* Route metadata mapping for navigation history
* Provides title and icon information based on route path
*/
import {
Brain,
Circle,
Compass,
Database,
FileText,
Home,
Image,
type LucideIcon,
MessageSquare,
Rocket,
Settings,
Users,
} from 'lucide-react';
export interface RouteMetadata {
icon?: LucideIcon;
/** i18n key for the title (namespace: electron) */
titleKey: string;
/** Whether this route should use document.title for more specific title */
useDynamicTitle?: boolean;
}
interface RoutePattern {
icon?: LucideIcon;
test: (pathname: string) => boolean;
/** i18n key for the title (namespace: electron) */
titleKey: string;
/** Whether this route should use document.title for more specific title */
useDynamicTitle?: boolean;
}
/**
* Route patterns ordered by specificity (most specific first)
*/
const routePatterns: RoutePattern[] = [
// Settings routes
{
icon: Settings,
test: (p) => p.startsWith('/settings/provider'),
titleKey: 'navigation.provider',
},
{
icon: Settings,
test: (p) => p.startsWith('/settings'),
titleKey: 'navigation.settings',
},
// Agent/Chat routes - use dynamic title for specific chat names
{
icon: MessageSquare,
test: (p) => p.startsWith('/agent/'),
titleKey: 'navigation.chat',
useDynamicTitle: true,
},
{
icon: MessageSquare,
test: (p) => p === '/agent',
titleKey: 'navigation.chat',
},
// Group routes - use dynamic title for specific group names
{
icon: Users,
test: (p) => p.startsWith('/group/'),
titleKey: 'navigation.groupChat',
useDynamicTitle: true,
},
{
icon: Users,
test: (p) => p === '/group',
titleKey: 'navigation.group',
},
// Community/Discover routes
{
icon: Compass,
test: (p) => p.startsWith('/community/assistant'),
titleKey: 'navigation.discoverAssistants',
},
{
icon: Compass,
test: (p) => p.startsWith('/community/model'),
titleKey: 'navigation.discoverModels',
},
{
icon: Compass,
test: (p) => p.startsWith('/community/provider'),
titleKey: 'navigation.discoverProviders',
},
{
icon: Compass,
test: (p) => p.startsWith('/community/mcp'),
titleKey: 'navigation.discoverMcp',
},
{
icon: Compass,
test: (p) => p.startsWith('/community'),
titleKey: 'navigation.discover',
},
// Resource/Knowledge routes
{
icon: Database,
test: (p) => p.startsWith('/resource/library'),
titleKey: 'navigation.knowledgeBase',
},
{
icon: Database,
test: (p) => p.startsWith('/resource'),
titleKey: 'navigation.resources',
},
// Memory routes
{
icon: Brain,
test: (p) => p.startsWith('/memory/identities'),
titleKey: 'navigation.memoryIdentities',
},
{
icon: Brain,
test: (p) => p.startsWith('/memory/contexts'),
titleKey: 'navigation.memoryContexts',
},
{
icon: Brain,
test: (p) => p.startsWith('/memory/preferences'),
titleKey: 'navigation.memoryPreferences',
},
{
icon: Brain,
test: (p) => p.startsWith('/memory/experiences'),
titleKey: 'navigation.memoryExperiences',
},
{
icon: Brain,
test: (p) => p.startsWith('/memory'),
titleKey: 'navigation.memory',
},
// Image routes
{
icon: Image,
test: (p) => p.startsWith('/image'),
titleKey: 'navigation.image',
},
// Page routes - use dynamic title for specific page names
{
icon: FileText,
test: (p) => p.startsWith('/page/'),
titleKey: 'navigation.page',
useDynamicTitle: true,
},
{
icon: FileText,
test: (p) => p === '/page',
titleKey: 'navigation.pages',
},
// Onboarding
{
icon: Rocket,
test: (p) => p.startsWith('/desktop-onboarding') || p.startsWith('/onboarding'),
titleKey: 'navigation.onboarding',
},
// Home (default)
{
icon: Home,
test: (p) => p === '/' || p === '',
titleKey: 'navigation.home',
},
];
/**
* Get route metadata based on pathname
* @param pathname - The current route pathname
* @returns Route metadata with titleKey, icon, and useDynamicTitle flag
*/
export const getRouteMetadata = (pathname: string): RouteMetadata => {
// Find the first matching pattern
for (const pattern of routePatterns) {
if (pattern.test(pathname)) {
return {
icon: pattern.icon,
titleKey: pattern.titleKey,
useDynamicTitle: pattern.useDynamicTitle,
};
}
}
// Default fallback
return {
icon: Circle,
titleKey: 'navigation.lobehub',
};
};
/**
* Get route icon based on pathname or URL
* @param url - The route URL (may include query string)
* @returns LucideIcon component or undefined
*/
export const getRouteIcon = (url: string): LucideIcon | undefined => {
// Extract pathname from URL
const pathname = url.split('?')[0];
const metadata = getRouteMetadata(pathname);
return metadata.icon;
};

View File

@@ -0,0 +1,152 @@
'use client';
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
import { useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { useElectronStore } from '@/store/electron';
import { getRouteMetadata } from '../helpers/routeMetadata';
/**
* Hook to manage navigation history in Electron desktop app
* Provides browser-like back/forward functionality
*/
export const useNavigationHistory = () => {
const { t } = useTranslation('electron');
const navigate = useNavigate();
const location = useLocation();
// Get store state and actions
const isNavigatingHistory = useElectronStore((s) => s.isNavigatingHistory);
const historyCurrentIndex = useElectronStore((s) => s.historyCurrentIndex);
const historyEntries = useElectronStore((s) => s.historyEntries);
const currentPageTitle = useElectronStore((s) => s.currentPageTitle);
const pushHistory = useElectronStore((s) => s.pushHistory);
const replaceHistory = useElectronStore((s) => s.replaceHistory);
const setIsNavigatingHistory = useElectronStore((s) => s.setIsNavigatingHistory);
const storeGoBack = useElectronStore((s) => s.goBack);
const storeGoForward = useElectronStore((s) => s.goForward);
const canGoBackFn = useElectronStore((s) => s.canGoBack);
const canGoForwardFn = useElectronStore((s) => s.canGoForward);
const getCurrentEntry = useElectronStore((s) => s.getCurrentEntry);
// Track previous location to avoid duplicate entries
const prevLocationRef = useRef<string | null>(null);
// Calculate can go back/forward
const canGoBack = historyCurrentIndex > 0;
const canGoForward = historyCurrentIndex < historyEntries.length - 1;
/**
* Go back in history
*/
const goBack = useCallback(() => {
if (!canGoBackFn()) return;
const targetEntry = storeGoBack();
if (targetEntry) {
navigate(targetEntry.url);
}
}, [canGoBackFn, storeGoBack, navigate]);
/**
* Go forward in history
*/
const goForward = useCallback(() => {
if (!canGoForwardFn()) return;
const targetEntry = storeGoForward();
if (targetEntry) {
navigate(targetEntry.url);
}
}, [canGoForwardFn, storeGoForward, navigate]);
// Listen to route changes and push history
useEffect(() => {
const currentUrl = location.pathname + location.search;
// Skip if this is a back/forward navigation
if (isNavigatingHistory) {
setIsNavigatingHistory(false);
prevLocationRef.current = currentUrl;
return;
}
// Skip if same as previous location
if (prevLocationRef.current === currentUrl) {
return;
}
// Skip if same as current entry
const currentEntry = getCurrentEntry();
if (currentEntry?.url === currentUrl) {
prevLocationRef.current = currentUrl;
return;
}
// Get metadata for this route
const metadata = getRouteMetadata(location.pathname);
const presetTitle = t(metadata.titleKey as any) as string;
// Push history with preset title (will be updated by PageTitle if useDynamicTitle)
pushHistory({
metadata: {
timestamp: Date.now(),
},
title: presetTitle,
url: currentUrl,
});
prevLocationRef.current = currentUrl;
}, [
location.pathname,
location.search,
isNavigatingHistory,
setIsNavigatingHistory,
getCurrentEntry,
pushHistory,
t,
]);
// Update current history entry title when PageTitle component updates
useEffect(() => {
if (!currentPageTitle) return;
const currentEntry = getCurrentEntry();
if (!currentEntry) return;
// Check if current route supports dynamic title
const metadata = getRouteMetadata(location.pathname);
if (!metadata.useDynamicTitle) return;
// Skip if title is already the same
if (currentEntry.title === currentPageTitle) return;
// Update the current history entry with the dynamic title
replaceHistory({
...currentEntry,
title: currentPageTitle,
});
}, [currentPageTitle, getCurrentEntry, replaceHistory, location.pathname]);
// Listen to broadcast events from main process (Electron menu)
useWatchBroadcast('historyGoBack', () => {
goBack();
});
useWatchBroadcast('historyGoForward', () => {
goForward();
});
return {
canGoBack,
canGoForward,
currentEntry: getCurrentEntry(),
goBack,
goForward,
historyEntries,
historyIndex: historyCurrentIndex,
};
};

View File

@@ -1,12 +1,13 @@
import { Flexbox } from '@lobehub/ui';
import { Divider } from 'antd';
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { useElectronStore } from '@/store/electron';
import { electronStylish } from '@/styles/electron';
import { isMacOS } from '@/utils/platform';
import Connection from './Connection';
import NavigationBar from './NavigationBar';
import { UpdateModal } from './UpdateModal';
import { UpdateNotification } from './UpdateNotification';
import WinControl from './WinControl';
@@ -25,6 +26,15 @@ const TitleBar = memo(() => {
useWatchThemeUpdate();
const showWinControl = isAppStateInit && !isMac;
const padding = useMemo(() => {
if (showWinControl) {
return '0 12px 0 0';
}
return '0 12px';
}, [showWinControl, isMac]);
return (
<Flexbox
align={'center'}
@@ -32,12 +42,10 @@ const TitleBar = memo(() => {
height={TITLE_BAR_HEIGHT}
horizontal
justify={'space-between'}
paddingInline={showWinControl ? '12px 0' : 12}
style={{ minHeight: TITLE_BAR_HEIGHT }}
style={{ minHeight: TITLE_BAR_HEIGHT, padding }}
width={'100%'}
>
<div />
<div>{/* TODO */}</div>
<NavigationBar />
<Flexbox align={'center'} gap={4} horizontal>
<Flexbox className={electronStylish.nodrag} gap={8} horizontal>

View File

@@ -2,7 +2,8 @@ import { Flexbox, type FlexboxProps, TooltipGroup } from '@lobehub/ui';
import { type CSSProperties, type ReactNode, memo } from 'react';
import ToggleLeftPanelButton from '@/features/NavPanel/ToggleLeftPanelButton';
import { useNavPanel } from '@/features/NavPanel/hooks/useNavPanel';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
export interface NavHeaderProps extends Omit<FlexboxProps, 'children'> {
children?: ReactNode;
@@ -18,7 +19,8 @@ export interface NavHeaderProps extends Omit<FlexboxProps, 'children'> {
const NavHeader = memo<NavHeaderProps>(
({ showTogglePanelButton = true, style, children, left, right, styles, ...rest }) => {
const { expand } = useNavPanel();
const expand = useGlobalStore(systemStatusSelectors.showLeftPanel);
const noContent = !left && !right;
if (noContent && expand) return;

View File

@@ -0,0 +1,174 @@
'use client';
import { DraggablePanel } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { AnimatePresence, motion } from 'motion/react';
import { type ReactNode, memo, useMemo, useRef } from 'react';
import { USER_DROPDOWN_ICON_ID } from '@/app/[variants]/(main)/home/_layout/Header/components/User';
import { isDesktop } from '@/const/version';
import { TOGGLE_BUTTON_ID } from '@/features/NavPanel/ToggleLeftPanelButton';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { isMacOS } from '@/utils/platform';
import { useNavPanelSizeChangeHandler } from '../hooks/useNavPanel';
import { BACK_BUTTON_ID } from './BackButton';
const motionVariants = {
animate: { opacity: 1, x: 0 },
exit: {
opacity: 0,
x: '-20%',
},
initial: {
opacity: 0,
x: 0,
},
transition: {
duration: 0.4,
ease: [0.4, 0, 0.2, 1],
},
} as const;
const draggableStyles = createStaticStyles(({ css, cssVar }) => ({
content: css`
position: relative;
overflow: hidden;
display: flex;
height: 100%;
min-height: 100%;
max-height: 100%;
`,
inner: css`
position: relative;
inset: 0;
overflow: hidden;
flex: 1;
flex-direction: column;
min-width: 240px;
`,
panel: css`
user-select: none;
height: 100%;
color: ${cssVar.colorTextSecondary};
background: ${isDesktop && isMacOS() ? 'transparent' : cssVar.colorBgLayout};
* {
user-select: none;
}
#${TOGGLE_BUTTON_ID} {
width: 0 !important;
opacity: 0;
transition:
opacity,
width 0.2s ${cssVar.motionEaseOut};
}
#${USER_DROPDOWN_ICON_ID} {
width: 0 !important;
opacity: 0;
transition:
opacity,
width 0.2s ${cssVar.motionEaseOut};
}
#${BACK_BUTTON_ID} {
width: 0 !important;
opacity: 0;
transition: all 0.2s ${cssVar.motionEaseOut};
}
&:hover {
#${TOGGLE_BUTTON_ID} {
width: 32px !important;
opacity: 1;
}
#${USER_DROPDOWN_ICON_ID} {
width: 14px !important;
opacity: 1;
}
&:hover {
#${BACK_BUTTON_ID} {
width: 24px !important;
opacity: 1;
}
}
}
`,
}));
interface NavPanelDraggableProps {
activeContent: {
key: string;
node: ReactNode;
};
}
const classNames = {
content: draggableStyles.content,
};
export const NavPanelDraggable = memo<NavPanelDraggableProps>(({ activeContent }) => {
const [expand, togglePanel] = useGlobalStore((s) => [
systemStatusSelectors.showLeftPanel(s),
s.toggleLeftPanel,
]);
const handleSizeChange = useNavPanelSizeChangeHandler();
const defaultWidthRef = useRef(0);
if (defaultWidthRef.current === 0) {
defaultWidthRef.current = systemStatusSelectors.leftPanelWidth(useGlobalStore.getState());
}
const defaultSize = useMemo(
() => ({
height: '100%',
width: defaultWidthRef.current,
}),
[defaultWidthRef.current],
);
const styles = useMemo(
() => ({
background: isDesktop && isMacOS() ? 'transparent' : cssVar.colorBgLayout,
zIndex: 11,
}),
[isDesktop, isMacOS()],
);
return (
<DraggablePanel
className={draggableStyles.panel}
classNames={classNames}
defaultSize={defaultSize}
expand={expand}
expandable={false}
maxWidth={400}
minWidth={240}
onExpandChange={togglePanel}
onSizeDragging={handleSizeChange}
placement="left"
showBorder={false}
style={styles}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.div
animate={motionVariants.animate}
className={draggableStyles.inner}
exit={motionVariants.exit}
initial={motionVariants.initial}
key={activeContent.key}
transition={motionVariants.transition}
>
{activeContent.node}
</motion.div>
</AnimatePresence>
</DraggablePanel>
);
});

View File

@@ -2,49 +2,25 @@
import { type DraggablePanelProps } from '@lobehub/ui';
import isEqual from 'fast-deep-equal';
import { useCallback, useState } from 'react';
import { useTypeScriptHappyCallback } from '@/hooks/useTypeScriptHappyCallback';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
export const useNavPanel = () => {
const [leftPanelWidth, sessionExpandable, togglePanel, updatePreference] = useGlobalStore((s) => [
systemStatusSelectors.leftPanelWidth(s),
systemStatusSelectors.showLeftPanel(s),
s.toggleLeftPanel,
s.updateSystemStatus,
]);
const [tmpWidth, setWidth] = useState(leftPanelWidth);
if (tmpWidth !== leftPanelWidth) setWidth(leftPanelWidth);
const handleSizeChange: DraggablePanelProps['onSizeChange'] = useCallback(
(_: any, size: any) => {
const width = size?.width;
export const useNavPanelSizeChangeHandler = (onChange?: (width: number) => void) => {
const handleSizeChange: DraggablePanelProps['onSizeChange'] = useTypeScriptHappyCallback(
(_, size) => {
const width = typeof size?.width === 'string' ? Number.parseInt(size.width) : size?.width;
if (!width || width < 64) return;
const s = useGlobalStore.getState();
const leftPanelWidth = systemStatusSelectors.leftPanelWidth(s);
const updatePreference = s.updateSystemStatus;
if (isEqual(width, leftPanelWidth)) return;
setWidth(width);
onChange?.(width);
updatePreference({ leftPanelWidth: width });
},
[sessionExpandable, leftPanelWidth, updatePreference],
[],
);
const openPanel = useCallback(() => {
togglePanel(true);
}, [togglePanel]);
const closePanel = useCallback(() => {
togglePanel(false);
}, [togglePanel]);
return {
closePanel,
defaultWidth: tmpWidth,
expand: sessionExpandable,
handleSizeChange,
openPanel,
togglePanel,
width: leftPanelWidth,
};
return handleSizeChange;
};

View File

@@ -1,8 +1,5 @@
'use client';
import { DraggablePanel } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { AnimatePresence, motion } from 'motion/react';
import {
type PropsWithChildren,
type ReactNode,
@@ -11,14 +8,8 @@ import {
useSyncExternalStore,
} from 'react';
import { USER_DROPDOWN_ICON_ID } from '@/app/[variants]/(main)/home/_layout/Header/components/User';
import { isDesktop } from '@/const/version';
import { TOGGLE_BUTTON_ID } from '@/features/NavPanel/ToggleLeftPanelButton';
import { isMacOS } from '@/utils/platform';
import Sidebar from '../../app/[variants]/(main)/home/_layout/Sidebar';
import { BACK_BUTTON_ID } from './components/BackButton';
import { useNavPanel } from './hooks/useNavPanel';
import { NavPanelDraggable } from './components/NavPanelDraggable';
export const NAV_PANEL_RIGHT_DRAWER_ID = 'nav-panel-drawer';
@@ -41,82 +32,7 @@ const setNavPanelSnapshot = (snapshot: NavPanelSnapshot) => {
listeners.forEach((listener) => listener());
};
export const styles = createStaticStyles(({ css, cssVar }) => ({
content: css`
position: relative;
overflow: hidden;
display: flex;
height: 100%;
min-height: 100%;
max-height: 100%;
`,
inner: css`
position: relative;
inset: 0;
overflow: hidden;
flex: 1;
flex-direction: column;
min-width: 240px;
`,
panel: css`
user-select: none;
height: 100%;
color: ${cssVar.colorTextSecondary};
background: ${isDesktop && isMacOS() ? 'transparent' : cssVar.colorBgLayout};
* {
user-select: none;
}
#${TOGGLE_BUTTON_ID} {
width: 0 !important;
opacity: 0;
transition:
opacity,
width 0.2s ${cssVar.motionEaseOut};
}
#${USER_DROPDOWN_ICON_ID} {
width: 0 !important;
opacity: 0;
transition:
opacity,
width 0.2s ${cssVar.motionEaseOut};
}
#${BACK_BUTTON_ID} {
width: 0 !important;
opacity: 0;
transition: all 0.2s ${cssVar.motionEaseOut};
}
&:hover {
#${TOGGLE_BUTTON_ID} {
width: 32px !important;
opacity: 1;
}
#${USER_DROPDOWN_ICON_ID} {
width: 14px !important;
opacity: 1;
}
&:hover {
#${BACK_BUTTON_ID} {
width: 24px !important;
opacity: 1;
}
}
}
`,
}));
const NavPanel = memo(() => {
const { expand, handleSizeChange, width, togglePanel } = useNavPanel();
const panelContent = useSyncExternalStore(
subscribeNavPanel,
getNavPanelSnapshot,
@@ -128,47 +44,7 @@ const NavPanel = memo(() => {
return (
<>
<DraggablePanel
className={styles.panel}
classNames={{
content: styles.content,
}}
defaultSize={{ height: '100%', width }}
expand={expand}
expandable={false}
maxWidth={400}
minWidth={240}
onExpandChange={(expand) => togglePanel(expand)}
onSizeChange={handleSizeChange}
placement="left"
showBorder={false}
style={{
background: isDesktop && isMacOS() ? 'transparent' : cssVar.colorBgLayout,
zIndex: 11,
}}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.div
animate={{ opacity: 1, x: 0 }}
className={styles.inner}
exit={{
opacity: 0,
x: '-20%',
}}
initial={{
opacity: 0,
x: 0,
}}
key={activeContent.key}
transition={{
duration: 0.4,
ease: [0.4, 0, 0.2, 1],
}}
>
{activeContent.node}
</motion.div>
</AnimatePresence>
</DraggablePanel>
<NavPanelDraggable activeContent={activeContent} />
<div
id={NAV_PANEL_RIGHT_DRAWER_ID}
style={{

View File

@@ -0,0 +1,7 @@
import type { DependencyList } from 'react';
import { useCallback } from 'react';
export const useTypeScriptHappyCallback: <Args extends unknown[], R>(
fn: (...args: Args) => R,
deps: DependencyList,
) => (...args: Args) => R = useCallback;

View File

@@ -1,4 +1,28 @@
export default {
'navigation.chat': 'Chat',
'navigation.discover': 'Discover',
'navigation.discoverAssistants': 'Discover Assistants',
'navigation.discoverMcp': 'Discover MCP',
'navigation.discoverModels': 'Discover Models',
'navigation.discoverProviders': 'Discover Providers',
'navigation.group': 'Group',
'navigation.groupChat': 'Group Chat',
'navigation.home': 'Home',
'navigation.image': 'Image',
'navigation.knowledgeBase': 'Knowledge Base',
'navigation.lobehub': 'LobeHub',
'navigation.memory': 'Memory',
'navigation.memoryContexts': 'Memory - Contexts',
'navigation.memoryExperiences': 'Memory - Experiences',
'navigation.memoryIdentities': 'Memory - Identities',
'navigation.memoryPreferences': 'Memory - Preferences',
'navigation.onboarding': 'Onboarding',
'navigation.page': 'Page',
'navigation.pages': 'Pages',
'navigation.provider': 'Provider',
'navigation.recentView': 'Recent pages',
'navigation.resources': 'Resources',
'navigation.settings': 'Settings',
'notification.finishChatGeneration': 'AI message generation completed',
'proxy.auth': 'Authentication Required',
'proxy.authDesc': 'If the proxy server requires a username and password',

View File

@@ -0,0 +1,247 @@
import { type StateCreator } from 'zustand/vanilla';
import type { ElectronStore } from '../store';
// ======== Types ======== //
export interface HistoryEntry {
icon?: string;
metadata?: {
[key: string]: any;
sessionId?: string;
timestamp: number;
};
title: string;
url: string;
}
export interface NavigationHistoryState {
/**
* Current page title from PageTitle component
* Used to get dynamic titles without setTimeout hack
*/
currentPageTitle: string;
/**
* Current position in history (-1 means empty)
*/
historyCurrentIndex: number;
/**
* History entries list
*/
historyEntries: HistoryEntry[];
/**
* Flag to indicate if currently navigating via back/forward
* Used to prevent adding duplicate history entries
*/
isNavigatingHistory: boolean;
}
// ======== Action Interface ======== //
export interface NavigationHistoryAction {
/**
* Check if can go back in history
*/
canGoBack: () => boolean;
/**
* Check if can go forward in history
*/
canGoForward: () => boolean;
/**
* Get current history entry
*/
getCurrentEntry: () => HistoryEntry | null;
/**
* Navigate back in history
* @returns The target entry or null if cannot go back
*/
goBack: () => HistoryEntry | null;
/**
* Navigate forward in history
* @returns The target entry or null if cannot go forward
*/
goForward: () => HistoryEntry | null;
/**
* Push a new entry to history (for normal navigation)
* Truncates any forward history if not at the end
*/
pushHistory: (
entry: Omit<HistoryEntry, 'metadata'> & { metadata?: Partial<HistoryEntry['metadata']> },
) => void;
/**
* Replace current entry in history (for replace navigation)
*/
replaceHistory: (
entry: Omit<HistoryEntry, 'metadata'> & { metadata?: Partial<HistoryEntry['metadata']> },
) => void;
/**
* Set current page title (called by PageTitle component)
*/
setCurrentPageTitle: (title: string) => void;
/**
* Set the navigating history flag
*/
setIsNavigatingHistory: (value: boolean) => void;
}
// ======== Initial State ======== //
export const navigationHistoryInitialState: NavigationHistoryState = {
currentPageTitle: '',
historyCurrentIndex: -1,
historyEntries: [],
isNavigatingHistory: false,
};
// ======== Action Implementation ======== //
export const createNavigationHistorySlice: StateCreator<
ElectronStore,
[['zustand/devtools', never]],
[],
NavigationHistoryAction
> = (set, get) => ({
canGoBack: () => {
const { historyCurrentIndex } = get();
return historyCurrentIndex > 0;
},
canGoForward: () => {
const { historyCurrentIndex, historyEntries } = get();
return historyCurrentIndex < historyEntries.length - 1;
},
getCurrentEntry: () => {
const { historyCurrentIndex, historyEntries } = get();
if (historyCurrentIndex < 0 || historyCurrentIndex >= historyEntries.length) {
return null;
}
return historyEntries[historyCurrentIndex];
},
goBack: () => {
const { historyCurrentIndex, historyEntries } = get();
if (historyCurrentIndex <= 0) {
return null;
}
const newIndex = historyCurrentIndex - 1;
const targetEntry = historyEntries[newIndex];
set(
{
historyCurrentIndex: newIndex,
isNavigatingHistory: true,
},
false,
'goBack',
);
return targetEntry;
},
goForward: () => {
const { historyCurrentIndex, historyEntries } = get();
if (historyCurrentIndex >= historyEntries.length - 1) {
return null;
}
const newIndex = historyCurrentIndex + 1;
const targetEntry = historyEntries[newIndex];
set(
{
historyCurrentIndex: newIndex,
isNavigatingHistory: true,
},
false,
'goForward',
);
return targetEntry;
},
pushHistory: (entry) => {
const { historyCurrentIndex, historyEntries } = get();
// Create full entry with metadata
const fullEntry: HistoryEntry = {
icon: entry.icon,
metadata: {
timestamp: Date.now(),
...entry.metadata,
},
title: entry.title,
url: entry.url,
};
// If not at the end, truncate forward history
const newEntries =
historyCurrentIndex < historyEntries.length - 1
? historyEntries.slice(0, historyCurrentIndex + 1)
: [...historyEntries];
// Add new entry
newEntries.push(fullEntry);
set(
{
historyCurrentIndex: newEntries.length - 1,
historyEntries: newEntries,
},
false,
'pushHistory',
);
},
replaceHistory: (entry) => {
const { historyCurrentIndex, historyEntries } = get();
// If history is empty, just push
if (historyCurrentIndex < 0 || historyEntries.length === 0) {
get().pushHistory(entry);
return;
}
// Create full entry with metadata
const fullEntry: HistoryEntry = {
icon: entry.icon,
metadata: {
timestamp: Date.now(),
...entry.metadata,
},
title: entry.title,
url: entry.url,
};
// Replace current entry
const newEntries = [...historyEntries];
newEntries[historyCurrentIndex] = fullEntry;
set(
{
historyEntries: newEntries,
},
false,
'replaceHistory',
);
},
setCurrentPageTitle: (title) => {
set({ currentPageTitle: title }, false, 'setCurrentPageTitle');
},
setIsNavigatingHistory: (value) => {
set({ isNavigatingHistory: value }, false, 'setIsNavigatingHistory');
},
});

View File

@@ -4,6 +4,11 @@ import {
type NetworkProxySettings,
} from '@lobechat/electron-client-ipc';
import {
type NavigationHistoryState,
navigationHistoryInitialState,
} from './actions/navigationHistory';
export type RemoteServerError = 'CONFIG_ERROR' | 'AUTH_ERROR' | 'DISCONNECT_ERROR';
export const defaultProxySettings: NetworkProxySettings = {
@@ -15,7 +20,7 @@ export const defaultProxySettings: NetworkProxySettings = {
proxyType: 'http',
};
export interface ElectronState {
export interface ElectronState extends NavigationHistoryState {
appState: ElectronAppState;
dataSyncConfig: DataSyncConfig;
desktopHotkeys: Record<string, string>;
@@ -29,6 +34,7 @@ export interface ElectronState {
}
export const initialState: ElectronState = {
...navigationHistoryInitialState,
appState: {},
dataSyncConfig: { storageMode: 'cloud' },
desktopHotkeys: {},

View File

@@ -4,6 +4,10 @@ import { type StateCreator } from 'zustand/vanilla';
import { createDevtools } from '../middleware/createDevtools';
import { type ElectronAppAction, createElectronAppSlice } from './actions/app';
import {
type NavigationHistoryAction,
createNavigationHistorySlice,
} from './actions/navigationHistory';
import { type ElectronSettingsAction, settingsSlice } from './actions/settings';
import { type ElectronRemoteServerAction, remoteSyncSlice } from './actions/sync';
import { type ElectronState, initialState } from './initialState';
@@ -11,10 +15,12 @@ import { type ElectronState, initialState } from './initialState';
// =============== Aggregate createStoreFn ============ //
export interface ElectronStore
extends ElectronState,
extends
ElectronState,
ElectronRemoteServerAction,
ElectronAppAction,
ElectronSettingsAction {
ElectronSettingsAction,
NavigationHistoryAction {
/* empty */
}
@@ -25,6 +31,7 @@ const createStore: StateCreator<ElectronStore, [['zustand/devtools', never]]> =
...remoteSyncSlice(...parameters),
...createElectronAppSlice(...parameters),
...settingsSlice(...parameters),
...createNavigationHistorySlice(...parameters),
});
// =============== Implement useStore ============ //

View File

@@ -30,7 +30,10 @@ const modelSwitchPanelWidth = (s: GlobalState) => s.status.modelSwitchPanelWidth
const showChatHeader = (s: GlobalState) => !s.status.zenMode;
const inZenMode = (s: GlobalState) => s.status.zenMode;
const leftPanelWidth = (s: GlobalState) => s.status.leftPanelWidth;
const leftPanelWidth = (s: GlobalState): number => {
const width = s.status.leftPanelWidth;
return typeof width === 'string' ? Number.parseInt(width) : width;
};
const portalWidth = (s: GlobalState) => s.status.portalWidth || 400;
const filePanelWidth = (s: GlobalState) => s.status.filePanelWidth;
const imagePanelWidth = (s: GlobalState) => s.status.imagePanelWidth;