feat(electron): refactor RecentlyViewed with Pinned + Recent architecture (#11774)

*  feat(electron): refactor RecentlyViewed with Pinned + Recent architecture

- Add Pinned section for user-pinned pages (persisted to localStorage)
- Add Recent section with auto-deduplication and 20 items limit
- Support dynamic title updates (e.g., conversation names instead of generic "Chat")
- Add Pin/Unpin toggle on hover
- Keep navigation history (back/forward) independent from recent pages

Closes LOBE-4212
Closes LOBE-4230

* 📝 docs(linear): update issue management guidelines

- Revise description for clarity on triggering conditions for Linear issues.
- Add critical section on PR creation with Linear issues, emphasizing immediate comment requirements.
- Update completion comment format to include structured summary and key changes.
- Clarify workflow steps and correct examples for task completion and status updates.

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

*  feat(electron): history stack

- Introduce a new plugin system for RecentlyViewed, allowing dynamic resolution of page references.
- Implement caching for display data, improving performance and user experience.
- Refactor existing page handling to support various page types (agents, groups, etc.) with dedicated plugins.
- Update Recent and Pinned pages management to utilize the new plugin system for better data integrity and retrieval.

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-02-02 16:33:55 +08:00
committed by arvinxx
parent bebfb461e9
commit 4db39075a9
49 changed files with 2217 additions and 154 deletions

View File

@@ -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 AComment AComplete B → ...
**✅ Correct:** Complete → Create PRAdd Linear commentsTask done

View File

@@ -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": "إذا كان خادم البروكسي يتطلب اسم مستخدم وكلمة مرور",

View File

@@ -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": "Ако прокси сървърът изисква потребителско име и парола",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "در صورتی که سرور پروکسی نیاز به نام کاربری و رمز عبور داشته باشد",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "プロキシサーバーがユーザー名とパスワードを必要とする場合",

View File

@@ -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": "프록시 서버가 사용자 이름과 비밀번호를 요구하는 경우",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "Если прокси-сервер требует имя пользователя и пароль",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "如果代理服务器需要用户名和密码",

View File

@@ -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": "如果代理伺服器需要使用者名稱和密碼",

View File

@@ -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) {

View File

@@ -5,10 +5,98 @@ import { useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins';
import {
type CachedPageData,
type PageReference,
} from '@/features/Electron/titlebar/RecentlyViewed/types';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors/selectors';
import { useChatStore } from '@/store/chat';
import { useElectronStore } from '@/store/electron';
import { usePageStore } from '@/store/page';
import { listSelectors } from '@/store/page/slices/list/selectors';
import { useSessionStore } from '@/store/session';
import { sessionGroupSelectors } from '@/store/session/slices/sessionGroup/selectors';
import { getRouteMetadata } from './routeMetadata';
/**
* Get cached display data for a page reference
*/
const getCachedDataForReference = (reference: PageReference): CachedPageData | undefined => {
switch (reference.type) {
case 'agent':
case 'agent-topic': {
const agentId = 'agentId' in reference.params ? reference.params.agentId : undefined;
if (!agentId) return undefined;
const meta = agentSelectors.getAgentMetaById(agentId)(useAgentStore.getState());
if (!meta || Object.keys(meta).length === 0) return undefined;
// For agent-topic, try to get topic title
let title = meta.title;
if (reference.type === 'agent-topic' && 'topicId' in reference.params) {
const topicId = reference.params.topicId;
const topicDataMap = useChatStore.getState().topicDataMap;
for (const data of Object.values(topicDataMap)) {
const topic = data.items?.find((t) => t.id === topicId);
if (topic?.title) {
title = topic.title;
break;
}
}
}
return {
avatar: meta.avatar,
backgroundColor: meta.backgroundColor,
title: title || '',
};
}
case 'group':
case 'group-topic': {
const groupId = 'groupId' in reference.params ? reference.params.groupId : undefined;
if (!groupId) return undefined;
const group = sessionGroupSelectors.getGroupById(groupId)(useSessionStore.getState());
if (!group) return undefined;
// For group-topic, try to get topic title
let title = group.name;
if (reference.type === 'group-topic' && 'topicId' in reference.params) {
const topicId = reference.params.topicId;
const topicDataMap = useChatStore.getState().topicDataMap;
for (const data of Object.values(topicDataMap)) {
const topic = data.items?.find((t) => t.id === topicId);
if (topic?.title) {
title = topic.title;
break;
}
}
}
return { title: title || '' };
}
case 'page': {
const pageId = 'pageId' in reference.params ? reference.params.pageId : undefined;
if (!pageId) return undefined;
const document = listSelectors.getDocumentById(pageId)(usePageStore.getState());
if (!document) return undefined;
return { title: document.title || '' };
}
default: {
// Static pages don't need cached data
return undefined;
}
}
};
/**
* Hook to manage navigation history in Electron desktop app
* Provides browser-like back/forward functionality
@@ -31,6 +119,7 @@ export const useNavigationHistory = () => {
const canGoBackFn = useElectronStore((s) => s.canGoBack);
const canGoForwardFn = useElectronStore((s) => s.canGoForward);
const getCurrentEntry = useElectronStore((s) => s.getCurrentEntry);
const addRecentPage = useElectronStore((s) => s.addRecentPage);
// Track previous location to avoid duplicate entries
const prevLocationRef = useRef<string | null>(null);
@@ -39,9 +128,6 @@ export const useNavigationHistory = () => {
const canGoBack = historyCurrentIndex > 0;
const canGoForward = historyCurrentIndex < historyEntries.length - 1;
/**
* Go back in history
*/
const goBack = useCallback(() => {
if (!canGoBackFn()) return;
@@ -51,9 +137,6 @@ export const useNavigationHistory = () => {
}
}, [canGoBackFn, storeGoBack, navigate]);
/**
* Go forward in history
*/
const goForward = useCallback(() => {
if (!canGoForwardFn()) return;
@@ -99,6 +182,17 @@ export const useNavigationHistory = () => {
url: currentUrl,
});
// Only add to recent pages if NOT a dynamic title route
// Dynamic title routes will be added when the real title is available
if (!metadata.useDynamicTitle) {
// Parse URL into a page reference using plugins
const reference = pluginRegistry.parseUrl(location.pathname, location.search);
if (reference) {
const cached = getCachedDataForReference(reference);
addRecentPage(reference, cached);
}
}
prevLocationRef.current = currentUrl;
}, [
location.pathname,
@@ -107,6 +201,7 @@ export const useNavigationHistory = () => {
setIsNavigatingHistory,
getCurrentEntry,
pushHistory,
addRecentPage,
t,
]);
@@ -129,7 +224,27 @@ export const useNavigationHistory = () => {
...currentEntry,
title: currentPageTitle,
});
}, [currentPageTitle, getCurrentEntry, replaceHistory, location.pathname]);
// Add or update in recent pages (dynamic title routes are added here, not on route change)
// Parse URL into a page reference using plugins
const reference = pluginRegistry.parseUrl(location.pathname, location.search);
if (reference) {
// Get cached data with the dynamic title
const cached = getCachedDataForReference(reference);
// Override with the current page title if available
const cachedWithTitle = cached
? { ...cached, title: currentPageTitle }
: { title: currentPageTitle };
addRecentPage(reference, cachedWithTitle);
}
}, [
currentPageTitle,
getCurrentEntry,
replaceHistory,
addRecentPage,
location.pathname,
location.search,
]);
// Listen to broadcast events from main process (Electron menu)
useWatchBroadcast('historyGoBack', () => {

View File

@@ -1,8 +1,9 @@
'use client';
import { ActionIcon, Flexbox, Popover, Tooltip } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { ArrowLeft, ArrowRight, Clock } from 'lucide-react';
import { memo, useCallback, useEffect, useState } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useGlobalStore } from '@/store/global';
@@ -12,6 +13,7 @@ import { isMacOS } from '@/utils/platform';
import { useNavigationHistory } from '../navigation/useNavigationHistory';
import RecentlyViewed from './RecentlyViewed';
import { loadAllRecentlyViewedPlugins } from './RecentlyViewed/plugins';
const isMac = isMacOS();
@@ -19,7 +21,26 @@ const useNavPanelWidth = () => {
return useGlobalStore(systemStatusSelectors.leftPanelWidth);
};
const styles = createStaticStyles(({ css, cssVar }) => ({
clock: css`
&[data-popup-open] {
border-radius: ${cssVar.borderRadiusSM};
background-color: ${cssVar.colorFillTertiary};
}
`,
}));
const useLoadAllRecentlyViewedPlugins = () => {
const registerRef = useRef(false);
if (!registerRef.current) {
loadAllRecentlyViewedPlugins();
registerRef.current = true;
}
};
const NavigationBar = memo(() => {
useLoadAllRecentlyViewedPlugins();
const { t } = useTranslation('electron');
const { canGoBack, canGoForward, goBack, goForward } = useNavigationHistory();
const [historyOpen, setHistoryOpen] = useState(false);
@@ -69,7 +90,7 @@ const NavigationBar = memo(() => {
styles={{ content: { padding: 0 } }}
trigger="click"
>
<div>
<div className={styles.clock}>
<Tooltip open={historyOpen ? false : undefined} title={tooltipContent}>
<ActionIcon icon={Clock} size="small" />
</Tooltip>

View File

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

View File

@@ -0,0 +1,71 @@
'use client';
import { ActionIcon, Flexbox, Icon } from '@lobehub/ui';
import { cx } from 'antd-style';
import { Pin, PinOff } from 'lucide-react';
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { useElectronStore } from '@/store/electron';
import { useStyles } from './styles';
import { type ResolvedPageData } from './types';
interface PageItemProps {
isPinned: boolean;
item: ResolvedPageData;
onClose: () => void;
}
const PageItem = memo<PageItemProps>(({ item, isPinned, onClose }) => {
const { t } = useTranslation('electron');
const navigate = useNavigate();
const location = useLocation();
const styles = useStyles;
const pinPage = useElectronStore((s) => s.pinPage);
const unpinPage = useElectronStore((s) => s.unpinPage);
// Check if this item matches the current route
const currentUrl = location.pathname + location.search;
const isActive = item.url === currentUrl || item.url === currentUrl.replace(/\/+$/, '');
const handleClick = () => {
navigate(item.url);
onClose();
};
const handlePinToggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (isPinned) {
unpinPage(item.reference.id);
} else {
pinPage(item.reference);
}
};
return (
<Flexbox
align="center"
className={cx(styles.item, isActive && styles.itemActive)}
gap={8}
horizontal
onClick={handleClick}
>
{item.icon && <Icon className={styles.icon} icon={item.icon} size="small" />}
<span className={styles.itemTitle}>{item.title}</span>
<ActionIcon
className={cx('actionIcon', styles.actionIcon)}
icon={isPinned ? PinOff : Pin}
onClick={handlePinToggle}
size="small"
title={isPinned ? t('navigation.unpin') : t('navigation.pin')}
/>
</Flexbox>
);
});
PageItem.displayName = 'PageItem';
export default PageItem;

View File

@@ -0,0 +1,33 @@
'use client';
import { memo } from 'react';
import PageItem from './PageItem';
import { useStyles } from './styles';
import { type ResolvedPageData } from './types';
interface SectionProps {
isPinned: boolean;
items: ResolvedPageData[];
onClose: () => void;
title: string;
}
const Section = memo<SectionProps>(({ title, items, isPinned, onClose }) => {
const styles = useStyles;
if (items.length === 0) return null;
return (
<>
<div className={styles.title}>{title}</div>
{items.map((item) => (
<PageItem isPinned={isPinned} item={item} key={item.reference.id} onClose={onClose} />
))}
</>
);
});
Section.displayName = 'Section';
export default Section;

View File

@@ -0,0 +1,71 @@
'use client';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors/selectors';
import { useChatStore } from '@/store/chat';
import { usePageStore } from '@/store/page';
import { listSelectors } from '@/store/page/slices/list/selectors';
import { useSessionStore } from '@/store/session';
import { sessionGroupSelectors } from '@/store/session/slices/sessionGroup/selectors';
import { type ChatTopic } from '@/types/topic';
import { type PluginContext } from '../plugins/types';
/**
* Search for a topic across all entries in topicDataMap
* This is needed because getTopicById only searches in the current active session's topics
*/
const findTopicAcrossAllSessions = (
topicDataMap: Record<string, { items?: ChatTopic[] }>,
topicId: string,
): ChatTopic | undefined => {
for (const data of Object.values(topicDataMap)) {
const topic = data.items?.find((t) => t.id === topicId);
if (topic) return topic;
}
return undefined;
};
/**
* Hook to create plugin context with access to store data
*/
export const usePluginContext = (): PluginContext => {
const { t } = useTranslation('electron');
const agentMap = useAgentStore((s) => s.agentMap);
const topicDataMap = useChatStore((s) => s.topicDataMap);
const sessionGroups = useSessionStore((s) => s.sessionGroups);
const documents = usePageStore((s) => s.documents);
return useMemo<PluginContext>(
() => ({
getAgentMeta: (agentId: string) => {
const state = useAgentStore.getState();
return agentSelectors.getAgentMetaById(agentId)(state);
},
getDocument: (documentId: string) => {
const state = usePageStore.getState();
return listSelectors.getDocumentById(documentId)(state);
},
getSessionGroup: (groupId: string) => {
const state = useSessionStore.getState();
return sessionGroupSelectors.getGroupById(groupId)(state);
},
getTopic: (topicId: string) => {
// Search across ALL entries in topicDataMap, not just current session
// This ensures we can find topics even after navigating away from the agent page
const state = useChatStore.getState();
return findTopicAcrossAllSessions(state.topicDataMap, topicId);
},
t: (key: string, options?: Record<string, unknown>) => t(key as any, options) as string,
}),
[agentMap, topicDataMap, sessionGroups, documents, t],
);
};

View File

@@ -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,
};
};

View File

@@ -0,0 +1,55 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useElectronStore } from '@/store/electron';
import Section from './Section';
import { useResolvedPages } from './hooks/useResolvedPages';
import { useStyles } from './styles';
interface RecentlyViewedProps {
onClose: () => void;
}
const RecentlyViewed = memo<RecentlyViewedProps>(({ onClose }) => {
const { t } = useTranslation('electron');
const styles = useStyles;
const loadPinnedPages = useElectronStore((s) => s.loadPinnedPages);
const { pinnedPages, recentPages } = useResolvedPages();
useEffect(() => {
loadPinnedPages();
}, [loadPinnedPages]);
const isEmpty = pinnedPages.length === 0 && recentPages.length === 0;
if (isEmpty) {
return (
<div className={styles.container}>
<div className={styles.empty}>{t('navigation.noPages')}</div>
</div>
);
}
return (
<Flexbox className={styles.container}>
<Section isPinned items={pinnedPages} onClose={onClose} title={t('navigation.pinned')} />
{pinnedPages.length > 0 && recentPages.length > 0 && <div className={styles.divider} />}
<Section
isPinned={false}
items={recentPages}
onClose={onClose}
title={t('navigation.recentView')}
/>
</Flexbox>
);
});
RecentlyViewed.displayName = 'RecentlyViewed';
export default RecentlyViewed;

View File

@@ -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',
};

View File

@@ -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',
};

View File

@@ -0,0 +1,72 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { ShapesIcon } from 'lucide-react';
import { getRouteById } from '@/config/routes';
import { type CommunityParams, type PageReference, type ResolvedPageData } from '../types';
import { type PluginContext, type RecentlyViewedPlugin, createPageReference } from './types';
const communityIcon = getRouteById('community')?.icon || ShapesIcon;
const COMMUNITY_PATH_REGEX = /^\/community(\/([^/?]+))?$/;
// Section to title key mapping
const sectionTitleKeys: Record<string, string> = {
agent: 'navigation.discoverAssistants',
mcp: 'navigation.discoverMcp',
model: 'navigation.discoverModels',
provider: 'navigation.discoverProviders',
};
export const communityPlugin: RecentlyViewedPlugin<'community'> = {
checkExists(_reference: PageReference<'community'>, _ctx: PluginContext): boolean {
return true; // Static page always exists
},
generateId(reference: PageReference<'community'>): string {
const { section } = reference.params;
return section ? `community:${section}` : 'community';
},
generateUrl(reference: PageReference<'community'>): string {
const { section } = reference.params;
return section ? `/community/${section}` : '/community';
},
getDefaultIcon() {
return communityIcon;
},
matchUrl(pathname: string, _searchParams: URLSearchParams): boolean {
return COMMUNITY_PATH_REGEX.test(pathname);
},
parseUrl(pathname: string, _searchParams: URLSearchParams): PageReference<'community'> | null {
const match = pathname.match(COMMUNITY_PATH_REGEX);
if (!match) return null;
const section = match[2]; // Optional section like 'agent', 'model', etc.
const params: CommunityParams = section ? { section } : {};
const id = this.generateId({ params } as PageReference<'community'>);
return createPageReference('community', params, id);
},
priority: 5,
resolve(reference: PageReference<'community'>, ctx: PluginContext): ResolvedPageData {
const { section } = reference.params;
const titleKey = section
? sectionTitleKeys[section] || 'navigation.discover'
: 'navigation.discover';
return {
exists: true,
icon: this.getDefaultIcon!(),
reference,
title: ctx.t(titleKey as any, { ns: 'electron' }) as string,
url: this.generateUrl(reference),
};
},
type: 'community',
};

View File

@@ -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',
};

View File

@@ -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',
};

View File

@@ -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',
};

View File

@@ -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',
};

View File

@@ -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,
]);
};

View File

@@ -0,0 +1,72 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Brain } from 'lucide-react';
import { getRouteById } from '@/config/routes';
import { type MemoryParams, type PageReference, type ResolvedPageData } from '../types';
import { type PluginContext, type RecentlyViewedPlugin, createPageReference } from './types';
const memoryIcon = getRouteById('memory')?.icon || Brain;
const MEMORY_PATH_REGEX = /^\/memory(\/([^/?]+))?$/;
// Section to title key mapping
const sectionTitleKeys: Record<string, string> = {
contexts: 'navigation.memoryContexts',
experiences: 'navigation.memoryExperiences',
identities: 'navigation.memoryIdentities',
preferences: 'navigation.memoryPreferences',
};
export const memoryPlugin: RecentlyViewedPlugin<'memory'> = {
checkExists(_reference: PageReference<'memory'>, _ctx: PluginContext): boolean {
return true; // Static page always exists
},
generateId(reference: PageReference<'memory'>): string {
const { section } = reference.params;
return section ? `memory:${section}` : 'memory';
},
generateUrl(reference: PageReference<'memory'>): string {
const { section } = reference.params;
return section ? `/memory/${section}` : '/memory';
},
getDefaultIcon() {
return memoryIcon;
},
matchUrl(pathname: string, _searchParams: URLSearchParams): boolean {
return MEMORY_PATH_REGEX.test(pathname);
},
parseUrl(pathname: string, _searchParams: URLSearchParams): PageReference<'memory'> | null {
const match = pathname.match(MEMORY_PATH_REGEX);
if (!match) return null;
const section = match[2];
const params: MemoryParams = section ? { section } : {};
const id = this.generateId({ params } as PageReference<'memory'>);
return createPageReference('memory', params, id);
},
priority: 5,
resolve(reference: PageReference<'memory'>, ctx: PluginContext): ResolvedPageData {
const { section } = reference.params;
const titleKey = section
? sectionTitleKeys[section] || 'navigation.memory'
: 'navigation.memory';
return {
exists: true,
icon: this.getDefaultIcon!(),
reference,
title: ctx.t(titleKey as any, { ns: 'electron' }) as string,
url: this.generateUrl(reference),
};
},
type: 'memory',
};

View File

@@ -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',
};

View File

@@ -0,0 +1,246 @@
/* eslint-disable no-dupe-class-members */
import { type PageReference, type PageType, type ResolvedPageData } from '../types';
import {
type BaseRecentlyViewedPlugin,
type PluginContext,
type RecentlyViewedPlugin,
} from './types';
/**
* Plugin registry for RecentlyViewed system
* Manages all page type plugins and provides URL parsing/resolution
*/
class PluginRegistry {
private plugins: Map<PageType, BaseRecentlyViewedPlugin> = new Map();
private sortedPlugins: BaseRecentlyViewedPlugin[] = [];
/**
* Register multiple plugins at once
*/
register<T extends PageType>(plugins: [RecentlyViewedPlugin<T>]): void;
register<T extends PageType, T2 extends PageType>(
plugins: [RecentlyViewedPlugin<T>, RecentlyViewedPlugin<T2>],
): void;
register<T extends PageType, T2 extends PageType, T3 extends PageType>(
plugins: [RecentlyViewedPlugin<T>, RecentlyViewedPlugin<T2>, RecentlyViewedPlugin<T3>],
): void;
register<T extends PageType, T2 extends PageType, T3 extends PageType, T4 extends PageType>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
],
): void;
register<
T extends PageType,
T2 extends PageType,
T3 extends PageType,
T4 extends PageType,
T5 extends PageType,
>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
RecentlyViewedPlugin<T5>,
],
): void;
register<
T extends PageType,
T2 extends PageType,
T3 extends PageType,
T4 extends PageType,
T5 extends PageType,
T6 extends PageType,
>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
RecentlyViewedPlugin<T5>,
RecentlyViewedPlugin<T6>,
],
): void;
register<
T extends PageType,
T2 extends PageType,
T3 extends PageType,
T4 extends PageType,
T5 extends PageType,
T6 extends PageType,
T7 extends PageType,
>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
RecentlyViewedPlugin<T5>,
RecentlyViewedPlugin<T6>,
RecentlyViewedPlugin<T7>,
],
): void;
register<
T extends PageType,
T2 extends PageType,
T3 extends PageType,
T4 extends PageType,
T5 extends PageType,
T6 extends PageType,
T7 extends PageType,
T8 extends PageType,
>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
RecentlyViewedPlugin<T5>,
RecentlyViewedPlugin<T6>,
RecentlyViewedPlugin<T7>,
RecentlyViewedPlugin<T8>,
],
): void;
register<
T extends PageType,
T2 extends PageType,
T3 extends PageType,
T4 extends PageType,
T5 extends PageType,
T6 extends PageType,
T7 extends PageType,
T8 extends PageType,
T9 extends PageType,
>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
RecentlyViewedPlugin<T5>,
RecentlyViewedPlugin<T6>,
RecentlyViewedPlugin<T7>,
RecentlyViewedPlugin<T8>,
RecentlyViewedPlugin<T9>,
],
): void;
register<
T extends PageType,
T2 extends PageType,
T3 extends PageType,
T4 extends PageType,
T5 extends PageType,
T6 extends PageType,
T7 extends PageType,
T8 extends PageType,
T9 extends PageType,
T10 extends PageType,
>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
RecentlyViewedPlugin<T5>,
RecentlyViewedPlugin<T6>,
RecentlyViewedPlugin<T7>,
RecentlyViewedPlugin<T8>,
RecentlyViewedPlugin<T9>,
RecentlyViewedPlugin<T10>,
],
): void;
register<
T extends PageType,
T2 extends PageType,
T3 extends PageType,
T4 extends PageType,
T5 extends PageType,
T6 extends PageType,
T7 extends PageType,
T8 extends PageType,
T9 extends PageType,
T10 extends PageType,
T11 extends PageType,
>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
RecentlyViewedPlugin<T5>,
RecentlyViewedPlugin<T6>,
RecentlyViewedPlugin<T7>,
RecentlyViewedPlugin<T8>,
RecentlyViewedPlugin<T9>,
RecentlyViewedPlugin<T10>,
RecentlyViewedPlugin<T11>,
],
): void;
register(plugins: BaseRecentlyViewedPlugin[]): void {
for (const plugin of plugins) {
this.plugins.set(plugin.type, plugin);
}
this.updateSortedPlugins();
}
/**
* Parse URL into a page reference using registered plugins
* Returns null if no plugin matches
*/
parseUrl(pathname: string, search: string): PageReference | null {
const searchParams = new URLSearchParams(search);
for (const plugin of this.sortedPlugins) {
if (plugin.matchUrl(pathname, searchParams)) {
const reference = plugin.parseUrl(pathname, searchParams);
if (reference) {
return reference;
}
}
}
return null;
}
/**
* Resolve a page reference into display data
*/
resolve(reference: PageReference, ctx: PluginContext): ResolvedPageData | null {
const plugin = this.plugins.get(reference.type);
if (!plugin) return null;
return plugin.resolve(reference, ctx);
}
/**
* Resolve multiple page references, filtering out non-existent ones
*/
resolveAll(references: PageReference[], ctx: PluginContext): ResolvedPageData[] {
const results: ResolvedPageData[] = [];
for (const reference of references) {
const resolved = this.resolve(reference, ctx);
if (resolved && resolved.exists) {
results.push(resolved);
}
}
return results;
}
/**
* Update sorted plugins list by priority
*/
private updateSortedPlugins(): void {
this.sortedPlugins = Array.from(this.plugins.values()).sort(
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
);
}
}
export const pluginRegistry = new PluginRegistry();

View File

@@ -0,0 +1,69 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Database } from 'lucide-react';
import { getRouteById } from '@/config/routes';
import { type PageReference, type ResolvedPageData, type ResourceParams } from '../types';
import { type PluginContext, type RecentlyViewedPlugin, createPageReference } from './types';
const resourceIcon = getRouteById('resource')?.icon || Database;
const RESOURCE_PATH_REGEX = /^\/resource(\/([^/?]+))?$/;
// Section to title key mapping
const sectionTitleKeys: Record<string, string> = {
library: 'navigation.knowledgeBase',
};
export const resourcePlugin: RecentlyViewedPlugin<'resource'> = {
checkExists(_reference: PageReference<'resource'>, _ctx: PluginContext): boolean {
return true; // Static page always exists
},
generateId(reference: PageReference<'resource'>): string {
const { section } = reference.params;
return section ? `resource:${section}` : 'resource';
},
generateUrl(reference: PageReference<'resource'>): string {
const { section } = reference.params;
return section ? `/resource/${section}` : '/resource';
},
getDefaultIcon() {
return resourceIcon;
},
matchUrl(pathname: string, _searchParams: URLSearchParams): boolean {
return RESOURCE_PATH_REGEX.test(pathname);
},
parseUrl(pathname: string, _searchParams: URLSearchParams): PageReference<'resource'> | null {
const match = pathname.match(RESOURCE_PATH_REGEX);
if (!match) return null;
const section = match[2];
const params: ResourceParams = section ? { section } : {};
const id = this.generateId({ params } as PageReference<'resource'>);
return createPageReference('resource', params, id);
},
priority: 5,
resolve(reference: PageReference<'resource'>, ctx: PluginContext): ResolvedPageData {
const { section } = reference.params;
const titleKey = section
? sectionTitleKeys[section] || 'navigation.resources'
: 'navigation.resources';
return {
exists: true,
icon: this.getDefaultIcon!(),
reference,
title: ctx.t(titleKey as any, { ns: 'electron' }) as string,
url: this.generateUrl(reference),
};
},
type: 'resource',
};

View File

@@ -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',
};

View File

@@ -0,0 +1,167 @@
import { type LucideIcon } from 'lucide-react';
import { type LobeDocument } from '@/types/document';
import { type MetaData } from '@/types/meta';
import { type SessionGroupItem } from '@/types/session';
import { type ChatTopic } from '@/types/topic';
import {
type PageParamsMap,
type PageReference,
type PageType,
type ResolvedPageData,
} from '../types';
// ======== Plugin Context ======== //
/**
* Context provided to plugins for data access
* This abstracts away direct store access
*/
export interface PluginContext {
/**
* Get agent metadata by ID
*/
getAgentMeta: (agentId: string) => MetaData | undefined;
/**
* Get document by ID
*/
getDocument: (documentId: string) => LobeDocument | undefined;
/**
* Get session/group by ID
*/
getSessionGroup: (groupId: string) => SessionGroupItem | undefined;
/**
* Get topic by ID from current context
*/
getTopic: (topicId: string) => ChatTopic | undefined;
/**
* i18n translation function
*/
t: (key: string, options?: Record<string, unknown>) => string;
}
// ======== Plugin Interface ======== //
/**
* Base plugin interface (non-generic for registry use)
*/
export interface BaseRecentlyViewedPlugin {
/**
* Check if the underlying data exists
*/
checkExists: (reference: PageReference, ctx: PluginContext) => boolean;
/**
* Generate unique ID from reference params
*/
generateId: (reference: PageReference) => string;
/**
* Generate navigation URL from reference
*/
generateUrl: (reference: PageReference) => string;
/**
* Get default icon for this page type
*/
getDefaultIcon?: () => LucideIcon;
/**
* Check if URL matches this plugin
*/
matchUrl: (pathname: string, searchParams: URLSearchParams) => boolean;
/**
* Parse URL into a page reference
*/
parseUrl: (pathname: string, searchParams: URLSearchParams) => PageReference | null;
/**
* Priority for URL matching (higher = checked first)
*/
readonly priority?: number;
/**
* Resolve reference into display data
*/
resolve: (reference: PageReference, ctx: PluginContext) => ResolvedPageData;
/**
* Page type this plugin handles
*/
readonly type: PageType;
}
/**
* Typed plugin interface for implementation
* Each page type should have its own plugin implementation
*/
export interface RecentlyViewedPlugin<T extends PageType = PageType> {
/**
* Check if the underlying data exists
* Used to filter out stale entries
*/
checkExists: (reference: PageReference<T>, ctx: PluginContext) => boolean;
/**
* Generate unique ID from reference params
* e.g., "agent:abc123" or "agent-topic:abc123:topic456"
*/
generateId: (reference: PageReference<T>) => string;
/**
* Generate navigation URL from reference
*/
generateUrl: (reference: PageReference<T>) => string;
/**
* Get default icon for this page type
*/
getDefaultIcon?: () => LucideIcon;
/**
* Check if URL matches this plugin
*/
matchUrl: (pathname: string, searchParams: URLSearchParams) => boolean;
/**
* Parse URL into a page reference
* Returns null if URL doesn't match
*/
parseUrl: (pathname: string, searchParams: URLSearchParams) => PageReference<T> | null;
/**
* Priority for URL matching (higher = checked first)
* Used when multiple plugins could match the same URL
*/
readonly priority?: number;
/**
* Resolve reference into display data
*/
resolve: (reference: PageReference<T>, ctx: PluginContext) => ResolvedPageData;
/**
* Page type this plugin handles
*/
readonly type: T;
}
// ======== Helper Types ======== //
/**
* Helper to create typed page reference
*/
export function createPageReference<T extends PageType>(
type: T,
params: PageParamsMap[T],
id: string,
): PageReference<T> {
return {
id,
lastVisited: Date.now(),
params,
type,
};
}

View File

@@ -0,0 +1,59 @@
import { type PageReference } from './types';
export const PINNED_PAGES_STORAGE_KEY = 'lobechat:desktop:pinned-pages:v2';
/**
* Get pinned pages from localStorage
*/
export const getPinnedPages = (): PageReference[] => {
if (typeof window === 'undefined') return [];
try {
const data = window.localStorage.getItem(PINNED_PAGES_STORAGE_KEY);
if (!data) return [];
const parsed = JSON.parse(data);
if (!Array.isArray(parsed)) return [];
// Validate each entry has required fields
return parsed.filter(
(item): item is PageReference =>
item &&
typeof item === 'object' &&
typeof item.id === 'string' &&
typeof item.type === 'string' &&
typeof item.lastVisited === 'number' &&
item.params !== undefined,
);
} catch {
return [];
}
};
/**
* Save pinned pages to localStorage
*/
export const savePinnedPages = (pages: PageReference[]): boolean => {
if (typeof window === 'undefined') return false;
try {
window.localStorage.setItem(PINNED_PAGES_STORAGE_KEY, JSON.stringify(pages));
return true;
} catch {
return false;
}
};
/**
* Clear pinned pages from localStorage
*/
export const clearPinnedPages = (): boolean => {
if (typeof window === 'undefined') return false;
try {
window.localStorage.removeItem(PINNED_PAGES_STORAGE_KEY);
return true;
} catch {
return false;
}
};

View File

@@ -0,0 +1,83 @@
import { createStaticStyles } from 'antd-style';
export const useStyles = createStaticStyles(({ css, cssVar }) => ({
actionIcon: css`
flex-shrink: 0;
color: ${cssVar.colorTextTertiary};
opacity: 0;
transition: opacity 0.2s ${cssVar.motionEaseOut};
`,
container: css`
overflow-y: auto;
width: 260px;
max-height: 320px;
padding: 4px;
`,
divider: css`
height: 1px;
margin-block: 4px;
background-color: ${cssVar.colorBorderSecondary};
`,
empty: css`
padding-block: 16px;
padding-inline: 12px;
font-size: 12px;
color: ${cssVar.colorTextTertiary};
text-align: center;
`,
icon: css`
flex-shrink: 0;
color: ${cssVar.colorTextSecondary};
`,
item: css`
cursor: default;
overflow: hidden;
flex-shrink: 0;
padding-block: 3px;
padding-inline: 8px;
border-radius: ${cssVar.borderRadiusSM};
transition: background-color 0.15s ${cssVar.motionEaseInOut};
&:hover {
background-color: ${cssVar.colorFillSecondary};
}
&:hover .actionIcon {
color: ${cssVar.colorText};
opacity: 1;
}
`,
itemActive: css`
background-color: ${cssVar.colorFillTertiary};
&:hover {
background-color: ${cssVar.colorFillSecondary};
}
`,
itemHovered: css`
background-color: ${cssVar.colorFillSecondary};
`,
itemTitle: css`
overflow: hidden;
flex: 1;
font-size: 12px;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
`,
title: css`
padding-block: 4px;
padding-inline: 8px;
font-size: 11px;
font-weight: 500;
color: ${cssVar.colorTextTertiary};
text-transform: capitalize;
letter-spacing: 0.5px;
`,
}));

View File

@@ -0,0 +1,177 @@
import { type LucideIcon } from 'lucide-react';
// ======== Page Types ======== //
/**
* All supported page types for recently viewed
*/
export type PageType =
| 'agent'
| 'agent-topic'
| 'group'
| 'group-topic'
| 'page'
| 'settings'
| 'community'
| 'resource'
| 'memory'
| 'image'
| 'home';
// ======== Page Params ======== //
export interface AgentParams {
agentId: string;
}
export interface AgentTopicParams {
agentId: string;
topicId: string;
}
export interface GroupParams {
groupId: string;
}
export interface GroupTopicParams {
groupId: string;
topicId: string;
}
export interface PageParams {
pageId: string;
}
export interface SettingsParams {
section?: string;
}
export interface CommunityParams {
section?: string;
}
export interface ResourceParams {
section?: string;
}
export interface MemoryParams {
section?: string;
}
export interface ImageParams {
section?: string;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface HomeParams {}
/**
* Type-safe params mapping for each page type
*/
export interface PageParamsMap {
'agent': AgentParams;
'agent-topic': AgentTopicParams;
'community': CommunityParams;
'group': GroupParams;
'group-topic': GroupTopicParams;
'home': HomeParams;
'image': ImageParams;
'memory': MemoryParams;
'page': PageParams;
'resource': ResourceParams;
'settings': SettingsParams;
}
// ======== Cached Display Data ======== //
/**
* Cached display data stored with page reference
* Used as fallback when store data is not available
*/
export interface CachedPageData {
/**
* Avatar URL
*/
avatar?: string;
/**
* Background color for avatar
*/
backgroundColor?: string;
/**
* Display title
*/
title: string;
}
// ======== Page Reference (Storage) ======== //
/**
* Structured page reference for storage
* This replaces the old PageEntry type
*/
export interface PageReference<T extends PageType = PageType> {
/**
* Cached display data for when store data is unavailable
* This ensures pinned pages can display even after app restart
*/
cached?: CachedPageData;
/**
* Unique identifier combining type and params
* e.g., "agent:abc123" or "agent-topic:abc123:topic456"
*/
id: string;
/**
* Timestamp of last visit
*/
lastVisited: number;
/**
* Type-specific parameters
*/
params: PageParamsMap[T];
/**
* Page type
*/
type: T;
/**
* Visit count for sorting/analytics
*/
visitCount?: number;
}
// ======== Resolved Page Data (Display) ======== //
/**
* Resolved page data ready for rendering
* Contains all display information generated by plugins
*/
export interface ResolvedPageData {
/**
* Avatar URL for agent/group pages
*/
avatar?: string;
/**
* Background color for avatar
*/
backgroundColor?: string;
/**
* Whether the underlying data exists
* Pages with exists=false should be filtered out
*/
exists: boolean;
/**
* Icon to display
*/
icon?: LucideIcon;
/**
* Original reference for navigation and pin/unpin
*/
reference: PageReference;
/**
* Display title
*/
title: string;
/**
* Generated URL for navigation
*/
url: string;
}

View File

@@ -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',

View File

@@ -0,0 +1,193 @@
import { type StateCreator } from 'zustand/vanilla';
import {
getPinnedPages,
savePinnedPages,
} from '@/features/Electron/titlebar/RecentlyViewed/storage';
import {
type CachedPageData,
type PageReference,
} from '@/features/Electron/titlebar/RecentlyViewed/types';
import type { ElectronStore } from '../store';
// ======== Constants ======== //
const RECENT_PAGES_LIMIT = 20;
const PINNED_PAGES_LIMIT = 10;
// ======== Types ======== //
export interface RecentPagesState {
pinnedPages: PageReference[];
recentPages: PageReference[];
}
// ======== Action Interface ======== //
export interface RecentPagesAction {
/**
* Add/update a page reference in recent list (auto-dedupe)
* @param reference - The page reference to add
* @param cached - Optional cached display data (title, avatar, etc.)
*/
addRecentPage: (reference: PageReference, cached?: CachedPageData) => void;
/**
* Clear all recent pages
*/
clearRecentPages: () => void;
/**
* Check if a page is pinned by its ID
*/
isPagePinned: (id: string) => boolean;
/**
* Load pinned pages from localStorage (called on init)
*/
loadPinnedPages: () => void;
/**
* Add a page to pinned list
*/
pinPage: (reference: PageReference) => void;
/**
* Remove a page from recent list by ID
*/
removeRecentPage: (id: string) => void;
/**
* Remove a page from pinned list by ID
*/
unpinPage: (id: string) => void;
}
// ======== Initial State ======== //
export const recentPagesInitialState: RecentPagesState = {
pinnedPages: [],
recentPages: [],
};
// ======== Action Implementation ======== //
export const createRecentPagesSlice: StateCreator<
ElectronStore,
[['zustand/devtools', never]],
[],
RecentPagesAction
> = (set, get) => ({
addRecentPage: (reference, cached) => {
const { pinnedPages, recentPages } = get();
const { id } = reference;
// If pinned, update cached data on pinned entry
const pinnedIndex = pinnedPages.findIndex((p) => p.id === id);
if (pinnedIndex >= 0) {
if (cached) {
const updatedPinned = [...pinnedPages];
updatedPinned[pinnedIndex] = {
...updatedPinned[pinnedIndex],
cached: { ...updatedPinned[pinnedIndex].cached, ...cached },
};
set({ pinnedPages: updatedPinned }, false, 'updatePinnedPageCache');
savePinnedPages(updatedPinned);
}
return;
}
// Find existing entry
const existingIndex = recentPages.findIndex((p) => p.id === id);
const existingEntry = existingIndex >= 0 ? recentPages[existingIndex] : null;
// Merge cached data: new cached takes precedence, but preserve existing fields if not provided
const mergedCached = cached ? { ...existingEntry?.cached, ...cached } : existingEntry?.cached;
const newEntry: PageReference = {
...reference,
cached: mergedCached,
lastVisited: Date.now(),
visitCount: (existingEntry?.visitCount || 0) + 1,
};
// Remove existing if present
const filtered =
existingIndex >= 0 ? recentPages.filter((_, i) => i !== existingIndex) : recentPages;
// Add to front, enforce limit
const newRecent = [newEntry, ...filtered].slice(0, RECENT_PAGES_LIMIT);
set({ recentPages: newRecent }, false, 'addRecentPage');
},
clearRecentPages: () => {
set({ recentPages: [] }, false, 'clearRecentPages');
},
isPagePinned: (id) => {
return get().pinnedPages.some((p) => p.id === id);
},
loadPinnedPages: () => {
const pinned = getPinnedPages();
const { recentPages } = get();
const pinnedIds = new Set(pinned.map((p) => p.id));
// Filter out any pages from recent that are now in pinned
// This handles the race condition where addRecentPage runs before loadPinnedPages
const filteredRecent = recentPages.filter((p) => !pinnedIds.has(p.id));
set({ pinnedPages: pinned, recentPages: filteredRecent }, false, 'loadPinnedPages');
},
pinPage: (reference) => {
const { pinnedPages, recentPages } = get();
const { id } = reference;
// Check if already pinned
if (pinnedPages.some((p) => p.id === id)) return;
// Check if pinned list is full
if (pinnedPages.length >= PINNED_PAGES_LIMIT) return;
// Find existing entry in recent to preserve cached data
const existingRecent = recentPages.find((p) => p.id === id);
const newEntry: PageReference = {
...reference,
// Preserve cached data from recent page if available
cached: reference.cached ?? existingRecent?.cached,
lastVisited: Date.now(),
};
// Add to pinned, remove from recent if exists
const newPinned = [...pinnedPages, newEntry];
const newRecent = recentPages.filter((p) => p.id !== id);
set({ pinnedPages: newPinned, recentPages: newRecent }, false, 'pinPage');
savePinnedPages(newPinned);
},
removeRecentPage: (id) => {
const { recentPages } = get();
set({ recentPages: recentPages.filter((p) => p.id !== id) }, false, 'removeRecentPage');
},
unpinPage: (id) => {
const { pinnedPages, recentPages } = get();
const page = pinnedPages.find((p) => p.id === id);
if (!page) return;
const newPinned = pinnedPages.filter((p) => p.id !== id);
// Add back to recent (at the front)
const newRecent = [page, ...recentPages].slice(0, RECENT_PAGES_LIMIT);
set({ pinnedPages: newPinned, recentPages: newRecent }, false, 'unpinPage');
savePinnedPages(newPinned);
},
});

View File

@@ -8,6 +8,7 @@ import {
type NavigationHistoryState,
navigationHistoryInitialState,
} from './actions/navigationHistory';
import { type RecentPagesState, recentPagesInitialState } from './actions/recentPages';
export type RemoteServerError = 'CONFIG_ERROR' | 'AUTH_ERROR' | 'DISCONNECT_ERROR';
@@ -20,7 +21,7 @@ export const defaultProxySettings: NetworkProxySettings = {
proxyType: 'http',
};
export interface ElectronState extends NavigationHistoryState {
export interface ElectronState extends NavigationHistoryState, RecentPagesState {
appState: ElectronAppState;
dataSyncConfig: DataSyncConfig;
desktopHotkeys: Record<string, string>;
@@ -36,6 +37,7 @@ export interface ElectronState extends NavigationHistoryState {
export const initialState: ElectronState = {
...navigationHistoryInitialState,
...recentPagesInitialState,
appState: {},
dataSyncConfig: { storageMode: 'cloud' },
desktopHotkeys: {},

View File

@@ -8,6 +8,7 @@ import {
type NavigationHistoryAction,
createNavigationHistorySlice,
} from './actions/navigationHistory';
import { type RecentPagesAction, createRecentPagesSlice } from './actions/recentPages';
import { type ElectronSettingsAction, settingsSlice } from './actions/settings';
import { type ElectronRemoteServerAction, remoteSyncSlice } from './actions/sync';
import { type ElectronState, initialState } from './initialState';
@@ -20,7 +21,8 @@ export interface ElectronStore
ElectronRemoteServerAction,
ElectronAppAction,
ElectronSettingsAction,
NavigationHistoryAction {
NavigationHistoryAction,
RecentPagesAction {
/* empty */
}
@@ -32,6 +34,7 @@ const createStore: StateCreator<ElectronStore, [['zustand/devtools', never]]> =
...createElectronAppSlice(...parameters),
...settingsSlice(...parameters),
...createNavigationHistorySlice(...parameters),
...createRecentPagesSlice(...parameters),
});
// =============== Implement useStore ============ //