diff --git a/.agents/skills/linear/SKILL.md b/.agents/skills/linear/SKILL.md index 97100c8b8a..c68a26dc83 100644 --- a/.agents/skills/linear/SKILL.md +++ b/.agents/skills/linear/SKILL.md @@ -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 diff --git a/locales/ar/electron.json b/locales/ar/electron.json index e860f10c3c..2f61c10dc1 100644 --- a/locales/ar/electron.json +++ b/locales/ar/electron.json @@ -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": "إذا كان خادم البروكسي يتطلب اسم مستخدم وكلمة مرور", diff --git a/locales/bg-BG/electron.json b/locales/bg-BG/electron.json index 59fa38b2aa..172f5b3e61 100644 --- a/locales/bg-BG/electron.json +++ b/locales/bg-BG/electron.json @@ -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": "Ако прокси сървърът изисква потребителско име и парола", diff --git a/locales/de-DE/electron.json b/locales/de-DE/electron.json index a1b41511c5..50b4f73736 100644 --- a/locales/de-DE/electron.json +++ b/locales/de-DE/electron.json @@ -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", diff --git a/locales/en-US/electron.json b/locales/en-US/electron.json index c2080e2808..b5e440f28a 100644 --- a/locales/en-US/electron.json +++ b/locales/en-US/electron.json @@ -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", diff --git a/locales/es-ES/electron.json b/locales/es-ES/electron.json index 80b4f50005..5884216c57 100644 --- a/locales/es-ES/electron.json +++ b/locales/es-ES/electron.json @@ -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", diff --git a/locales/fa-IR/electron.json b/locales/fa-IR/electron.json index ea50699833..c36fe9d7bb 100644 --- a/locales/fa-IR/electron.json +++ b/locales/fa-IR/electron.json @@ -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": "در صورتی که سرور پروکسی نیاز به نام کاربری و رمز عبور داشته باشد", diff --git a/locales/fr-FR/electron.json b/locales/fr-FR/electron.json index 8aadd1874f..bd3daf2139 100644 --- a/locales/fr-FR/electron.json +++ b/locales/fr-FR/electron.json @@ -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", diff --git a/locales/it-IT/electron.json b/locales/it-IT/electron.json index e3810bd79f..661c6faf08 100644 --- a/locales/it-IT/electron.json +++ b/locales/it-IT/electron.json @@ -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", diff --git a/locales/ja-JP/electron.json b/locales/ja-JP/electron.json index 1233e1cbaf..920021b2ab 100644 --- a/locales/ja-JP/electron.json +++ b/locales/ja-JP/electron.json @@ -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": "プロキシサーバーがユーザー名とパスワードを必要とする場合", diff --git a/locales/ko-KR/electron.json b/locales/ko-KR/electron.json index b6bfd5972d..583626dcfa 100644 --- a/locales/ko-KR/electron.json +++ b/locales/ko-KR/electron.json @@ -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": "프록시 서버가 사용자 이름과 비밀번호를 요구하는 경우", diff --git a/locales/nl-NL/electron.json b/locales/nl-NL/electron.json index a7deb90066..e4808015f1 100644 --- a/locales/nl-NL/electron.json +++ b/locales/nl-NL/electron.json @@ -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", diff --git a/locales/pl-PL/electron.json b/locales/pl-PL/electron.json index ba020fc707..f9e7aa881f 100644 --- a/locales/pl-PL/electron.json +++ b/locales/pl-PL/electron.json @@ -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", diff --git a/locales/pt-BR/electron.json b/locales/pt-BR/electron.json index 096e52e25d..b54bdaa1c0 100644 --- a/locales/pt-BR/electron.json +++ b/locales/pt-BR/electron.json @@ -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", diff --git a/locales/ru-RU/electron.json b/locales/ru-RU/electron.json index 06990d97e2..f108bc031b 100644 --- a/locales/ru-RU/electron.json +++ b/locales/ru-RU/electron.json @@ -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": "Если прокси-сервер требует имя пользователя и пароль", diff --git a/locales/tr-TR/electron.json b/locales/tr-TR/electron.json index c73bc52a00..dc14c2eaed 100644 --- a/locales/tr-TR/electron.json +++ b/locales/tr-TR/electron.json @@ -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", diff --git a/locales/vi-VN/electron.json b/locales/vi-VN/electron.json index 3bd47b5d11..ac16f67c65 100644 --- a/locales/vi-VN/electron.json +++ b/locales/vi-VN/electron.json @@ -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", diff --git a/locales/zh-CN/electron.json b/locales/zh-CN/electron.json index 70a4b387be..0c8250146a 100644 --- a/locales/zh-CN/electron.json +++ b/locales/zh-CN/electron.json @@ -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": "如果代理服务器需要用户名和密码", diff --git a/locales/zh-TW/electron.json b/locales/zh-TW/electron.json index 7c869af211..d1de7ecefd 100644 --- a/locales/zh-TW/electron.json +++ b/locales/zh-TW/electron.json @@ -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": "如果代理伺服器需要使用者名稱和密碼", diff --git a/src/features/ChatInput/Desktop/ContextContainer/ContextList.tsx b/src/features/ChatInput/Desktop/ContextContainer/ContextList.tsx index 619f53463e..9779cec57f 100644 --- a/src/features/ChatInput/Desktop/ContextContainer/ContextList.tsx +++ b/src/features/ChatInput/Desktop/ContextContainer/ContextList.tsx @@ -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) { diff --git a/src/features/Electron/navigation/useNavigationHistory.ts b/src/features/Electron/navigation/useNavigationHistory.ts index 10392f89c2..f937be97cc 100644 --- a/src/features/Electron/navigation/useNavigationHistory.ts +++ b/src/features/Electron/navigation/useNavigationHistory.ts @@ -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(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', () => { diff --git a/src/features/Electron/titlebar/NavigationBar.tsx b/src/features/Electron/titlebar/NavigationBar.tsx index babf729f00..c540f1390f 100644 --- a/src/features/Electron/titlebar/NavigationBar.tsx +++ b/src/features/Electron/titlebar/NavigationBar.tsx @@ -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" > -
+
diff --git a/src/features/Electron/titlebar/RecentlyViewed.tsx b/src/features/Electron/titlebar/RecentlyViewed.tsx deleted file mode 100644 index de5e98adec..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed.tsx +++ /dev/null @@ -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(({ 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 ( -
-
{t('navigation.recentView')}
-
- ); - } - - return ( - -
{t('navigation.recentView')}
- {recentEntries.map(({ entry, originalIndex }) => { - const isActive = originalIndex === historyCurrentIndex; - const RouteIcon = getRouteIcon(entry.url); - - return ( - handleClick(entry, originalIndex)} - > - {RouteIcon && } - {entry.title} - - ); - })} -
- ); -}); - -RecentlyViewed.displayName = 'RecentlyViewed'; - -export default RecentlyViewed; diff --git a/src/features/Electron/titlebar/RecentlyViewed/PageItem.tsx b/src/features/Electron/titlebar/RecentlyViewed/PageItem.tsx new file mode 100644 index 0000000000..afe587f477 --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/PageItem.tsx @@ -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(({ 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 ( + + {item.icon && } + {item.title} + + + ); +}); + +PageItem.displayName = 'PageItem'; + +export default PageItem; diff --git a/src/features/Electron/titlebar/RecentlyViewed/Section.tsx b/src/features/Electron/titlebar/RecentlyViewed/Section.tsx new file mode 100644 index 0000000000..a430e64993 --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/Section.tsx @@ -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(({ title, items, isPinned, onClose }) => { + const styles = useStyles; + + if (items.length === 0) return null; + + return ( + <> +
{title}
+ {items.map((item) => ( + + ))} + + ); +}); + +Section.displayName = 'Section'; + +export default Section; diff --git a/src/features/Electron/titlebar/RecentlyViewed/hooks/usePluginContext.ts b/src/features/Electron/titlebar/RecentlyViewed/hooks/usePluginContext.ts new file mode 100644 index 0000000000..aa2bc03835 --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/hooks/usePluginContext.ts @@ -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, + 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( + () => ({ + 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) => t(key as any, options) as string, + }), + [agentMap, topicDataMap, sessionGroups, documents, t], + ); +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/hooks/useResolvedPages.ts b/src/features/Electron/titlebar/RecentlyViewed/hooks/useResolvedPages.ts new file mode 100644 index 0000000000..1954d6e8dd --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/hooks/useResolvedPages.ts @@ -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, + }; +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/index.tsx b/src/features/Electron/titlebar/RecentlyViewed/index.tsx new file mode 100644 index 0000000000..705e6899db --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/index.tsx @@ -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(({ 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 ( +
+
{t('navigation.noPages')}
+
+ ); + } + + return ( + +
+ {pinnedPages.length > 0 && recentPages.length > 0 &&
} +
+ + ); +}); + +RecentlyViewed.displayName = 'RecentlyViewed'; + +export default RecentlyViewed; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/agentPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/agentPlugin.ts new file mode 100644 index 0000000000..584a42dbd9 --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/agentPlugin.ts @@ -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', +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPlugin.ts new file mode 100644 index 0000000000..27389cc9f0 --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPlugin.ts @@ -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', +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/communityPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/communityPlugin.ts new file mode 100644 index 0000000000..aea96ab8fa --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/communityPlugin.ts @@ -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 = { + 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', +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/groupPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/groupPlugin.ts new file mode 100644 index 0000000000..1b96d28bc8 --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/groupPlugin.ts @@ -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', +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/groupTopicPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/groupTopicPlugin.ts new file mode 100644 index 0000000000..f0a4b0291d --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/groupTopicPlugin.ts @@ -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', +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/homePlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/homePlugin.ts new file mode 100644 index 0000000000..7a4f8322db --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/homePlugin.ts @@ -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', +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/imagePlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/imagePlugin.ts new file mode 100644 index 0000000000..154de4759c --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/imagePlugin.ts @@ -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', +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/index.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/index.ts new file mode 100644 index 0000000000..454b50ae71 --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/index.ts @@ -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, + ]); +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/memoryPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/memoryPlugin.ts new file mode 100644 index 0000000000..7d37fec448 --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/memoryPlugin.ts @@ -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 = { + 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', +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/pagePlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/pagePlugin.ts new file mode 100644 index 0000000000..347bd13066 --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/pagePlugin.ts @@ -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', +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/registry.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/registry.ts new file mode 100644 index 0000000000..ae660fb3bd --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/registry.ts @@ -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 = new Map(); + private sortedPlugins: BaseRecentlyViewedPlugin[] = []; + + /** + * Register multiple plugins at once + */ + + register(plugins: [RecentlyViewedPlugin]): void; + register( + plugins: [RecentlyViewedPlugin, RecentlyViewedPlugin], + ): void; + register( + plugins: [RecentlyViewedPlugin, RecentlyViewedPlugin, RecentlyViewedPlugin], + ): void; + register( + plugins: [ + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + ], + ): void; + register< + T extends PageType, + T2 extends PageType, + T3 extends PageType, + T4 extends PageType, + T5 extends PageType, + >( + plugins: [ + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + ], + ): void; + register< + T extends PageType, + T2 extends PageType, + T3 extends PageType, + T4 extends PageType, + T5 extends PageType, + T6 extends PageType, + >( + plugins: [ + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + ], + ): 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, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + ], + ): 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, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + ], + ): 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, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + ], + ): 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, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + ], + ): 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, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + RecentlyViewedPlugin, + ], + ): 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(); diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/resourcePlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/resourcePlugin.ts new file mode 100644 index 0000000000..56f3c4235c --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/resourcePlugin.ts @@ -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 = { + 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', +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/settingsPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/settingsPlugin.ts new file mode 100644 index 0000000000..b088b11323 --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/settingsPlugin.ts @@ -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', +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/types.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/types.ts new file mode 100644 index 0000000000..ee62afb860 --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/types.ts @@ -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; +} + +// ======== 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 { + /** + * Check if the underlying data exists + * Used to filter out stale entries + */ + checkExists: (reference: PageReference, ctx: PluginContext) => boolean; + + /** + * Generate unique ID from reference params + * e.g., "agent:abc123" or "agent-topic:abc123:topic456" + */ + 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 + * Returns null if URL doesn't match + */ + parseUrl: (pathname: string, searchParams: URLSearchParams) => PageReference | 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, ctx: PluginContext) => ResolvedPageData; + + /** + * Page type this plugin handles + */ + readonly type: T; +} + +// ======== Helper Types ======== // + +/** + * Helper to create typed page reference + */ +export function createPageReference( + type: T, + params: PageParamsMap[T], + id: string, +): PageReference { + return { + id, + lastVisited: Date.now(), + params, + type, + }; +} diff --git a/src/features/Electron/titlebar/RecentlyViewed/storage.ts b/src/features/Electron/titlebar/RecentlyViewed/storage.ts new file mode 100644 index 0000000000..16ae7aeb79 --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/storage.ts @@ -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; + } +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/styles.ts b/src/features/Electron/titlebar/RecentlyViewed/styles.ts new file mode 100644 index 0000000000..1a8bf3431a --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/styles.ts @@ -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; + `, +})); diff --git a/src/features/Electron/titlebar/RecentlyViewed/types.ts b/src/features/Electron/titlebar/RecentlyViewed/types.ts new file mode 100644 index 0000000000..1a1505525a --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/types.ts @@ -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 { + /** + * 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; +} diff --git a/src/locales/default/electron.ts b/src/locales/default/electron.ts index a6f7d6b738..e951592441 100644 --- a/src/locales/default/electron.ts +++ b/src/locales/default/electron.ts @@ -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', diff --git a/src/store/electron/actions/recentPages.ts b/src/store/electron/actions/recentPages.ts new file mode 100644 index 0000000000..e918a470fa --- /dev/null +++ b/src/store/electron/actions/recentPages.ts @@ -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); + }, +}); diff --git a/src/store/electron/initialState.ts b/src/store/electron/initialState.ts index 048c8889e5..131476c33a 100644 --- a/src/store/electron/initialState.ts +++ b/src/store/electron/initialState.ts @@ -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; @@ -36,6 +37,7 @@ export interface ElectronState extends NavigationHistoryState { export const initialState: ElectronState = { ...navigationHistoryInitialState, + ...recentPagesInitialState, appState: {}, dataSyncConfig: { storageMode: 'cloud' }, desktopHotkeys: {}, diff --git a/src/store/electron/store.ts b/src/store/electron/store.ts index cac475740c..ae768e3a58 100644 --- a/src/store/electron/store.ts +++ b/src/store/electron/store.ts @@ -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 = ...createElectronAppSlice(...parameters), ...settingsSlice(...parameters), ...createNavigationHistorySlice(...parameters), + ...createRecentPagesSlice(...parameters), }); // =============== Implement useStore ============ //