mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ feat(electron): refactor RecentlyViewed with Pinned + Recent architecture (#11774)
* ✨ feat(electron): refactor RecentlyViewed with Pinned + Recent architecture - Add Pinned section for user-pinned pages (persisted to localStorage) - Add Recent section with auto-deduplication and 20 items limit - Support dynamic title updates (e.g., conversation names instead of generic "Chat") - Add Pin/Unpin toggle on hover - Keep navigation history (back/forward) independent from recent pages Closes LOBE-4212 Closes LOBE-4230 * 📝 docs(linear): update issue management guidelines - Revise description for clarity on triggering conditions for Linear issues. - Add critical section on PR creation with Linear issues, emphasizing immediate comment requirements. - Update completion comment format to include structured summary and key changes. - Clarify workflow steps and correct examples for task completion and status updates. Signed-off-by: Innei <tukon479@gmail.com> * ✨ feat(electron): history stack - Introduce a new plugin system for RecentlyViewed, allowing dynamic resolution of page references. - Implement caching for display data, improving performance and user experience. - Refactor existing page handling to support various page types (agents, groups, etc.) with dedicated plugins. - Update Recent and Pinned pages management to utilize the new plugin system for better data integrity and retrieval. Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -7,6 +7,16 @@ description: "Linear issue management. MUST USE when: (1) user mentions LOBE-xxx
|
||||
|
||||
Before using Linear workflows, search for `linear` MCP tools. If not found, treat as not installed.
|
||||
|
||||
## ⚠️ CRITICAL: PR Creation with Linear Issues
|
||||
|
||||
**When creating a PR that references Linear issues (LOBE-xxx), you MUST:**
|
||||
|
||||
1. Create the PR with magic keywords (`Fixes LOBE-xxx`)
|
||||
2. **IMMEDIATELY after PR creation**, add completion comments to ALL referenced Linear issues
|
||||
3. Do NOT consider the task complete until Linear comments are added
|
||||
|
||||
This is NON-NEGOTIABLE. Skipping Linear comments is a workflow violation.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Retrieve issue details** before starting: `mcp__linear-server__get_issue`
|
||||
@@ -18,9 +28,25 @@ Before using Linear workflows, search for `linear` MCP tools. If not found, trea
|
||||
|
||||
When creating issues with `mcp__linear-server__create_issue`, **MUST add the `claude code` label**.
|
||||
|
||||
## Completion Comment (REQUIRED)
|
||||
## Completion Comment Format
|
||||
|
||||
Every completed issue MUST have a comment summarizing work done. This is critical for:
|
||||
Every completed issue MUST have a comment summarizing work done:
|
||||
|
||||
```markdown
|
||||
## Changes Summary
|
||||
|
||||
- **Feature**: Brief description of what was implemented
|
||||
- **Files Changed**: List key files modified
|
||||
- **PR**: #xxx or PR URL
|
||||
|
||||
### Key Changes
|
||||
|
||||
- Change 1
|
||||
- Change 2
|
||||
- ...
|
||||
```
|
||||
|
||||
This is critical for:
|
||||
|
||||
- Team visibility
|
||||
- Code review context
|
||||
@@ -43,11 +69,11 @@ When working on multiple issues, update EACH issue IMMEDIATELY after completing
|
||||
3. Run related tests
|
||||
4. Create PR if needed
|
||||
5. Update status to **"In Review"** (NOT "Done")
|
||||
6. Add completion comment
|
||||
6. **Add completion comment immediately**
|
||||
7. Move to next issue
|
||||
|
||||
**Note:** Status → "In Review" when PR created. "Done" only after PR merged.
|
||||
|
||||
**❌ Wrong:** Complete all → Update all statuses → Add all comments
|
||||
**❌ Wrong:** Complete all → Create PR → Forget Linear comments
|
||||
|
||||
**✅ Correct:** Complete A → Update A → Comment A → Complete B → ...
|
||||
**✅ Correct:** Complete → Create PR → Add Linear comments → Task done
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "الذاكرة - التجارب",
|
||||
"navigation.memoryIdentities": "الذاكرة - الهويات",
|
||||
"navigation.memoryPreferences": "الذاكرة - التفضيلات",
|
||||
"navigation.noPages": "لا توجد صفحات بعد",
|
||||
"navigation.onboarding": "البدء",
|
||||
"navigation.page": "صفحة",
|
||||
"navigation.pages": "الصفحات",
|
||||
"navigation.pin": "تثبيت",
|
||||
"navigation.pinned": "مثبت",
|
||||
"navigation.provider": "المزود",
|
||||
"navigation.recentView": "المشاهدات الأخيرة",
|
||||
"navigation.resources": "الموارد",
|
||||
"navigation.settings": "الإعدادات",
|
||||
"navigation.unpin": "إلغاء التثبيت",
|
||||
"notification.finishChatGeneration": "اكتمل توليد الرسالة بواسطة الذكاء الاصطناعي",
|
||||
"proxy.auth": "يتطلب المصادقة",
|
||||
"proxy.authDesc": "إذا كان خادم البروكسي يتطلب اسم مستخدم وكلمة مرور",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "Памят - Преживявания",
|
||||
"navigation.memoryIdentities": "Памят - Идентичности",
|
||||
"navigation.memoryPreferences": "Памят - Предпочитания",
|
||||
"navigation.noPages": "Все още няма страници",
|
||||
"navigation.onboarding": "Въведение",
|
||||
"navigation.page": "Страница",
|
||||
"navigation.pages": "Страници",
|
||||
"navigation.pin": "Закачи",
|
||||
"navigation.pinned": "Закачено",
|
||||
"navigation.provider": "Доставчик",
|
||||
"navigation.recentView": "Последни преглеждания",
|
||||
"navigation.resources": "Ресурси",
|
||||
"navigation.settings": "Настройки",
|
||||
"navigation.unpin": "Откачи",
|
||||
"notification.finishChatGeneration": "Генерирането на съобщение от ИИ е завършено",
|
||||
"proxy.auth": "Изисква се удостоверяване",
|
||||
"proxy.authDesc": "Ако прокси сървърът изисква потребителско име и парола",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "Speicher - Erfahrungen",
|
||||
"navigation.memoryIdentities": "Speicher - Identitäten",
|
||||
"navigation.memoryPreferences": "Speicher - Präferenzen",
|
||||
"navigation.noPages": "Noch keine Seiten",
|
||||
"navigation.onboarding": "Einführung",
|
||||
"navigation.page": "Seite",
|
||||
"navigation.pages": "Seiten",
|
||||
"navigation.pin": "Anheften",
|
||||
"navigation.pinned": "Angeheftet",
|
||||
"navigation.provider": "Anbieter",
|
||||
"navigation.recentView": "Zuletzt angesehen",
|
||||
"navigation.resources": "Ressourcen",
|
||||
"navigation.settings": "Einstellungen",
|
||||
"navigation.unpin": "Loslösen",
|
||||
"notification.finishChatGeneration": "KI-Nachrichtenerstellung abgeschlossen",
|
||||
"proxy.auth": "Authentifizierung erforderlich",
|
||||
"proxy.authDesc": "Falls der Proxy-Server einen Benutzernamen und ein Passwort benötigt",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "Memory - Experiences",
|
||||
"navigation.memoryIdentities": "Memory - Identities",
|
||||
"navigation.memoryPreferences": "Memory - Preferences",
|
||||
"navigation.noPages": "No pages yet",
|
||||
"navigation.onboarding": "Onboarding",
|
||||
"navigation.page": "Page",
|
||||
"navigation.pages": "Pages",
|
||||
"navigation.pin": "Pin",
|
||||
"navigation.pinned": "Pinned",
|
||||
"navigation.provider": "Provider",
|
||||
"navigation.recentView": "Recent pages",
|
||||
"navigation.resources": "Resources",
|
||||
"navigation.settings": "Settings",
|
||||
"navigation.unpin": "Unpin",
|
||||
"notification.finishChatGeneration": "AI message generation completed",
|
||||
"proxy.auth": "Authentication Required",
|
||||
"proxy.authDesc": "If the proxy server requires a username and password",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "Memoria - Experiencias",
|
||||
"navigation.memoryIdentities": "Memoria - Identidades",
|
||||
"navigation.memoryPreferences": "Memoria - Preferencias",
|
||||
"navigation.noPages": "Aún no hay páginas",
|
||||
"navigation.onboarding": "Incorporación",
|
||||
"navigation.page": "Página",
|
||||
"navigation.pages": "Páginas",
|
||||
"navigation.pin": "Fijar",
|
||||
"navigation.pinned": "Fijado",
|
||||
"navigation.provider": "Proveedor",
|
||||
"navigation.recentView": "Vistas recientes",
|
||||
"navigation.resources": "Recursos",
|
||||
"navigation.settings": "Configuración",
|
||||
"navigation.unpin": "Desfijar",
|
||||
"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",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "حافظه - تجربیات",
|
||||
"navigation.memoryIdentities": "حافظه - هویتها",
|
||||
"navigation.memoryPreferences": "حافظه - ترجیحات",
|
||||
"navigation.noPages": "هنوز صفحهای وجود ندارد",
|
||||
"navigation.onboarding": "راهاندازی",
|
||||
"navigation.page": "صفحه",
|
||||
"navigation.pages": "صفحات",
|
||||
"navigation.pin": "ثابت کردن",
|
||||
"navigation.pinned": "ثابت شده",
|
||||
"navigation.provider": "ارائهدهنده",
|
||||
"navigation.recentView": "مشاهدات اخیر",
|
||||
"navigation.resources": "منابع",
|
||||
"navigation.settings": "تنظیمات",
|
||||
"navigation.unpin": "حذف ثابت",
|
||||
"notification.finishChatGeneration": "تولید پیام توسط هوش مصنوعی به پایان رسید",
|
||||
"proxy.auth": "احراز هویت لازم است",
|
||||
"proxy.authDesc": "در صورتی که سرور پروکسی نیاز به نام کاربری و رمز عبور داشته باشد",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "Mémoire - Expériences",
|
||||
"navigation.memoryIdentities": "Mémoire - Identités",
|
||||
"navigation.memoryPreferences": "Mémoire - Préférences",
|
||||
"navigation.noPages": "Aucune page pour le moment",
|
||||
"navigation.onboarding": "Intégration",
|
||||
"navigation.page": "Page",
|
||||
"navigation.pages": "Pages",
|
||||
"navigation.pin": "Épingler",
|
||||
"navigation.pinned": "Épinglé",
|
||||
"navigation.provider": "Fournisseur",
|
||||
"navigation.recentView": "Vues récentes",
|
||||
"navigation.resources": "Ressources",
|
||||
"navigation.settings": "Paramètres",
|
||||
"navigation.unpin": "Désépingler",
|
||||
"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",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "Memoria - Esperienze",
|
||||
"navigation.memoryIdentities": "Memoria - Identità",
|
||||
"navigation.memoryPreferences": "Memoria - Preferenze",
|
||||
"navigation.noPages": "Nessuna pagina ancora",
|
||||
"navigation.onboarding": "Onboarding",
|
||||
"navigation.page": "Pagina",
|
||||
"navigation.pages": "Pagine",
|
||||
"navigation.pin": "Appunta",
|
||||
"navigation.pinned": "Appuntato",
|
||||
"navigation.provider": "Provider",
|
||||
"navigation.recentView": "Visualizzazioni recenti",
|
||||
"navigation.resources": "Risorse",
|
||||
"navigation.settings": "Impostazioni",
|
||||
"navigation.unpin": "Rimuovi appuntamento",
|
||||
"notification.finishChatGeneration": "Generazione del messaggio AI completata",
|
||||
"proxy.auth": "Autenticazione richiesta",
|
||||
"proxy.authDesc": "Se il server proxy richiede nome utente e password",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "メモリ - 経験",
|
||||
"navigation.memoryIdentities": "メモリ - アイデンティティ",
|
||||
"navigation.memoryPreferences": "メモリ - 設定",
|
||||
"navigation.noPages": "ページがまだありません",
|
||||
"navigation.onboarding": "オンボーディング",
|
||||
"navigation.page": "ページ",
|
||||
"navigation.pages": "ページ",
|
||||
"navigation.pin": "ピン留め",
|
||||
"navigation.pinned": "ピン留め済み",
|
||||
"navigation.provider": "プロバイダー",
|
||||
"navigation.recentView": "最近の閲覧",
|
||||
"navigation.resources": "リソース",
|
||||
"navigation.settings": "設定",
|
||||
"navigation.unpin": "ピン留めを解除",
|
||||
"notification.finishChatGeneration": "AI メッセージの生成が完了しました",
|
||||
"proxy.auth": "認証が必要",
|
||||
"proxy.authDesc": "プロキシサーバーがユーザー名とパスワードを必要とする場合",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "메모리 - 경험",
|
||||
"navigation.memoryIdentities": "메모리 - 신원",
|
||||
"navigation.memoryPreferences": "메모리 - 선호도",
|
||||
"navigation.noPages": "아직 페이지가 없습니다",
|
||||
"navigation.onboarding": "온보딩",
|
||||
"navigation.page": "페이지",
|
||||
"navigation.pages": "페이지",
|
||||
"navigation.pin": "고정",
|
||||
"navigation.pinned": "고정됨",
|
||||
"navigation.provider": "프로바이더",
|
||||
"navigation.recentView": "최근 조회",
|
||||
"navigation.resources": "리소스",
|
||||
"navigation.settings": "설정",
|
||||
"navigation.unpin": "고정 해제",
|
||||
"notification.finishChatGeneration": "AI 메시지 생성이 완료되었습니다",
|
||||
"proxy.auth": "인증 필요",
|
||||
"proxy.authDesc": "프록시 서버가 사용자 이름과 비밀번호를 요구하는 경우",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "Geheugen - Ervaringen",
|
||||
"navigation.memoryIdentities": "Geheugen - Identiteiten",
|
||||
"navigation.memoryPreferences": "Geheugen - Voorkeuren",
|
||||
"navigation.noPages": "Nog geen pagina's",
|
||||
"navigation.onboarding": "Onboarding",
|
||||
"navigation.page": "Pagina",
|
||||
"navigation.pages": "Pagina's",
|
||||
"navigation.pin": "Vastmaken",
|
||||
"navigation.pinned": "Vastgemaakt",
|
||||
"navigation.provider": "Provider",
|
||||
"navigation.recentView": "Recente weergaven",
|
||||
"navigation.resources": "Bronnen",
|
||||
"navigation.settings": "Instellingen",
|
||||
"navigation.unpin": "Losmaken",
|
||||
"notification.finishChatGeneration": "AI-berichtgeneratie voltooid",
|
||||
"proxy.auth": "Authenticatie vereist",
|
||||
"proxy.authDesc": "Indien de proxyserver een gebruikersnaam en wachtwoord vereist",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "Pamięć - Doświadczenia",
|
||||
"navigation.memoryIdentities": "Pamięć - Tożsamości",
|
||||
"navigation.memoryPreferences": "Pamięć - Preferencje",
|
||||
"navigation.noPages": "Brak stron",
|
||||
"navigation.onboarding": "Wprowadzenie",
|
||||
"navigation.page": "Strona",
|
||||
"navigation.pages": "Strony",
|
||||
"navigation.pin": "Przypnij",
|
||||
"navigation.pinned": "Przypięte",
|
||||
"navigation.provider": "Dostawca",
|
||||
"navigation.recentView": "Ostatnie wyświetlenia",
|
||||
"navigation.resources": "Zasoby",
|
||||
"navigation.settings": "Ustawienia",
|
||||
"navigation.unpin": "Odepnij",
|
||||
"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",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "Memória - Experiências",
|
||||
"navigation.memoryIdentities": "Memória - Identidades",
|
||||
"navigation.memoryPreferences": "Memória - Preferências",
|
||||
"navigation.noPages": "Ainda não há páginas",
|
||||
"navigation.onboarding": "Integração",
|
||||
"navigation.page": "Página",
|
||||
"navigation.pages": "Páginas",
|
||||
"navigation.pin": "Fixar",
|
||||
"navigation.pinned": "Fixado",
|
||||
"navigation.provider": "Provedor",
|
||||
"navigation.recentView": "Visualizações recentes",
|
||||
"navigation.resources": "Recursos",
|
||||
"navigation.settings": "Configurações",
|
||||
"navigation.unpin": "Desfixar",
|
||||
"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",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "Память - Опыт",
|
||||
"navigation.memoryIdentities": "Память - Идентичности",
|
||||
"navigation.memoryPreferences": "Память - Предпочтения",
|
||||
"navigation.noPages": "Пока нет страниц",
|
||||
"navigation.onboarding": "Введение",
|
||||
"navigation.page": "Страница",
|
||||
"navigation.pages": "Страницы",
|
||||
"navigation.pin": "Закрепить",
|
||||
"navigation.pinned": "Закреплено",
|
||||
"navigation.provider": "Провайдер",
|
||||
"navigation.recentView": "Недавние просмотры",
|
||||
"navigation.resources": "Ресурсы",
|
||||
"navigation.settings": "Настройки",
|
||||
"navigation.unpin": "Открепить",
|
||||
"notification.finishChatGeneration": "Генерация сообщения ИИ завершена",
|
||||
"proxy.auth": "Требуется аутентификация",
|
||||
"proxy.authDesc": "Если прокси-сервер требует имя пользователя и пароль",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "Bellek - Deneyimler",
|
||||
"navigation.memoryIdentities": "Bellek - Kimlikler",
|
||||
"navigation.memoryPreferences": "Bellek - Tercihler",
|
||||
"navigation.noPages": "Henüz sayfa yok",
|
||||
"navigation.onboarding": "Hoş Geldiniz",
|
||||
"navigation.page": "Sayfa",
|
||||
"navigation.pages": "Sayfalar",
|
||||
"navigation.pin": "Sabitle",
|
||||
"navigation.pinned": "Sabitlendi",
|
||||
"navigation.provider": "Sağlayıcı",
|
||||
"navigation.recentView": "Son görüntülemeler",
|
||||
"navigation.resources": "Kaynaklar",
|
||||
"navigation.settings": "Ayarlar",
|
||||
"navigation.unpin": "Sabitlemeyi kaldır",
|
||||
"notification.finishChatGeneration": "Yapay zeka mesaj oluşturma tamamlandı",
|
||||
"proxy.auth": "Kimlik Doğrulama Gerekli",
|
||||
"proxy.authDesc": "Proxy sunucusu kullanıcı adı ve şifre gerektiriyorsa",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "Bộ nhớ - Trải nghiệm",
|
||||
"navigation.memoryIdentities": "Bộ nhớ - Danh tính",
|
||||
"navigation.memoryPreferences": "Bộ nhớ - Tùy chọn",
|
||||
"navigation.noPages": "Chưa có trang nào",
|
||||
"navigation.onboarding": "Giới thiệu",
|
||||
"navigation.page": "Trang",
|
||||
"navigation.pages": "Trang",
|
||||
"navigation.pin": "Ghim",
|
||||
"navigation.pinned": "Đã ghim",
|
||||
"navigation.provider": "Nhà cung cấp",
|
||||
"navigation.recentView": "Đã xem gần đây",
|
||||
"navigation.resources": "Tài nguyên",
|
||||
"navigation.settings": "Cài đặt",
|
||||
"navigation.unpin": "Bỏ ghim",
|
||||
"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",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "记忆 - 经历",
|
||||
"navigation.memoryIdentities": "记忆 - 身份",
|
||||
"navigation.memoryPreferences": "记忆 - 偏好",
|
||||
"navigation.noPages": "暂无页面",
|
||||
"navigation.onboarding": "引导",
|
||||
"navigation.page": "文稿",
|
||||
"navigation.pages": "文稿",
|
||||
"navigation.pin": "固定",
|
||||
"navigation.pinned": "已固定",
|
||||
"navigation.provider": "模型服务商",
|
||||
"navigation.recentView": "最近访问",
|
||||
"navigation.resources": "资源",
|
||||
"navigation.settings": "设置",
|
||||
"navigation.unpin": "取消固定",
|
||||
"notification.finishChatGeneration": "AI 消息已生成完毕",
|
||||
"proxy.auth": "需要认证",
|
||||
"proxy.authDesc": "如果代理服务器需要用户名和密码",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "記憶 - 經歷",
|
||||
"navigation.memoryIdentities": "記憶 - 身份",
|
||||
"navigation.memoryPreferences": "記憶 - 偏好",
|
||||
"navigation.noPages": "暫無頁面",
|
||||
"navigation.onboarding": "引導",
|
||||
"navigation.page": "文稿",
|
||||
"navigation.pages": "文稿",
|
||||
"navigation.pin": "固定",
|
||||
"navigation.pinned": "已固定",
|
||||
"navigation.provider": "模型服務商",
|
||||
"navigation.recentView": "最近訪問",
|
||||
"navigation.resources": "資源",
|
||||
"navigation.settings": "設定",
|
||||
"navigation.unpin": "取消固定",
|
||||
"notification.finishChatGeneration": "AI 訊息已生成完畢",
|
||||
"proxy.auth": "需要認證",
|
||||
"proxy.authDesc": "如果代理伺服器需要使用者名稱和密碼",
|
||||
|
||||
@@ -31,6 +31,7 @@ const ContextList = memo(() => {
|
||||
const showSelectionList = useFileStore(fileChatSelectors.chatContextSelectionHasItem);
|
||||
const clearChatContextSelections = useFileStore((s) => s.clearChatContextSelections);
|
||||
|
||||
|
||||
// Clear selections only when agentId changes (not on initial mount)
|
||||
useEffect(() => {
|
||||
if (prevAgentIdRef.current !== undefined && prevAgentIdRef.current !== agentId) {
|
||||
|
||||
@@ -5,10 +5,98 @@ import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins';
|
||||
import {
|
||||
type CachedPageData,
|
||||
type PageReference,
|
||||
} from '@/features/Electron/titlebar/RecentlyViewed/types';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentSelectors } from '@/store/agent/selectors/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
import { usePageStore } from '@/store/page';
|
||||
import { listSelectors } from '@/store/page/slices/list/selectors';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { sessionGroupSelectors } from '@/store/session/slices/sessionGroup/selectors';
|
||||
|
||||
import { getRouteMetadata } from './routeMetadata';
|
||||
|
||||
/**
|
||||
* Get cached display data for a page reference
|
||||
*/
|
||||
const getCachedDataForReference = (reference: PageReference): CachedPageData | undefined => {
|
||||
switch (reference.type) {
|
||||
case 'agent':
|
||||
case 'agent-topic': {
|
||||
const agentId = 'agentId' in reference.params ? reference.params.agentId : undefined;
|
||||
if (!agentId) return undefined;
|
||||
|
||||
const meta = agentSelectors.getAgentMetaById(agentId)(useAgentStore.getState());
|
||||
if (!meta || Object.keys(meta).length === 0) return undefined;
|
||||
|
||||
// For agent-topic, try to get topic title
|
||||
let title = meta.title;
|
||||
if (reference.type === 'agent-topic' && 'topicId' in reference.params) {
|
||||
const topicId = reference.params.topicId;
|
||||
const topicDataMap = useChatStore.getState().topicDataMap;
|
||||
for (const data of Object.values(topicDataMap)) {
|
||||
const topic = data.items?.find((t) => t.id === topicId);
|
||||
if (topic?.title) {
|
||||
title = topic.title;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
avatar: meta.avatar,
|
||||
backgroundColor: meta.backgroundColor,
|
||||
title: title || '',
|
||||
};
|
||||
}
|
||||
|
||||
case 'group':
|
||||
case 'group-topic': {
|
||||
const groupId = 'groupId' in reference.params ? reference.params.groupId : undefined;
|
||||
if (!groupId) return undefined;
|
||||
|
||||
const group = sessionGroupSelectors.getGroupById(groupId)(useSessionStore.getState());
|
||||
if (!group) return undefined;
|
||||
|
||||
// For group-topic, try to get topic title
|
||||
let title = group.name;
|
||||
if (reference.type === 'group-topic' && 'topicId' in reference.params) {
|
||||
const topicId = reference.params.topicId;
|
||||
const topicDataMap = useChatStore.getState().topicDataMap;
|
||||
for (const data of Object.values(topicDataMap)) {
|
||||
const topic = data.items?.find((t) => t.id === topicId);
|
||||
if (topic?.title) {
|
||||
title = topic.title;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { title: title || '' };
|
||||
}
|
||||
|
||||
case 'page': {
|
||||
const pageId = 'pageId' in reference.params ? reference.params.pageId : undefined;
|
||||
if (!pageId) return undefined;
|
||||
|
||||
const document = listSelectors.getDocumentById(pageId)(usePageStore.getState());
|
||||
if (!document) return undefined;
|
||||
|
||||
return { title: document.title || '' };
|
||||
}
|
||||
|
||||
default: {
|
||||
// Static pages don't need cached data
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to manage navigation history in Electron desktop app
|
||||
* Provides browser-like back/forward functionality
|
||||
@@ -31,6 +119,7 @@ export const useNavigationHistory = () => {
|
||||
const canGoBackFn = useElectronStore((s) => s.canGoBack);
|
||||
const canGoForwardFn = useElectronStore((s) => s.canGoForward);
|
||||
const getCurrentEntry = useElectronStore((s) => s.getCurrentEntry);
|
||||
const addRecentPage = useElectronStore((s) => s.addRecentPage);
|
||||
|
||||
// Track previous location to avoid duplicate entries
|
||||
const prevLocationRef = useRef<string | null>(null);
|
||||
@@ -39,9 +128,6 @@ export const useNavigationHistory = () => {
|
||||
const canGoBack = historyCurrentIndex > 0;
|
||||
const canGoForward = historyCurrentIndex < historyEntries.length - 1;
|
||||
|
||||
/**
|
||||
* Go back in history
|
||||
*/
|
||||
const goBack = useCallback(() => {
|
||||
if (!canGoBackFn()) return;
|
||||
|
||||
@@ -51,9 +137,6 @@ export const useNavigationHistory = () => {
|
||||
}
|
||||
}, [canGoBackFn, storeGoBack, navigate]);
|
||||
|
||||
/**
|
||||
* Go forward in history
|
||||
*/
|
||||
const goForward = useCallback(() => {
|
||||
if (!canGoForwardFn()) return;
|
||||
|
||||
@@ -99,6 +182,17 @@ export const useNavigationHistory = () => {
|
||||
url: currentUrl,
|
||||
});
|
||||
|
||||
// Only add to recent pages if NOT a dynamic title route
|
||||
// Dynamic title routes will be added when the real title is available
|
||||
if (!metadata.useDynamicTitle) {
|
||||
// Parse URL into a page reference using plugins
|
||||
const reference = pluginRegistry.parseUrl(location.pathname, location.search);
|
||||
if (reference) {
|
||||
const cached = getCachedDataForReference(reference);
|
||||
addRecentPage(reference, cached);
|
||||
}
|
||||
}
|
||||
|
||||
prevLocationRef.current = currentUrl;
|
||||
}, [
|
||||
location.pathname,
|
||||
@@ -107,6 +201,7 @@ export const useNavigationHistory = () => {
|
||||
setIsNavigatingHistory,
|
||||
getCurrentEntry,
|
||||
pushHistory,
|
||||
addRecentPage,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -129,7 +224,27 @@ export const useNavigationHistory = () => {
|
||||
...currentEntry,
|
||||
title: currentPageTitle,
|
||||
});
|
||||
}, [currentPageTitle, getCurrentEntry, replaceHistory, location.pathname]);
|
||||
|
||||
// Add or update in recent pages (dynamic title routes are added here, not on route change)
|
||||
// Parse URL into a page reference using plugins
|
||||
const reference = pluginRegistry.parseUrl(location.pathname, location.search);
|
||||
if (reference) {
|
||||
// Get cached data with the dynamic title
|
||||
const cached = getCachedDataForReference(reference);
|
||||
// Override with the current page title if available
|
||||
const cachedWithTitle = cached
|
||||
? { ...cached, title: currentPageTitle }
|
||||
: { title: currentPageTitle };
|
||||
addRecentPage(reference, cachedWithTitle);
|
||||
}
|
||||
}, [
|
||||
currentPageTitle,
|
||||
getCurrentEntry,
|
||||
replaceHistory,
|
||||
addRecentPage,
|
||||
location.pathname,
|
||||
location.search,
|
||||
]);
|
||||
|
||||
// Listen to broadcast events from main process (Electron menu)
|
||||
useWatchBroadcast('historyGoBack', () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { ActionIcon, Flexbox, Popover, Tooltip } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { ArrowLeft, ArrowRight, Clock } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
@@ -12,6 +13,7 @@ import { isMacOS } from '@/utils/platform';
|
||||
|
||||
import { useNavigationHistory } from '../navigation/useNavigationHistory';
|
||||
import RecentlyViewed from './RecentlyViewed';
|
||||
import { loadAllRecentlyViewedPlugins } from './RecentlyViewed/plugins';
|
||||
|
||||
const isMac = isMacOS();
|
||||
|
||||
@@ -19,7 +21,26 @@ const useNavPanelWidth = () => {
|
||||
return useGlobalStore(systemStatusSelectors.leftPanelWidth);
|
||||
};
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
clock: css`
|
||||
&[data-popup-open] {
|
||||
border-radius: ${cssVar.borderRadiusSM};
|
||||
background-color: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
const useLoadAllRecentlyViewedPlugins = () => {
|
||||
const registerRef = useRef(false);
|
||||
|
||||
if (!registerRef.current) {
|
||||
loadAllRecentlyViewedPlugins();
|
||||
registerRef.current = true;
|
||||
}
|
||||
};
|
||||
const NavigationBar = memo(() => {
|
||||
useLoadAllRecentlyViewedPlugins();
|
||||
|
||||
const { t } = useTranslation('electron');
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useNavigationHistory();
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
@@ -69,7 +90,7 @@ const NavigationBar = memo(() => {
|
||||
styles={{ content: { padding: 0 } }}
|
||||
trigger="click"
|
||||
>
|
||||
<div>
|
||||
<div className={styles.clock}>
|
||||
<Tooltip open={historyOpen ? false : undefined} title={tooltipContent}>
|
||||
<ActionIcon icon={Clock} size="small" />
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
'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 '../navigation/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;
|
||||
flex-shrink: 0;
|
||||
|
||||
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;
|
||||
71
src/features/Electron/titlebar/RecentlyViewed/PageItem.tsx
Normal file
71
src/features/Electron/titlebar/RecentlyViewed/PageItem.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { ActionIcon, Flexbox, Icon } from '@lobehub/ui';
|
||||
import { cx } from 'antd-style';
|
||||
import { Pin, PinOff } from 'lucide-react';
|
||||
import React, { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
|
||||
import { useStyles } from './styles';
|
||||
import { type ResolvedPageData } from './types';
|
||||
|
||||
interface PageItemProps {
|
||||
isPinned: boolean;
|
||||
item: ResolvedPageData;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PageItem = memo<PageItemProps>(({ item, isPinned, onClose }) => {
|
||||
const { t } = useTranslation('electron');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const styles = useStyles;
|
||||
|
||||
const pinPage = useElectronStore((s) => s.pinPage);
|
||||
const unpinPage = useElectronStore((s) => s.unpinPage);
|
||||
|
||||
// Check if this item matches the current route
|
||||
const currentUrl = location.pathname + location.search;
|
||||
const isActive = item.url === currentUrl || item.url === currentUrl.replace(/\/+$/, '');
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(item.url);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handlePinToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isPinned) {
|
||||
unpinPage(item.reference.id);
|
||||
} else {
|
||||
pinPage(item.reference);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
align="center"
|
||||
className={cx(styles.item, isActive && styles.itemActive)}
|
||||
gap={8}
|
||||
horizontal
|
||||
onClick={handleClick}
|
||||
>
|
||||
{item.icon && <Icon className={styles.icon} icon={item.icon} size="small" />}
|
||||
<span className={styles.itemTitle}>{item.title}</span>
|
||||
<ActionIcon
|
||||
className={cx('actionIcon', styles.actionIcon)}
|
||||
icon={isPinned ? PinOff : Pin}
|
||||
onClick={handlePinToggle}
|
||||
size="small"
|
||||
title={isPinned ? t('navigation.unpin') : t('navigation.pin')}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
PageItem.displayName = 'PageItem';
|
||||
|
||||
export default PageItem;
|
||||
33
src/features/Electron/titlebar/RecentlyViewed/Section.tsx
Normal file
33
src/features/Electron/titlebar/RecentlyViewed/Section.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
|
||||
import PageItem from './PageItem';
|
||||
import { useStyles } from './styles';
|
||||
import { type ResolvedPageData } from './types';
|
||||
|
||||
interface SectionProps {
|
||||
isPinned: boolean;
|
||||
items: ResolvedPageData[];
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const Section = memo<SectionProps>(({ title, items, isPinned, onClose }) => {
|
||||
const styles = useStyles;
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.title}>{title}</div>
|
||||
{items.map((item) => (
|
||||
<PageItem isPinned={isPinned} item={item} key={item.reference.id} onClose={onClose} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Section.displayName = 'Section';
|
||||
|
||||
export default Section;
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentSelectors } from '@/store/agent/selectors/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { usePageStore } from '@/store/page';
|
||||
import { listSelectors } from '@/store/page/slices/list/selectors';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { sessionGroupSelectors } from '@/store/session/slices/sessionGroup/selectors';
|
||||
import { type ChatTopic } from '@/types/topic';
|
||||
|
||||
import { type PluginContext } from '../plugins/types';
|
||||
|
||||
/**
|
||||
* Search for a topic across all entries in topicDataMap
|
||||
* This is needed because getTopicById only searches in the current active session's topics
|
||||
*/
|
||||
const findTopicAcrossAllSessions = (
|
||||
topicDataMap: Record<string, { items?: ChatTopic[] }>,
|
||||
topicId: string,
|
||||
): ChatTopic | undefined => {
|
||||
for (const data of Object.values(topicDataMap)) {
|
||||
const topic = data.items?.find((t) => t.id === topicId);
|
||||
if (topic) return topic;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create plugin context with access to store data
|
||||
*/
|
||||
export const usePluginContext = (): PluginContext => {
|
||||
const { t } = useTranslation('electron');
|
||||
|
||||
const agentMap = useAgentStore((s) => s.agentMap);
|
||||
const topicDataMap = useChatStore((s) => s.topicDataMap);
|
||||
const sessionGroups = useSessionStore((s) => s.sessionGroups);
|
||||
const documents = usePageStore((s) => s.documents);
|
||||
|
||||
return useMemo<PluginContext>(
|
||||
() => ({
|
||||
getAgentMeta: (agentId: string) => {
|
||||
const state = useAgentStore.getState();
|
||||
return agentSelectors.getAgentMetaById(agentId)(state);
|
||||
},
|
||||
|
||||
getDocument: (documentId: string) => {
|
||||
const state = usePageStore.getState();
|
||||
return listSelectors.getDocumentById(documentId)(state);
|
||||
},
|
||||
|
||||
getSessionGroup: (groupId: string) => {
|
||||
const state = useSessionStore.getState();
|
||||
return sessionGroupSelectors.getGroupById(groupId)(state);
|
||||
},
|
||||
|
||||
getTopic: (topicId: string) => {
|
||||
// Search across ALL entries in topicDataMap, not just current session
|
||||
// This ensures we can find topics even after navigating away from the agent page
|
||||
const state = useChatStore.getState();
|
||||
return findTopicAcrossAllSessions(state.topicDataMap, topicId);
|
||||
},
|
||||
|
||||
t: (key: string, options?: Record<string, unknown>) => t(key as any, options) as string,
|
||||
}),
|
||||
[agentMap, topicDataMap, sessionGroups, documents, t],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
|
||||
import { pluginRegistry } from '../plugins';
|
||||
import { type ResolvedPageData } from '../types';
|
||||
import { usePluginContext } from './usePluginContext';
|
||||
|
||||
interface UseResolvedPagesResult {
|
||||
pinnedPages: ResolvedPageData[];
|
||||
recentPages: ResolvedPageData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to resolve page references into display data
|
||||
* Automatically filters out pages where data no longer exists
|
||||
*/
|
||||
export const useResolvedPages = (): UseResolvedPagesResult => {
|
||||
const ctx = usePluginContext();
|
||||
|
||||
const pinnedRefs = useElectronStore((s) => s.pinnedPages);
|
||||
const recentRefs = useElectronStore((s) => s.recentPages);
|
||||
|
||||
const pinnedPages = useMemo(() => pluginRegistry.resolveAll(pinnedRefs, ctx), [pinnedRefs, ctx]);
|
||||
|
||||
const recentPages = useMemo(() => pluginRegistry.resolveAll(recentRefs, ctx), [recentRefs, ctx]);
|
||||
|
||||
return {
|
||||
pinnedPages,
|
||||
recentPages,
|
||||
};
|
||||
};
|
||||
55
src/features/Electron/titlebar/RecentlyViewed/index.tsx
Normal file
55
src/features/Electron/titlebar/RecentlyViewed/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { memo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
|
||||
import Section from './Section';
|
||||
import { useResolvedPages } from './hooks/useResolvedPages';
|
||||
import { useStyles } from './styles';
|
||||
|
||||
interface RecentlyViewedProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const RecentlyViewed = memo<RecentlyViewedProps>(({ onClose }) => {
|
||||
const { t } = useTranslation('electron');
|
||||
const styles = useStyles;
|
||||
|
||||
const loadPinnedPages = useElectronStore((s) => s.loadPinnedPages);
|
||||
|
||||
const { pinnedPages, recentPages } = useResolvedPages();
|
||||
|
||||
useEffect(() => {
|
||||
loadPinnedPages();
|
||||
}, [loadPinnedPages]);
|
||||
|
||||
const isEmpty = pinnedPages.length === 0 && recentPages.length === 0;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.empty}>{t('navigation.noPages')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container}>
|
||||
<Section isPinned items={pinnedPages} onClose={onClose} title={t('navigation.pinned')} />
|
||||
{pinnedPages.length > 0 && recentPages.length > 0 && <div className={styles.divider} />}
|
||||
<Section
|
||||
isPinned={false}
|
||||
items={recentPages}
|
||||
onClose={onClose}
|
||||
title={t('navigation.recentView')}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
RecentlyViewed.displayName = 'RecentlyViewed';
|
||||
|
||||
export default RecentlyViewed;
|
||||
@@ -0,0 +1,62 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
import { type AgentParams, type PageReference, type ResolvedPageData } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin, createPageReference } from './types';
|
||||
|
||||
const AGENT_PATH_REGEX = /^\/agent\/([^/?]+)$/;
|
||||
|
||||
export const agentPlugin: RecentlyViewedPlugin<'agent'> = {
|
||||
checkExists(reference: PageReference<'agent'>, ctx: PluginContext): boolean {
|
||||
const meta = ctx.getAgentMeta(reference.params.agentId);
|
||||
return meta !== undefined && Object.keys(meta).length > 0;
|
||||
},
|
||||
generateId(reference: PageReference<'agent'>): string {
|
||||
return `agent:${reference.params.agentId}`;
|
||||
},
|
||||
|
||||
generateUrl(reference: PageReference<'agent'>): string {
|
||||
return `/agent/${reference.params.agentId}`;
|
||||
},
|
||||
|
||||
getDefaultIcon() {
|
||||
return MessageSquare;
|
||||
},
|
||||
|
||||
matchUrl(pathname: string, searchParams: URLSearchParams): boolean {
|
||||
// Match /agent/:id but NOT when there's a topic param
|
||||
return AGENT_PATH_REGEX.test(pathname) && !searchParams.has('topic');
|
||||
},
|
||||
|
||||
parseUrl(pathname: string, _searchParams: URLSearchParams): PageReference<'agent'> | null {
|
||||
const match = pathname.match(AGENT_PATH_REGEX);
|
||||
if (!match) return null;
|
||||
|
||||
const agentId = match[1];
|
||||
const params: AgentParams = { agentId };
|
||||
const id = this.generateId({ params } as PageReference<'agent'>);
|
||||
|
||||
return createPageReference('agent', params, id);
|
||||
},
|
||||
|
||||
priority: 10,
|
||||
|
||||
resolve(reference: PageReference<'agent'>, ctx: PluginContext): ResolvedPageData {
|
||||
const meta = ctx.getAgentMeta(reference.params.agentId);
|
||||
const hasStoreData = meta !== undefined && Object.keys(meta).length > 0;
|
||||
const cached = reference.cached;
|
||||
|
||||
// Use store data if available, otherwise fallback to cached data
|
||||
return {
|
||||
avatar: meta?.avatar ?? cached?.avatar,
|
||||
backgroundColor: meta?.backgroundColor ?? cached?.backgroundColor,
|
||||
exists: hasStoreData || cached !== undefined,
|
||||
icon: this.getDefaultIcon!(),
|
||||
reference,
|
||||
title: meta?.title || cached?.title || ctx.t('navigation.chat', { ns: 'electron' }),
|
||||
url: this.generateUrl(reference),
|
||||
};
|
||||
},
|
||||
|
||||
type: 'agent',
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
import { type AgentTopicParams, type PageReference, type ResolvedPageData } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin, createPageReference } from './types';
|
||||
|
||||
const AGENT_PATH_REGEX = /^\/agent\/([^/?]+)$/;
|
||||
|
||||
export const agentTopicPlugin: RecentlyViewedPlugin<'agent-topic'> = {
|
||||
checkExists(reference: PageReference<'agent-topic'>, ctx: PluginContext): boolean {
|
||||
const { agentId, topicId } = reference.params;
|
||||
const agentMeta = ctx.getAgentMeta(agentId);
|
||||
const topic = ctx.getTopic(topicId);
|
||||
|
||||
// Both agent and topic must exist
|
||||
return agentMeta !== undefined && Object.keys(agentMeta).length > 0 && topic !== undefined;
|
||||
},
|
||||
|
||||
generateId(reference: PageReference<'agent-topic'>): string {
|
||||
const { agentId, topicId } = reference.params;
|
||||
return `agent-topic:${agentId}:${topicId}`;
|
||||
},
|
||||
|
||||
generateUrl(reference: PageReference<'agent-topic'>): string {
|
||||
const { agentId, topicId } = reference.params;
|
||||
return `/agent/${agentId}?topic=${topicId}`;
|
||||
},
|
||||
|
||||
getDefaultIcon() {
|
||||
return MessageSquare;
|
||||
},
|
||||
|
||||
// Higher priority than agent plugin to match topic URLs first
|
||||
matchUrl(pathname: string, searchParams: URLSearchParams): boolean {
|
||||
// Match /agent/:id with topic param
|
||||
return AGENT_PATH_REGEX.test(pathname) && searchParams.has('topic');
|
||||
},
|
||||
|
||||
parseUrl(pathname: string, searchParams: URLSearchParams): PageReference<'agent-topic'> | null {
|
||||
const match = pathname.match(AGENT_PATH_REGEX);
|
||||
if (!match) return null;
|
||||
|
||||
const topicId = searchParams.get('topic');
|
||||
if (!topicId) return null;
|
||||
|
||||
const agentId = match[1];
|
||||
const params: AgentTopicParams = { agentId, topicId };
|
||||
const id = this.generateId({ params } as PageReference<'agent-topic'>);
|
||||
|
||||
return createPageReference('agent-topic', params, id);
|
||||
},
|
||||
|
||||
priority: 20,
|
||||
|
||||
resolve(reference: PageReference<'agent-topic'>, ctx: PluginContext): ResolvedPageData {
|
||||
const { agentId, topicId } = reference.params;
|
||||
const agentMeta = ctx.getAgentMeta(agentId);
|
||||
const topic = ctx.getTopic(topicId);
|
||||
const cached = reference.cached;
|
||||
|
||||
const agentExists = agentMeta !== undefined && Object.keys(agentMeta).length > 0;
|
||||
const topicExists = topic !== undefined;
|
||||
const hasStoreData = agentExists && topicExists;
|
||||
|
||||
// Use topic title if available, otherwise fall back to agent title, then cached
|
||||
const title =
|
||||
topic?.title ||
|
||||
agentMeta?.title ||
|
||||
cached?.title ||
|
||||
ctx.t('navigation.chat', { ns: 'electron' });
|
||||
|
||||
return {
|
||||
avatar: agentMeta?.avatar ?? cached?.avatar,
|
||||
backgroundColor: agentMeta?.backgroundColor ?? cached?.backgroundColor,
|
||||
exists: hasStoreData || cached !== undefined,
|
||||
icon: this.getDefaultIcon!(),
|
||||
reference,
|
||||
title,
|
||||
url: this.generateUrl(reference),
|
||||
};
|
||||
},
|
||||
|
||||
type: 'agent-topic',
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { ShapesIcon } from 'lucide-react';
|
||||
|
||||
import { getRouteById } from '@/config/routes';
|
||||
|
||||
import { type CommunityParams, type PageReference, type ResolvedPageData } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin, createPageReference } from './types';
|
||||
|
||||
const communityIcon = getRouteById('community')?.icon || ShapesIcon;
|
||||
|
||||
const COMMUNITY_PATH_REGEX = /^\/community(\/([^/?]+))?$/;
|
||||
|
||||
// Section to title key mapping
|
||||
const sectionTitleKeys: Record<string, string> = {
|
||||
agent: 'navigation.discoverAssistants',
|
||||
mcp: 'navigation.discoverMcp',
|
||||
model: 'navigation.discoverModels',
|
||||
provider: 'navigation.discoverProviders',
|
||||
};
|
||||
|
||||
export const communityPlugin: RecentlyViewedPlugin<'community'> = {
|
||||
checkExists(_reference: PageReference<'community'>, _ctx: PluginContext): boolean {
|
||||
return true; // Static page always exists
|
||||
},
|
||||
generateId(reference: PageReference<'community'>): string {
|
||||
const { section } = reference.params;
|
||||
return section ? `community:${section}` : 'community';
|
||||
},
|
||||
|
||||
generateUrl(reference: PageReference<'community'>): string {
|
||||
const { section } = reference.params;
|
||||
return section ? `/community/${section}` : '/community';
|
||||
},
|
||||
|
||||
getDefaultIcon() {
|
||||
return communityIcon;
|
||||
},
|
||||
|
||||
matchUrl(pathname: string, _searchParams: URLSearchParams): boolean {
|
||||
return COMMUNITY_PATH_REGEX.test(pathname);
|
||||
},
|
||||
|
||||
parseUrl(pathname: string, _searchParams: URLSearchParams): PageReference<'community'> | null {
|
||||
const match = pathname.match(COMMUNITY_PATH_REGEX);
|
||||
if (!match) return null;
|
||||
|
||||
const section = match[2]; // Optional section like 'agent', 'model', etc.
|
||||
const params: CommunityParams = section ? { section } : {};
|
||||
const id = this.generateId({ params } as PageReference<'community'>);
|
||||
|
||||
return createPageReference('community', params, id);
|
||||
},
|
||||
|
||||
priority: 5,
|
||||
|
||||
resolve(reference: PageReference<'community'>, ctx: PluginContext): ResolvedPageData {
|
||||
const { section } = reference.params;
|
||||
const titleKey = section
|
||||
? sectionTitleKeys[section] || 'navigation.discover'
|
||||
: 'navigation.discover';
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
icon: this.getDefaultIcon!(),
|
||||
reference,
|
||||
title: ctx.t(titleKey as any, { ns: 'electron' }) as string,
|
||||
url: this.generateUrl(reference),
|
||||
};
|
||||
},
|
||||
|
||||
type: 'community',
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
import { type GroupParams, type PageReference, type ResolvedPageData } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin, createPageReference } from './types';
|
||||
|
||||
const GROUP_PATH_REGEX = /^\/group\/([^/?]+)$/;
|
||||
|
||||
export const groupPlugin: RecentlyViewedPlugin<'group'> = {
|
||||
checkExists(reference: PageReference<'group'>, ctx: PluginContext): boolean {
|
||||
const group = ctx.getSessionGroup(reference.params.groupId);
|
||||
return group !== undefined;
|
||||
},
|
||||
generateId(reference: PageReference<'group'>): string {
|
||||
return `group:${reference.params.groupId}`;
|
||||
},
|
||||
|
||||
generateUrl(reference: PageReference<'group'>): string {
|
||||
return `/group/${reference.params.groupId}`;
|
||||
},
|
||||
|
||||
getDefaultIcon() {
|
||||
return Users;
|
||||
},
|
||||
|
||||
matchUrl(pathname: string, searchParams: URLSearchParams): boolean {
|
||||
// Match /group/:id but NOT when there's a topic param
|
||||
return GROUP_PATH_REGEX.test(pathname) && !searchParams.has('topic');
|
||||
},
|
||||
|
||||
parseUrl(pathname: string, _searchParams: URLSearchParams): PageReference<'group'> | null {
|
||||
const match = pathname.match(GROUP_PATH_REGEX);
|
||||
if (!match) return null;
|
||||
|
||||
const groupId = match[1];
|
||||
const params: GroupParams = { groupId };
|
||||
const id = this.generateId({ params } as PageReference<'group'>);
|
||||
|
||||
return createPageReference('group', params, id);
|
||||
},
|
||||
|
||||
priority: 10,
|
||||
|
||||
resolve(reference: PageReference<'group'>, ctx: PluginContext): ResolvedPageData {
|
||||
const group = ctx.getSessionGroup(reference.params.groupId);
|
||||
const hasStoreData = group !== undefined;
|
||||
const cached = reference.cached;
|
||||
|
||||
return {
|
||||
exists: hasStoreData || cached !== undefined,
|
||||
icon: this.getDefaultIcon!(),
|
||||
reference,
|
||||
title: group?.name || cached?.title || ctx.t('navigation.groupChat', { ns: 'electron' }),
|
||||
url: this.generateUrl(reference),
|
||||
};
|
||||
},
|
||||
|
||||
type: 'group',
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
import { type GroupTopicParams, type PageReference, type ResolvedPageData } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin, createPageReference } from './types';
|
||||
|
||||
const GROUP_PATH_REGEX = /^\/group\/([^/?]+)$/;
|
||||
|
||||
export const groupTopicPlugin: RecentlyViewedPlugin<'group-topic'> = {
|
||||
checkExists(reference: PageReference<'group-topic'>, ctx: PluginContext): boolean {
|
||||
const { groupId, topicId } = reference.params;
|
||||
const group = ctx.getSessionGroup(groupId);
|
||||
const topic = ctx.getTopic(topicId);
|
||||
|
||||
// Both group and topic must exist
|
||||
return group !== undefined && topic !== undefined;
|
||||
},
|
||||
|
||||
generateId(reference: PageReference<'group-topic'>): string {
|
||||
const { groupId, topicId } = reference.params;
|
||||
return `group-topic:${groupId}:${topicId}`;
|
||||
},
|
||||
|
||||
generateUrl(reference: PageReference<'group-topic'>): string {
|
||||
const { groupId, topicId } = reference.params;
|
||||
return `/group/${groupId}?topic=${topicId}`;
|
||||
},
|
||||
|
||||
getDefaultIcon() {
|
||||
return Users;
|
||||
},
|
||||
|
||||
// Higher priority than group plugin to match topic URLs first
|
||||
matchUrl(pathname: string, searchParams: URLSearchParams): boolean {
|
||||
// Match /group/:id with topic param
|
||||
return GROUP_PATH_REGEX.test(pathname) && searchParams.has('topic');
|
||||
},
|
||||
|
||||
parseUrl(pathname: string, searchParams: URLSearchParams): PageReference<'group-topic'> | null {
|
||||
const match = pathname.match(GROUP_PATH_REGEX);
|
||||
if (!match) return null;
|
||||
|
||||
const topicId = searchParams.get('topic');
|
||||
if (!topicId) return null;
|
||||
|
||||
const groupId = match[1];
|
||||
const params: GroupTopicParams = { groupId, topicId };
|
||||
const id = this.generateId({ params } as PageReference<'group-topic'>);
|
||||
|
||||
return createPageReference('group-topic', params, id);
|
||||
},
|
||||
|
||||
priority: 20,
|
||||
|
||||
resolve(reference: PageReference<'group-topic'>, ctx: PluginContext): ResolvedPageData {
|
||||
const { groupId, topicId } = reference.params;
|
||||
const group = ctx.getSessionGroup(groupId);
|
||||
const topic = ctx.getTopic(topicId);
|
||||
const cached = reference.cached;
|
||||
|
||||
const groupExists = group !== undefined;
|
||||
const topicExists = topic !== undefined;
|
||||
const hasStoreData = groupExists && topicExists;
|
||||
|
||||
// Use topic title if available, otherwise fall back to group name, then cached
|
||||
const title =
|
||||
topic?.title ||
|
||||
group?.name ||
|
||||
cached?.title ||
|
||||
ctx.t('navigation.groupChat', { ns: 'electron' });
|
||||
|
||||
return {
|
||||
exists: hasStoreData || cached !== undefined,
|
||||
icon: this.getDefaultIcon!(),
|
||||
reference,
|
||||
title,
|
||||
url: this.generateUrl(reference),
|
||||
};
|
||||
},
|
||||
|
||||
type: 'group-topic',
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { Home } from 'lucide-react';
|
||||
|
||||
import { type HomeParams, type PageReference, type ResolvedPageData } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin, createPageReference } from './types';
|
||||
|
||||
export const homePlugin: RecentlyViewedPlugin<'home'> = {
|
||||
checkExists(_reference: PageReference<'home'>, _ctx: PluginContext): boolean {
|
||||
return true; // Home page always exists
|
||||
},
|
||||
|
||||
generateId(_reference: PageReference<'home'>): string {
|
||||
return 'home';
|
||||
},
|
||||
|
||||
generateUrl(_reference: PageReference<'home'>): string {
|
||||
return '/';
|
||||
},
|
||||
|
||||
getDefaultIcon() {
|
||||
return Home;
|
||||
},
|
||||
|
||||
// Lowest priority, matched last
|
||||
matchUrl(pathname: string, _searchParams: URLSearchParams): boolean {
|
||||
return pathname === '/' || pathname === '';
|
||||
},
|
||||
|
||||
parseUrl(_pathname: string, _searchParams: URLSearchParams): PageReference<'home'> | null {
|
||||
const params: HomeParams = {};
|
||||
const id = this.generateId({ params } as PageReference<'home'>);
|
||||
|
||||
return createPageReference('home', params, id);
|
||||
},
|
||||
|
||||
priority: 1,
|
||||
|
||||
resolve(reference: PageReference<'home'>, ctx: PluginContext): ResolvedPageData {
|
||||
return {
|
||||
exists: true,
|
||||
icon: this.getDefaultIcon!(),
|
||||
reference,
|
||||
title: ctx.t('navigation.home' as any, { ns: 'electron' }) as string,
|
||||
url: this.generateUrl(reference),
|
||||
};
|
||||
},
|
||||
|
||||
type: 'home',
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { Image } from 'lucide-react';
|
||||
|
||||
import { getRouteById } from '@/config/routes';
|
||||
|
||||
import { type ImageParams, type PageReference, type ResolvedPageData } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin, createPageReference } from './types';
|
||||
|
||||
const imageIcon = getRouteById('image')?.icon || Image;
|
||||
|
||||
const IMAGE_PATH_REGEX = /^\/image(\/([^/?]+))?$/;
|
||||
|
||||
export const imagePlugin: RecentlyViewedPlugin<'image'> = {
|
||||
checkExists(_reference: PageReference<'image'>, _ctx: PluginContext): boolean {
|
||||
return true; // Static page always exists
|
||||
},
|
||||
generateId(reference: PageReference<'image'>): string {
|
||||
const { section } = reference.params;
|
||||
return section ? `image:${section}` : 'image';
|
||||
},
|
||||
|
||||
generateUrl(reference: PageReference<'image'>): string {
|
||||
const { section } = reference.params;
|
||||
return section ? `/image/${section}` : '/image';
|
||||
},
|
||||
|
||||
getDefaultIcon() {
|
||||
return imageIcon;
|
||||
},
|
||||
|
||||
matchUrl(pathname: string, _searchParams: URLSearchParams): boolean {
|
||||
return IMAGE_PATH_REGEX.test(pathname);
|
||||
},
|
||||
|
||||
parseUrl(pathname: string, _searchParams: URLSearchParams): PageReference<'image'> | null {
|
||||
const match = pathname.match(IMAGE_PATH_REGEX);
|
||||
if (!match) return null;
|
||||
|
||||
const section = match[2];
|
||||
const params: ImageParams = section ? { section } : {};
|
||||
const id = this.generateId({ params } as PageReference<'image'>);
|
||||
|
||||
return createPageReference('image', params, id);
|
||||
},
|
||||
|
||||
priority: 5,
|
||||
|
||||
resolve(reference: PageReference<'image'>, ctx: PluginContext): ResolvedPageData {
|
||||
return {
|
||||
exists: true,
|
||||
icon: this.getDefaultIcon!(),
|
||||
reference,
|
||||
title: ctx.t('navigation.image' as any, { ns: 'electron' }) as string,
|
||||
url: this.generateUrl(reference),
|
||||
};
|
||||
},
|
||||
|
||||
type: 'image',
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { agentPlugin } from './agentPlugin';
|
||||
import { agentTopicPlugin } from './agentTopicPlugin';
|
||||
import { communityPlugin } from './communityPlugin';
|
||||
import { groupPlugin } from './groupPlugin';
|
||||
import { groupTopicPlugin } from './groupTopicPlugin';
|
||||
import { homePlugin } from './homePlugin';
|
||||
import { imagePlugin } from './imagePlugin';
|
||||
import { memoryPlugin } from './memoryPlugin';
|
||||
import { pagePlugin } from './pagePlugin';
|
||||
import { pluginRegistry } from './registry';
|
||||
import { resourcePlugin } from './resourcePlugin';
|
||||
import { settingsPlugin } from './settingsPlugin';
|
||||
|
||||
export { pluginRegistry } from './registry';
|
||||
export * from './types';
|
||||
|
||||
export const loadAllRecentlyViewedPlugins = () => {
|
||||
pluginRegistry.register([
|
||||
agentPlugin,
|
||||
agentTopicPlugin,
|
||||
communityPlugin,
|
||||
groupPlugin,
|
||||
groupTopicPlugin,
|
||||
homePlugin,
|
||||
imagePlugin,
|
||||
memoryPlugin,
|
||||
pagePlugin,
|
||||
resourcePlugin,
|
||||
settingsPlugin,
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { Brain } from 'lucide-react';
|
||||
|
||||
import { getRouteById } from '@/config/routes';
|
||||
|
||||
import { type MemoryParams, type PageReference, type ResolvedPageData } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin, createPageReference } from './types';
|
||||
|
||||
const memoryIcon = getRouteById('memory')?.icon || Brain;
|
||||
|
||||
const MEMORY_PATH_REGEX = /^\/memory(\/([^/?]+))?$/;
|
||||
|
||||
// Section to title key mapping
|
||||
const sectionTitleKeys: Record<string, string> = {
|
||||
contexts: 'navigation.memoryContexts',
|
||||
experiences: 'navigation.memoryExperiences',
|
||||
identities: 'navigation.memoryIdentities',
|
||||
preferences: 'navigation.memoryPreferences',
|
||||
};
|
||||
|
||||
export const memoryPlugin: RecentlyViewedPlugin<'memory'> = {
|
||||
checkExists(_reference: PageReference<'memory'>, _ctx: PluginContext): boolean {
|
||||
return true; // Static page always exists
|
||||
},
|
||||
generateId(reference: PageReference<'memory'>): string {
|
||||
const { section } = reference.params;
|
||||
return section ? `memory:${section}` : 'memory';
|
||||
},
|
||||
|
||||
generateUrl(reference: PageReference<'memory'>): string {
|
||||
const { section } = reference.params;
|
||||
return section ? `/memory/${section}` : '/memory';
|
||||
},
|
||||
|
||||
getDefaultIcon() {
|
||||
return memoryIcon;
|
||||
},
|
||||
|
||||
matchUrl(pathname: string, _searchParams: URLSearchParams): boolean {
|
||||
return MEMORY_PATH_REGEX.test(pathname);
|
||||
},
|
||||
|
||||
parseUrl(pathname: string, _searchParams: URLSearchParams): PageReference<'memory'> | null {
|
||||
const match = pathname.match(MEMORY_PATH_REGEX);
|
||||
if (!match) return null;
|
||||
|
||||
const section = match[2];
|
||||
const params: MemoryParams = section ? { section } : {};
|
||||
const id = this.generateId({ params } as PageReference<'memory'>);
|
||||
|
||||
return createPageReference('memory', params, id);
|
||||
},
|
||||
|
||||
priority: 5,
|
||||
|
||||
resolve(reference: PageReference<'memory'>, ctx: PluginContext): ResolvedPageData {
|
||||
const { section } = reference.params;
|
||||
const titleKey = section
|
||||
? sectionTitleKeys[section] || 'navigation.memory'
|
||||
: 'navigation.memory';
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
icon: this.getDefaultIcon!(),
|
||||
reference,
|
||||
title: ctx.t(titleKey as any, { ns: 'electron' }) as string,
|
||||
url: this.generateUrl(reference),
|
||||
};
|
||||
},
|
||||
|
||||
type: 'memory',
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
import { getRouteById } from '@/config/routes';
|
||||
|
||||
import { type PageParams, type PageReference, type ResolvedPageData } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin, createPageReference } from './types';
|
||||
|
||||
const PAGE_PATH_REGEX = /^\/page\/([^/?]+)$/;
|
||||
|
||||
const pageIcon = getRouteById('page')?.icon || FileText;
|
||||
|
||||
export const pagePlugin: RecentlyViewedPlugin<'page'> = {
|
||||
checkExists(reference: PageReference<'page'>, ctx: PluginContext): boolean {
|
||||
const document = ctx.getDocument(reference.params.pageId);
|
||||
return document !== undefined;
|
||||
},
|
||||
generateId(reference: PageReference<'page'>): string {
|
||||
return `page:${reference.params.pageId}`;
|
||||
},
|
||||
|
||||
generateUrl(reference: PageReference<'page'>): string {
|
||||
return `/page/${reference.params.pageId}`;
|
||||
},
|
||||
|
||||
getDefaultIcon() {
|
||||
return pageIcon;
|
||||
},
|
||||
|
||||
matchUrl(pathname: string, _searchParams: URLSearchParams): boolean {
|
||||
return PAGE_PATH_REGEX.test(pathname);
|
||||
},
|
||||
|
||||
parseUrl(pathname: string, _searchParams: URLSearchParams): PageReference<'page'> | null {
|
||||
const match = pathname.match(PAGE_PATH_REGEX);
|
||||
if (!match) return null;
|
||||
|
||||
const pageId = match[1];
|
||||
const params: PageParams = { pageId };
|
||||
const id = this.generateId({ params } as PageReference<'page'>);
|
||||
|
||||
return createPageReference('page', params, id);
|
||||
},
|
||||
|
||||
priority: 10,
|
||||
|
||||
resolve(reference: PageReference<'page'>, ctx: PluginContext): ResolvedPageData {
|
||||
const document = ctx.getDocument(reference.params.pageId);
|
||||
const hasStoreData = document !== undefined;
|
||||
const cached = reference.cached;
|
||||
|
||||
return {
|
||||
exists: hasStoreData || cached !== undefined,
|
||||
icon: this.getDefaultIcon!(),
|
||||
reference,
|
||||
title: document?.title || cached?.title || ctx.t('navigation.page', { ns: 'electron' }),
|
||||
url: this.generateUrl(reference),
|
||||
};
|
||||
},
|
||||
|
||||
type: 'page',
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
/* eslint-disable no-dupe-class-members */
|
||||
import { type PageReference, type PageType, type ResolvedPageData } from '../types';
|
||||
import {
|
||||
type BaseRecentlyViewedPlugin,
|
||||
type PluginContext,
|
||||
type RecentlyViewedPlugin,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Plugin registry for RecentlyViewed system
|
||||
* Manages all page type plugins and provides URL parsing/resolution
|
||||
*/
|
||||
class PluginRegistry {
|
||||
private plugins: Map<PageType, BaseRecentlyViewedPlugin> = new Map();
|
||||
private sortedPlugins: BaseRecentlyViewedPlugin[] = [];
|
||||
|
||||
/**
|
||||
* Register multiple plugins at once
|
||||
*/
|
||||
|
||||
register<T extends PageType>(plugins: [RecentlyViewedPlugin<T>]): void;
|
||||
register<T extends PageType, T2 extends PageType>(
|
||||
plugins: [RecentlyViewedPlugin<T>, RecentlyViewedPlugin<T2>],
|
||||
): void;
|
||||
register<T extends PageType, T2 extends PageType, T3 extends PageType>(
|
||||
plugins: [RecentlyViewedPlugin<T>, RecentlyViewedPlugin<T2>, RecentlyViewedPlugin<T3>],
|
||||
): void;
|
||||
register<T extends PageType, T2 extends PageType, T3 extends PageType, T4 extends PageType>(
|
||||
plugins: [
|
||||
RecentlyViewedPlugin<T>,
|
||||
RecentlyViewedPlugin<T2>,
|
||||
RecentlyViewedPlugin<T3>,
|
||||
RecentlyViewedPlugin<T4>,
|
||||
],
|
||||
): void;
|
||||
register<
|
||||
T extends PageType,
|
||||
T2 extends PageType,
|
||||
T3 extends PageType,
|
||||
T4 extends PageType,
|
||||
T5 extends PageType,
|
||||
>(
|
||||
plugins: [
|
||||
RecentlyViewedPlugin<T>,
|
||||
RecentlyViewedPlugin<T2>,
|
||||
RecentlyViewedPlugin<T3>,
|
||||
RecentlyViewedPlugin<T4>,
|
||||
RecentlyViewedPlugin<T5>,
|
||||
],
|
||||
): void;
|
||||
register<
|
||||
T extends PageType,
|
||||
T2 extends PageType,
|
||||
T3 extends PageType,
|
||||
T4 extends PageType,
|
||||
T5 extends PageType,
|
||||
T6 extends PageType,
|
||||
>(
|
||||
plugins: [
|
||||
RecentlyViewedPlugin<T>,
|
||||
RecentlyViewedPlugin<T2>,
|
||||
RecentlyViewedPlugin<T3>,
|
||||
RecentlyViewedPlugin<T4>,
|
||||
RecentlyViewedPlugin<T5>,
|
||||
RecentlyViewedPlugin<T6>,
|
||||
],
|
||||
): void;
|
||||
register<
|
||||
T extends PageType,
|
||||
T2 extends PageType,
|
||||
T3 extends PageType,
|
||||
T4 extends PageType,
|
||||
T5 extends PageType,
|
||||
T6 extends PageType,
|
||||
T7 extends PageType,
|
||||
>(
|
||||
plugins: [
|
||||
RecentlyViewedPlugin<T>,
|
||||
RecentlyViewedPlugin<T2>,
|
||||
RecentlyViewedPlugin<T3>,
|
||||
RecentlyViewedPlugin<T4>,
|
||||
RecentlyViewedPlugin<T5>,
|
||||
RecentlyViewedPlugin<T6>,
|
||||
RecentlyViewedPlugin<T7>,
|
||||
],
|
||||
): void;
|
||||
register<
|
||||
T extends PageType,
|
||||
T2 extends PageType,
|
||||
T3 extends PageType,
|
||||
T4 extends PageType,
|
||||
T5 extends PageType,
|
||||
T6 extends PageType,
|
||||
T7 extends PageType,
|
||||
T8 extends PageType,
|
||||
>(
|
||||
plugins: [
|
||||
RecentlyViewedPlugin<T>,
|
||||
RecentlyViewedPlugin<T2>,
|
||||
RecentlyViewedPlugin<T3>,
|
||||
RecentlyViewedPlugin<T4>,
|
||||
RecentlyViewedPlugin<T5>,
|
||||
RecentlyViewedPlugin<T6>,
|
||||
RecentlyViewedPlugin<T7>,
|
||||
RecentlyViewedPlugin<T8>,
|
||||
],
|
||||
): void;
|
||||
register<
|
||||
T extends PageType,
|
||||
T2 extends PageType,
|
||||
T3 extends PageType,
|
||||
T4 extends PageType,
|
||||
T5 extends PageType,
|
||||
T6 extends PageType,
|
||||
T7 extends PageType,
|
||||
T8 extends PageType,
|
||||
T9 extends PageType,
|
||||
>(
|
||||
plugins: [
|
||||
RecentlyViewedPlugin<T>,
|
||||
RecentlyViewedPlugin<T2>,
|
||||
RecentlyViewedPlugin<T3>,
|
||||
RecentlyViewedPlugin<T4>,
|
||||
RecentlyViewedPlugin<T5>,
|
||||
RecentlyViewedPlugin<T6>,
|
||||
RecentlyViewedPlugin<T7>,
|
||||
RecentlyViewedPlugin<T8>,
|
||||
RecentlyViewedPlugin<T9>,
|
||||
],
|
||||
): void;
|
||||
register<
|
||||
T extends PageType,
|
||||
T2 extends PageType,
|
||||
T3 extends PageType,
|
||||
T4 extends PageType,
|
||||
T5 extends PageType,
|
||||
T6 extends PageType,
|
||||
T7 extends PageType,
|
||||
T8 extends PageType,
|
||||
T9 extends PageType,
|
||||
T10 extends PageType,
|
||||
>(
|
||||
plugins: [
|
||||
RecentlyViewedPlugin<T>,
|
||||
RecentlyViewedPlugin<T2>,
|
||||
RecentlyViewedPlugin<T3>,
|
||||
RecentlyViewedPlugin<T4>,
|
||||
RecentlyViewedPlugin<T5>,
|
||||
RecentlyViewedPlugin<T6>,
|
||||
RecentlyViewedPlugin<T7>,
|
||||
RecentlyViewedPlugin<T8>,
|
||||
RecentlyViewedPlugin<T9>,
|
||||
RecentlyViewedPlugin<T10>,
|
||||
],
|
||||
): void;
|
||||
register<
|
||||
T extends PageType,
|
||||
T2 extends PageType,
|
||||
T3 extends PageType,
|
||||
T4 extends PageType,
|
||||
T5 extends PageType,
|
||||
T6 extends PageType,
|
||||
T7 extends PageType,
|
||||
T8 extends PageType,
|
||||
T9 extends PageType,
|
||||
T10 extends PageType,
|
||||
T11 extends PageType,
|
||||
>(
|
||||
plugins: [
|
||||
RecentlyViewedPlugin<T>,
|
||||
RecentlyViewedPlugin<T2>,
|
||||
RecentlyViewedPlugin<T3>,
|
||||
RecentlyViewedPlugin<T4>,
|
||||
RecentlyViewedPlugin<T5>,
|
||||
RecentlyViewedPlugin<T6>,
|
||||
RecentlyViewedPlugin<T7>,
|
||||
RecentlyViewedPlugin<T8>,
|
||||
RecentlyViewedPlugin<T9>,
|
||||
RecentlyViewedPlugin<T10>,
|
||||
RecentlyViewedPlugin<T11>,
|
||||
],
|
||||
): void;
|
||||
register(plugins: BaseRecentlyViewedPlugin[]): void {
|
||||
for (const plugin of plugins) {
|
||||
this.plugins.set(plugin.type, plugin);
|
||||
}
|
||||
|
||||
this.updateSortedPlugins();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse URL into a page reference using registered plugins
|
||||
* Returns null if no plugin matches
|
||||
*/
|
||||
parseUrl(pathname: string, search: string): PageReference | null {
|
||||
const searchParams = new URLSearchParams(search);
|
||||
|
||||
for (const plugin of this.sortedPlugins) {
|
||||
if (plugin.matchUrl(pathname, searchParams)) {
|
||||
const reference = plugin.parseUrl(pathname, searchParams);
|
||||
if (reference) {
|
||||
return reference;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a page reference into display data
|
||||
*/
|
||||
resolve(reference: PageReference, ctx: PluginContext): ResolvedPageData | null {
|
||||
const plugin = this.plugins.get(reference.type);
|
||||
if (!plugin) return null;
|
||||
|
||||
return plugin.resolve(reference, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve multiple page references, filtering out non-existent ones
|
||||
*/
|
||||
resolveAll(references: PageReference[], ctx: PluginContext): ResolvedPageData[] {
|
||||
const results: ResolvedPageData[] = [];
|
||||
|
||||
for (const reference of references) {
|
||||
const resolved = this.resolve(reference, ctx);
|
||||
if (resolved && resolved.exists) {
|
||||
results.push(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sorted plugins list by priority
|
||||
*/
|
||||
private updateSortedPlugins(): void {
|
||||
this.sortedPlugins = Array.from(this.plugins.values()).sort(
|
||||
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginRegistry = new PluginRegistry();
|
||||
@@ -0,0 +1,69 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { Database } from 'lucide-react';
|
||||
|
||||
import { getRouteById } from '@/config/routes';
|
||||
|
||||
import { type PageReference, type ResolvedPageData, type ResourceParams } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin, createPageReference } from './types';
|
||||
|
||||
const resourceIcon = getRouteById('resource')?.icon || Database;
|
||||
|
||||
const RESOURCE_PATH_REGEX = /^\/resource(\/([^/?]+))?$/;
|
||||
|
||||
// Section to title key mapping
|
||||
const sectionTitleKeys: Record<string, string> = {
|
||||
library: 'navigation.knowledgeBase',
|
||||
};
|
||||
|
||||
export const resourcePlugin: RecentlyViewedPlugin<'resource'> = {
|
||||
checkExists(_reference: PageReference<'resource'>, _ctx: PluginContext): boolean {
|
||||
return true; // Static page always exists
|
||||
},
|
||||
generateId(reference: PageReference<'resource'>): string {
|
||||
const { section } = reference.params;
|
||||
return section ? `resource:${section}` : 'resource';
|
||||
},
|
||||
|
||||
generateUrl(reference: PageReference<'resource'>): string {
|
||||
const { section } = reference.params;
|
||||
return section ? `/resource/${section}` : '/resource';
|
||||
},
|
||||
|
||||
getDefaultIcon() {
|
||||
return resourceIcon;
|
||||
},
|
||||
|
||||
matchUrl(pathname: string, _searchParams: URLSearchParams): boolean {
|
||||
return RESOURCE_PATH_REGEX.test(pathname);
|
||||
},
|
||||
|
||||
parseUrl(pathname: string, _searchParams: URLSearchParams): PageReference<'resource'> | null {
|
||||
const match = pathname.match(RESOURCE_PATH_REGEX);
|
||||
if (!match) return null;
|
||||
|
||||
const section = match[2];
|
||||
const params: ResourceParams = section ? { section } : {};
|
||||
const id = this.generateId({ params } as PageReference<'resource'>);
|
||||
|
||||
return createPageReference('resource', params, id);
|
||||
},
|
||||
|
||||
priority: 5,
|
||||
|
||||
resolve(reference: PageReference<'resource'>, ctx: PluginContext): ResolvedPageData {
|
||||
const { section } = reference.params;
|
||||
const titleKey = section
|
||||
? sectionTitleKeys[section] || 'navigation.resources'
|
||||
: 'navigation.resources';
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
icon: this.getDefaultIcon!(),
|
||||
reference,
|
||||
title: ctx.t(titleKey as any, { ns: 'electron' }) as string,
|
||||
url: this.generateUrl(reference),
|
||||
};
|
||||
},
|
||||
|
||||
type: 'resource',
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { Settings } from 'lucide-react';
|
||||
|
||||
import { getRouteById } from '@/config/routes';
|
||||
|
||||
import { type PageReference, type ResolvedPageData, type SettingsParams } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin, createPageReference } from './types';
|
||||
|
||||
const settingsIcon = getRouteById('settings')?.icon || Settings;
|
||||
|
||||
const SETTINGS_PATH_REGEX = /^\/settings(\/([^/?]+))?$/;
|
||||
|
||||
export const settingsPlugin: RecentlyViewedPlugin<'settings'> = {
|
||||
checkExists(_reference: PageReference<'settings'>, _ctx: PluginContext): boolean {
|
||||
return true; // Static page always exists
|
||||
},
|
||||
generateId(reference: PageReference<'settings'>): string {
|
||||
const { section } = reference.params;
|
||||
return section ? `settings:${section}` : 'settings';
|
||||
},
|
||||
|
||||
generateUrl(reference: PageReference<'settings'>): string {
|
||||
const { section } = reference.params;
|
||||
return section ? `/settings/${section}` : '/settings';
|
||||
},
|
||||
|
||||
getDefaultIcon() {
|
||||
return settingsIcon;
|
||||
},
|
||||
|
||||
matchUrl(pathname: string, _searchParams: URLSearchParams): boolean {
|
||||
return SETTINGS_PATH_REGEX.test(pathname);
|
||||
},
|
||||
|
||||
parseUrl(pathname: string, _searchParams: URLSearchParams): PageReference<'settings'> | null {
|
||||
const match = pathname.match(SETTINGS_PATH_REGEX);
|
||||
if (!match) return null;
|
||||
|
||||
const section = match[2]; // Optional section like 'provider'
|
||||
const params: SettingsParams = section ? { section } : {};
|
||||
const id = this.generateId({ params } as PageReference<'settings'>);
|
||||
|
||||
return createPageReference('settings', params, id);
|
||||
},
|
||||
|
||||
priority: 5,
|
||||
|
||||
resolve(reference: PageReference<'settings'>, ctx: PluginContext): ResolvedPageData {
|
||||
const { section } = reference.params;
|
||||
|
||||
// Get title based on section
|
||||
let titleKey = 'navigation.settings';
|
||||
if (section === 'provider') {
|
||||
titleKey = 'navigation.provider';
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
icon: this.getDefaultIcon!(),
|
||||
reference,
|
||||
title: ctx.t(titleKey as any, { ns: 'electron' }) as string,
|
||||
url: this.generateUrl(reference),
|
||||
};
|
||||
},
|
||||
|
||||
type: 'settings',
|
||||
};
|
||||
167
src/features/Electron/titlebar/RecentlyViewed/plugins/types.ts
Normal file
167
src/features/Electron/titlebar/RecentlyViewed/plugins/types.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
|
||||
import { type LobeDocument } from '@/types/document';
|
||||
import { type MetaData } from '@/types/meta';
|
||||
import { type SessionGroupItem } from '@/types/session';
|
||||
import { type ChatTopic } from '@/types/topic';
|
||||
|
||||
import {
|
||||
type PageParamsMap,
|
||||
type PageReference,
|
||||
type PageType,
|
||||
type ResolvedPageData,
|
||||
} from '../types';
|
||||
|
||||
// ======== Plugin Context ======== //
|
||||
|
||||
/**
|
||||
* Context provided to plugins for data access
|
||||
* This abstracts away direct store access
|
||||
*/
|
||||
export interface PluginContext {
|
||||
/**
|
||||
* Get agent metadata by ID
|
||||
*/
|
||||
getAgentMeta: (agentId: string) => MetaData | undefined;
|
||||
/**
|
||||
* Get document by ID
|
||||
*/
|
||||
getDocument: (documentId: string) => LobeDocument | undefined;
|
||||
/**
|
||||
* Get session/group by ID
|
||||
*/
|
||||
getSessionGroup: (groupId: string) => SessionGroupItem | undefined;
|
||||
/**
|
||||
* Get topic by ID from current context
|
||||
*/
|
||||
getTopic: (topicId: string) => ChatTopic | undefined;
|
||||
/**
|
||||
* i18n translation function
|
||||
*/
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
// ======== Plugin Interface ======== //
|
||||
|
||||
/**
|
||||
* Base plugin interface (non-generic for registry use)
|
||||
*/
|
||||
export interface BaseRecentlyViewedPlugin {
|
||||
/**
|
||||
* Check if the underlying data exists
|
||||
*/
|
||||
checkExists: (reference: PageReference, ctx: PluginContext) => boolean;
|
||||
|
||||
/**
|
||||
* Generate unique ID from reference params
|
||||
*/
|
||||
generateId: (reference: PageReference) => string;
|
||||
|
||||
/**
|
||||
* Generate navigation URL from reference
|
||||
*/
|
||||
generateUrl: (reference: PageReference) => string;
|
||||
|
||||
/**
|
||||
* Get default icon for this page type
|
||||
*/
|
||||
getDefaultIcon?: () => LucideIcon;
|
||||
|
||||
/**
|
||||
* Check if URL matches this plugin
|
||||
*/
|
||||
matchUrl: (pathname: string, searchParams: URLSearchParams) => boolean;
|
||||
|
||||
/**
|
||||
* Parse URL into a page reference
|
||||
*/
|
||||
parseUrl: (pathname: string, searchParams: URLSearchParams) => PageReference | null;
|
||||
|
||||
/**
|
||||
* Priority for URL matching (higher = checked first)
|
||||
*/
|
||||
readonly priority?: number;
|
||||
|
||||
/**
|
||||
* Resolve reference into display data
|
||||
*/
|
||||
resolve: (reference: PageReference, ctx: PluginContext) => ResolvedPageData;
|
||||
|
||||
/**
|
||||
* Page type this plugin handles
|
||||
*/
|
||||
readonly type: PageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed plugin interface for implementation
|
||||
* Each page type should have its own plugin implementation
|
||||
*/
|
||||
export interface RecentlyViewedPlugin<T extends PageType = PageType> {
|
||||
/**
|
||||
* Check if the underlying data exists
|
||||
* Used to filter out stale entries
|
||||
*/
|
||||
checkExists: (reference: PageReference<T>, ctx: PluginContext) => boolean;
|
||||
|
||||
/**
|
||||
* Generate unique ID from reference params
|
||||
* e.g., "agent:abc123" or "agent-topic:abc123:topic456"
|
||||
*/
|
||||
generateId: (reference: PageReference<T>) => string;
|
||||
|
||||
/**
|
||||
* Generate navigation URL from reference
|
||||
*/
|
||||
generateUrl: (reference: PageReference<T>) => string;
|
||||
|
||||
/**
|
||||
* Get default icon for this page type
|
||||
*/
|
||||
getDefaultIcon?: () => LucideIcon;
|
||||
|
||||
/**
|
||||
* Check if URL matches this plugin
|
||||
*/
|
||||
matchUrl: (pathname: string, searchParams: URLSearchParams) => boolean;
|
||||
|
||||
/**
|
||||
* Parse URL into a page reference
|
||||
* Returns null if URL doesn't match
|
||||
*/
|
||||
parseUrl: (pathname: string, searchParams: URLSearchParams) => PageReference<T> | null;
|
||||
|
||||
/**
|
||||
* Priority for URL matching (higher = checked first)
|
||||
* Used when multiple plugins could match the same URL
|
||||
*/
|
||||
readonly priority?: number;
|
||||
|
||||
/**
|
||||
* Resolve reference into display data
|
||||
*/
|
||||
resolve: (reference: PageReference<T>, ctx: PluginContext) => ResolvedPageData;
|
||||
|
||||
/**
|
||||
* Page type this plugin handles
|
||||
*/
|
||||
readonly type: T;
|
||||
}
|
||||
|
||||
// ======== Helper Types ======== //
|
||||
|
||||
/**
|
||||
* Helper to create typed page reference
|
||||
*/
|
||||
export function createPageReference<T extends PageType>(
|
||||
type: T,
|
||||
params: PageParamsMap[T],
|
||||
id: string,
|
||||
): PageReference<T> {
|
||||
return {
|
||||
id,
|
||||
lastVisited: Date.now(),
|
||||
params,
|
||||
type,
|
||||
};
|
||||
}
|
||||
59
src/features/Electron/titlebar/RecentlyViewed/storage.ts
Normal file
59
src/features/Electron/titlebar/RecentlyViewed/storage.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { type PageReference } from './types';
|
||||
|
||||
export const PINNED_PAGES_STORAGE_KEY = 'lobechat:desktop:pinned-pages:v2';
|
||||
|
||||
/**
|
||||
* Get pinned pages from localStorage
|
||||
*/
|
||||
export const getPinnedPages = (): PageReference[] => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const data = window.localStorage.getItem(PINNED_PAGES_STORAGE_KEY);
|
||||
if (!data) return [];
|
||||
|
||||
const parsed = JSON.parse(data);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
// Validate each entry has required fields
|
||||
return parsed.filter(
|
||||
(item): item is PageReference =>
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
typeof item.id === 'string' &&
|
||||
typeof item.type === 'string' &&
|
||||
typeof item.lastVisited === 'number' &&
|
||||
item.params !== undefined,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save pinned pages to localStorage
|
||||
*/
|
||||
export const savePinnedPages = (pages: PageReference[]): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(PINNED_PAGES_STORAGE_KEY, JSON.stringify(pages));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear pinned pages from localStorage
|
||||
*/
|
||||
export const clearPinnedPages = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem(PINNED_PAGES_STORAGE_KEY);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
83
src/features/Electron/titlebar/RecentlyViewed/styles.ts
Normal file
83
src/features/Electron/titlebar/RecentlyViewed/styles.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
|
||||
export const useStyles = createStaticStyles(({ css, cssVar }) => ({
|
||||
actionIcon: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ${cssVar.motionEaseOut};
|
||||
`,
|
||||
container: css`
|
||||
overflow-y: auto;
|
||||
width: 260px;
|
||||
max-height: 320px;
|
||||
padding: 4px;
|
||||
`,
|
||||
divider: css`
|
||||
height: 1px;
|
||||
margin-block: 4px;
|
||||
background-color: ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
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: default;
|
||||
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
padding-block: 3px;
|
||||
padding-inline: 8px;
|
||||
border-radius: ${cssVar.borderRadiusSM};
|
||||
|
||||
transition: background-color 0.15s ${cssVar.motionEaseInOut};
|
||||
|
||||
&:hover {
|
||||
background-color: ${cssVar.colorFillSecondary};
|
||||
}
|
||||
|
||||
&:hover .actionIcon {
|
||||
color: ${cssVar.colorText};
|
||||
opacity: 1;
|
||||
}
|
||||
`,
|
||||
itemActive: css`
|
||||
background-color: ${cssVar.colorFillTertiary};
|
||||
|
||||
&:hover {
|
||||
background-color: ${cssVar.colorFillSecondary};
|
||||
}
|
||||
`,
|
||||
itemHovered: css`
|
||||
background-color: ${cssVar.colorFillSecondary};
|
||||
`,
|
||||
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: capitalize;
|
||||
letter-spacing: 0.5px;
|
||||
`,
|
||||
}));
|
||||
177
src/features/Electron/titlebar/RecentlyViewed/types.ts
Normal file
177
src/features/Electron/titlebar/RecentlyViewed/types.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
|
||||
// ======== Page Types ======== //
|
||||
|
||||
/**
|
||||
* All supported page types for recently viewed
|
||||
*/
|
||||
export type PageType =
|
||||
| 'agent'
|
||||
| 'agent-topic'
|
||||
| 'group'
|
||||
| 'group-topic'
|
||||
| 'page'
|
||||
| 'settings'
|
||||
| 'community'
|
||||
| 'resource'
|
||||
| 'memory'
|
||||
| 'image'
|
||||
| 'home';
|
||||
|
||||
// ======== Page Params ======== //
|
||||
|
||||
export interface AgentParams {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export interface AgentTopicParams {
|
||||
agentId: string;
|
||||
topicId: string;
|
||||
}
|
||||
|
||||
export interface GroupParams {
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export interface GroupTopicParams {
|
||||
groupId: string;
|
||||
topicId: string;
|
||||
}
|
||||
|
||||
export interface PageParams {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export interface SettingsParams {
|
||||
section?: string;
|
||||
}
|
||||
|
||||
export interface CommunityParams {
|
||||
section?: string;
|
||||
}
|
||||
|
||||
export interface ResourceParams {
|
||||
section?: string;
|
||||
}
|
||||
|
||||
export interface MemoryParams {
|
||||
section?: string;
|
||||
}
|
||||
|
||||
export interface ImageParams {
|
||||
section?: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface HomeParams {}
|
||||
|
||||
/**
|
||||
* Type-safe params mapping for each page type
|
||||
*/
|
||||
export interface PageParamsMap {
|
||||
'agent': AgentParams;
|
||||
'agent-topic': AgentTopicParams;
|
||||
'community': CommunityParams;
|
||||
'group': GroupParams;
|
||||
'group-topic': GroupTopicParams;
|
||||
'home': HomeParams;
|
||||
'image': ImageParams;
|
||||
'memory': MemoryParams;
|
||||
'page': PageParams;
|
||||
'resource': ResourceParams;
|
||||
'settings': SettingsParams;
|
||||
}
|
||||
|
||||
// ======== Cached Display Data ======== //
|
||||
|
||||
/**
|
||||
* Cached display data stored with page reference
|
||||
* Used as fallback when store data is not available
|
||||
*/
|
||||
export interface CachedPageData {
|
||||
/**
|
||||
* Avatar URL
|
||||
*/
|
||||
avatar?: string;
|
||||
/**
|
||||
* Background color for avatar
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
/**
|
||||
* Display title
|
||||
*/
|
||||
title: string;
|
||||
}
|
||||
|
||||
// ======== Page Reference (Storage) ======== //
|
||||
|
||||
/**
|
||||
* Structured page reference for storage
|
||||
* This replaces the old PageEntry type
|
||||
*/
|
||||
export interface PageReference<T extends PageType = PageType> {
|
||||
/**
|
||||
* Cached display data for when store data is unavailable
|
||||
* This ensures pinned pages can display even after app restart
|
||||
*/
|
||||
cached?: CachedPageData;
|
||||
/**
|
||||
* Unique identifier combining type and params
|
||||
* e.g., "agent:abc123" or "agent-topic:abc123:topic456"
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Timestamp of last visit
|
||||
*/
|
||||
lastVisited: number;
|
||||
/**
|
||||
* Type-specific parameters
|
||||
*/
|
||||
params: PageParamsMap[T];
|
||||
/**
|
||||
* Page type
|
||||
*/
|
||||
type: T;
|
||||
/**
|
||||
* Visit count for sorting/analytics
|
||||
*/
|
||||
visitCount?: number;
|
||||
}
|
||||
|
||||
// ======== Resolved Page Data (Display) ======== //
|
||||
|
||||
/**
|
||||
* Resolved page data ready for rendering
|
||||
* Contains all display information generated by plugins
|
||||
*/
|
||||
export interface ResolvedPageData {
|
||||
/**
|
||||
* Avatar URL for agent/group pages
|
||||
*/
|
||||
avatar?: string;
|
||||
/**
|
||||
* Background color for avatar
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
/**
|
||||
* Whether the underlying data exists
|
||||
* Pages with exists=false should be filtered out
|
||||
*/
|
||||
exists: boolean;
|
||||
/**
|
||||
* Icon to display
|
||||
*/
|
||||
icon?: LucideIcon;
|
||||
/**
|
||||
* Original reference for navigation and pin/unpin
|
||||
*/
|
||||
reference: PageReference;
|
||||
/**
|
||||
* Display title
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Generated URL for navigation
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
@@ -16,13 +16,17 @@ export default {
|
||||
'navigation.memoryExperiences': 'Memory - Experiences',
|
||||
'navigation.memoryIdentities': 'Memory - Identities',
|
||||
'navigation.memoryPreferences': 'Memory - Preferences',
|
||||
'navigation.noPages': 'No pages yet',
|
||||
'navigation.onboarding': 'Onboarding',
|
||||
'navigation.page': 'Page',
|
||||
'navigation.pages': 'Pages',
|
||||
'navigation.pin': 'Pin',
|
||||
'navigation.pinned': 'Pinned',
|
||||
'navigation.provider': 'Provider',
|
||||
'navigation.recentView': 'Recent pages',
|
||||
'navigation.resources': 'Resources',
|
||||
'navigation.settings': 'Settings',
|
||||
'navigation.unpin': 'Unpin',
|
||||
'notification.finishChatGeneration': 'AI message generation completed',
|
||||
'proxy.auth': 'Authentication Required',
|
||||
'proxy.authDesc': 'If the proxy server requires a username and password',
|
||||
|
||||
193
src/store/electron/actions/recentPages.ts
Normal file
193
src/store/electron/actions/recentPages.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { type StateCreator } from 'zustand/vanilla';
|
||||
|
||||
import {
|
||||
getPinnedPages,
|
||||
savePinnedPages,
|
||||
} from '@/features/Electron/titlebar/RecentlyViewed/storage';
|
||||
import {
|
||||
type CachedPageData,
|
||||
type PageReference,
|
||||
} from '@/features/Electron/titlebar/RecentlyViewed/types';
|
||||
|
||||
import type { ElectronStore } from '../store';
|
||||
|
||||
// ======== Constants ======== //
|
||||
|
||||
const RECENT_PAGES_LIMIT = 20;
|
||||
const PINNED_PAGES_LIMIT = 10;
|
||||
|
||||
// ======== Types ======== //
|
||||
|
||||
export interface RecentPagesState {
|
||||
pinnedPages: PageReference[];
|
||||
recentPages: PageReference[];
|
||||
}
|
||||
|
||||
// ======== Action Interface ======== //
|
||||
|
||||
export interface RecentPagesAction {
|
||||
/**
|
||||
* Add/update a page reference in recent list (auto-dedupe)
|
||||
* @param reference - The page reference to add
|
||||
* @param cached - Optional cached display data (title, avatar, etc.)
|
||||
*/
|
||||
addRecentPage: (reference: PageReference, cached?: CachedPageData) => void;
|
||||
|
||||
/**
|
||||
* Clear all recent pages
|
||||
*/
|
||||
clearRecentPages: () => void;
|
||||
|
||||
/**
|
||||
* Check if a page is pinned by its ID
|
||||
*/
|
||||
isPagePinned: (id: string) => boolean;
|
||||
|
||||
/**
|
||||
* Load pinned pages from localStorage (called on init)
|
||||
*/
|
||||
loadPinnedPages: () => void;
|
||||
|
||||
/**
|
||||
* Add a page to pinned list
|
||||
*/
|
||||
pinPage: (reference: PageReference) => void;
|
||||
|
||||
/**
|
||||
* Remove a page from recent list by ID
|
||||
*/
|
||||
removeRecentPage: (id: string) => void;
|
||||
|
||||
/**
|
||||
* Remove a page from pinned list by ID
|
||||
*/
|
||||
unpinPage: (id: string) => void;
|
||||
}
|
||||
|
||||
// ======== Initial State ======== //
|
||||
|
||||
export const recentPagesInitialState: RecentPagesState = {
|
||||
pinnedPages: [],
|
||||
recentPages: [],
|
||||
};
|
||||
|
||||
// ======== Action Implementation ======== //
|
||||
|
||||
export const createRecentPagesSlice: StateCreator<
|
||||
ElectronStore,
|
||||
[['zustand/devtools', never]],
|
||||
[],
|
||||
RecentPagesAction
|
||||
> = (set, get) => ({
|
||||
addRecentPage: (reference, cached) => {
|
||||
const { pinnedPages, recentPages } = get();
|
||||
const { id } = reference;
|
||||
|
||||
// If pinned, update cached data on pinned entry
|
||||
const pinnedIndex = pinnedPages.findIndex((p) => p.id === id);
|
||||
if (pinnedIndex >= 0) {
|
||||
if (cached) {
|
||||
const updatedPinned = [...pinnedPages];
|
||||
updatedPinned[pinnedIndex] = {
|
||||
...updatedPinned[pinnedIndex],
|
||||
cached: { ...updatedPinned[pinnedIndex].cached, ...cached },
|
||||
};
|
||||
set({ pinnedPages: updatedPinned }, false, 'updatePinnedPageCache');
|
||||
savePinnedPages(updatedPinned);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Find existing entry
|
||||
const existingIndex = recentPages.findIndex((p) => p.id === id);
|
||||
const existingEntry = existingIndex >= 0 ? recentPages[existingIndex] : null;
|
||||
|
||||
// Merge cached data: new cached takes precedence, but preserve existing fields if not provided
|
||||
const mergedCached = cached ? { ...existingEntry?.cached, ...cached } : existingEntry?.cached;
|
||||
|
||||
const newEntry: PageReference = {
|
||||
...reference,
|
||||
cached: mergedCached,
|
||||
lastVisited: Date.now(),
|
||||
visitCount: (existingEntry?.visitCount || 0) + 1,
|
||||
};
|
||||
|
||||
// Remove existing if present
|
||||
const filtered =
|
||||
existingIndex >= 0 ? recentPages.filter((_, i) => i !== existingIndex) : recentPages;
|
||||
|
||||
// Add to front, enforce limit
|
||||
const newRecent = [newEntry, ...filtered].slice(0, RECENT_PAGES_LIMIT);
|
||||
|
||||
set({ recentPages: newRecent }, false, 'addRecentPage');
|
||||
},
|
||||
|
||||
clearRecentPages: () => {
|
||||
set({ recentPages: [] }, false, 'clearRecentPages');
|
||||
},
|
||||
|
||||
isPagePinned: (id) => {
|
||||
return get().pinnedPages.some((p) => p.id === id);
|
||||
},
|
||||
|
||||
loadPinnedPages: () => {
|
||||
const pinned = getPinnedPages();
|
||||
const { recentPages } = get();
|
||||
|
||||
const pinnedIds = new Set(pinned.map((p) => p.id));
|
||||
|
||||
// Filter out any pages from recent that are now in pinned
|
||||
// This handles the race condition where addRecentPage runs before loadPinnedPages
|
||||
const filteredRecent = recentPages.filter((p) => !pinnedIds.has(p.id));
|
||||
|
||||
set({ pinnedPages: pinned, recentPages: filteredRecent }, false, 'loadPinnedPages');
|
||||
},
|
||||
|
||||
pinPage: (reference) => {
|
||||
const { pinnedPages, recentPages } = get();
|
||||
const { id } = reference;
|
||||
|
||||
// Check if already pinned
|
||||
if (pinnedPages.some((p) => p.id === id)) return;
|
||||
|
||||
// Check if pinned list is full
|
||||
if (pinnedPages.length >= PINNED_PAGES_LIMIT) return;
|
||||
|
||||
// Find existing entry in recent to preserve cached data
|
||||
const existingRecent = recentPages.find((p) => p.id === id);
|
||||
|
||||
const newEntry: PageReference = {
|
||||
...reference,
|
||||
// Preserve cached data from recent page if available
|
||||
cached: reference.cached ?? existingRecent?.cached,
|
||||
lastVisited: Date.now(),
|
||||
};
|
||||
|
||||
// Add to pinned, remove from recent if exists
|
||||
const newPinned = [...pinnedPages, newEntry];
|
||||
const newRecent = recentPages.filter((p) => p.id !== id);
|
||||
|
||||
set({ pinnedPages: newPinned, recentPages: newRecent }, false, 'pinPage');
|
||||
savePinnedPages(newPinned);
|
||||
},
|
||||
|
||||
removeRecentPage: (id) => {
|
||||
const { recentPages } = get();
|
||||
set({ recentPages: recentPages.filter((p) => p.id !== id) }, false, 'removeRecentPage');
|
||||
},
|
||||
|
||||
unpinPage: (id) => {
|
||||
const { pinnedPages, recentPages } = get();
|
||||
const page = pinnedPages.find((p) => p.id === id);
|
||||
|
||||
if (!page) return;
|
||||
|
||||
const newPinned = pinnedPages.filter((p) => p.id !== id);
|
||||
|
||||
// Add back to recent (at the front)
|
||||
const newRecent = [page, ...recentPages].slice(0, RECENT_PAGES_LIMIT);
|
||||
|
||||
set({ pinnedPages: newPinned, recentPages: newRecent }, false, 'unpinPage');
|
||||
savePinnedPages(newPinned);
|
||||
},
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type NavigationHistoryState,
|
||||
navigationHistoryInitialState,
|
||||
} from './actions/navigationHistory';
|
||||
import { type RecentPagesState, recentPagesInitialState } from './actions/recentPages';
|
||||
|
||||
export type RemoteServerError = 'CONFIG_ERROR' | 'AUTH_ERROR' | 'DISCONNECT_ERROR';
|
||||
|
||||
@@ -20,7 +21,7 @@ export const defaultProxySettings: NetworkProxySettings = {
|
||||
proxyType: 'http',
|
||||
};
|
||||
|
||||
export interface ElectronState extends NavigationHistoryState {
|
||||
export interface ElectronState extends NavigationHistoryState, RecentPagesState {
|
||||
appState: ElectronAppState;
|
||||
dataSyncConfig: DataSyncConfig;
|
||||
desktopHotkeys: Record<string, string>;
|
||||
@@ -36,6 +37,7 @@ export interface ElectronState extends NavigationHistoryState {
|
||||
|
||||
export const initialState: ElectronState = {
|
||||
...navigationHistoryInitialState,
|
||||
...recentPagesInitialState,
|
||||
appState: {},
|
||||
dataSyncConfig: { storageMode: 'cloud' },
|
||||
desktopHotkeys: {},
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type NavigationHistoryAction,
|
||||
createNavigationHistorySlice,
|
||||
} from './actions/navigationHistory';
|
||||
import { type RecentPagesAction, createRecentPagesSlice } from './actions/recentPages';
|
||||
import { type ElectronSettingsAction, settingsSlice } from './actions/settings';
|
||||
import { type ElectronRemoteServerAction, remoteSyncSlice } from './actions/sync';
|
||||
import { type ElectronState, initialState } from './initialState';
|
||||
@@ -20,7 +21,8 @@ export interface ElectronStore
|
||||
ElectronRemoteServerAction,
|
||||
ElectronAppAction,
|
||||
ElectronSettingsAction,
|
||||
NavigationHistoryAction {
|
||||
NavigationHistoryAction,
|
||||
RecentPagesAction {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
@@ -32,6 +34,7 @@ const createStore: StateCreator<ElectronStore, [['zustand/devtools', never]]> =
|
||||
...createElectronAppSlice(...parameters),
|
||||
...settingsSlice(...parameters),
|
||||
...createNavigationHistorySlice(...parameters),
|
||||
...createRecentPagesSlice(...parameters),
|
||||
});
|
||||
|
||||
// =============== Implement useStore ============ //
|
||||
|
||||
Reference in New Issue
Block a user